Merge branch 'master' into chore/ai-persistence

This commit is contained in:
Saxon Fletcher committed 2025-12-15 15:32:52 +10:00
commit d5cfac7b86
193 files changed
+9455 -3115

No files matched your search

-11
View File
@@ -38,17 +38,6 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"ConfirmDialog": {
name: "ConfirmDialog",
type: "components:fragment",
registryDependencies: undefined,
component: React.lazy(() => import("@/../../packages/ui-patterns/src/Dialogs/ConfirmDialog")),
source: "",
files: ["registry/default//Dialogs/ConfirmDialog.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"PageContainer": {
name: "PageContainer",
type: "components:fragment",
@@ -4,7 +4,7 @@ import { SourcePanel } from '@/components/source-panel'
import { DashboardTableOfContents } from '@/components/toc'
import { siteConfig } from '@/config/site'
import { getTableOfContents } from '@/lib/toc'
import { absoluteUrl, cn } from '@/lib/utils'
import { absoluteUrl } from '@/lib/utils'
import '@/styles/code-block-variables.css'
import '@/styles/mdx.css'
import { allDocs } from 'contentlayer/generated'
@@ -83,15 +83,15 @@ export default async function DocPage(props: DocPageProps) {
const toc = await getTableOfContents(doc.body.raw)
return (
<main className="relative lg:gap-10 xl:grid xl:grid-cols-[1fr_200px] pr-6 lg:py-8">
<div className="mx-auto w-full min-w-0 max-w-4xl">
<div className="relative xl:grid xl:grid-cols-[1fr_160px] gap-4 px-6 py-6 lg:py-8">
<div className="mx-auto w-full min-w-0 max-w-4xl flex-1">
<div className="mb-4 flex items-center space-x-1 text-sm text-foreground-muted">
<div className="overflow-hidden text-ellipsis whitespace-nowrap">Docs</div>
<ChevronRight className="h-4 w-4 text-foreground-muted" />
<div className="text-foreground-lighter">{doc.title}</div>
</div>
<div className="space-y-2 mb-5">
<h1 className={cn('scroll-m-20 text-4xl tracking-tight')}>{doc.title}</h1>
<h1 className="scroll-m-20 text-4xl tracking-tight">{doc.title}</h1>
{doc.description && (
<p className="text-lg text-foreground-light">
<Balancer>{doc.description}</Balancer>
@@ -107,15 +107,15 @@ export default async function DocPage(props: DocPageProps) {
</div>
{doc.toc && (
<div className="hidden text-sm xl:block">
<div className="sticky top-16 -mt-10 pt-4">
<div className="sticky top-8 -mt-10 pt-8">
<ScrollArea className="pb-10">
<div className="sticky top-16 -mt-10 h-[calc(100vh-3.5rem)] py-12">
<div className="h-[calc(100vh-3.5rem)] py-14">
<DashboardTableOfContents toc={toc} />
</div>
</ScrollArea>
</div>
</div>
)}
</main>
</div>
)
}
+15 -21
View File
@@ -1,34 +1,28 @@
import SideNavigation from '@/components/side-navigation'
import { SideNavigation } from '@/components/side-navigation'
import { SiteFooter } from '@/components/site-footer'
// import ThemeSettings from '@/components/theme-settings'
import TopNavigation from '@/components/top-navigation'
import { TopNavigation } from '@/components/top-navigation'
import { MobileSidebarSheet } from '@/components/mobile-sidebar-sheet'
import { ScrollArea } from 'ui'
interface AppLayoutProps {
children: React.ReactNode
}
export default function AppLayout({ children }: AppLayoutProps) {
export default async function AppLayout({ children }: AppLayoutProps) {
return (
<>
<TopNavigation />
{/* main container */}
<div className="">
{/* main content */}
<main className="flex-1 max-w-site mx-auto w-full border-l border-r">
{/* {children} */}
<div className="border-b">
<div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-6 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
<aside className="fixed top-10 z-30 hidden h-[calc(100vh-3rem)] w-full shrink-0 md:sticky md:block border-r">
<ScrollArea className="h-full py-6 lg:py-8">
<SideNavigation />
</ScrollArea>
</aside>
{children}
</div>
</div>
</main>
</div>
<MobileSidebarSheet />
<main className="flex-1 max-w-site mx-auto w-full border-l border-r border-b">
<div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] lg:grid-cols-[240px_minmax(0,1fr)]">
<aside className="fixed top-10 z-30 hidden h-[calc(100vh-3rem)] w-full shrink-0 md:sticky md:block border-r">
<ScrollArea className="h-full">
<SideNavigation />
</ScrollArea>
</aside>
{children}
</div>
</main>
<SiteFooter />
</>
)
+52 -72
View File
@@ -1,85 +1,59 @@
import { HomepageSvgHandler } from '@/components/homepage-svg-handler'
import Link from 'next/link'
import { Paintbrush } from 'lucide-react'
import { Realtime, Database, Auth } from 'icons/src/icons'
export default function Home() {
return (
<main className="relative lg:gap-10 pr-6 lg:py-20">
<div className="mx-auto w-full min-w-0 max-w-6xl flex flex-col gap-20">
<div className="flex flex-col gap-8 justify-start">
{/* <div>
<DesignSystemMarks />
</div> */}
<div>
<h1 className="text-4xl text-foreground mb-3">Supabase Design System</h1>
<h2 className="text-xl text-foreground-light font-light">
Design resources for building consistent user experiences
</h2>
</div>
<div className="relative gap-4 px-6 py-6 lg:py-8">
<div className="mx-auto w-full min-w-0 max-w-4xl flex-1">
<div className="flex flex-col gap-2 justify-start my-8 w-full">
<h1 className="text-2xl lg:text-4xl text-foreground">Supabase Design System</h1>
<h2 className="md:text-xl text-base text-foreground-light font-light">
Design resources for building consistent user experiences
</h2>
</div>
{/* Homepage items */}
<div className="grid grid-cols-2 gap-10">
<Link href="/docs/components/atom-components">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<HomepageSvgHandler name="atoms-illustration" />
<div className="grid md:grid-cols-2 grid-cols-1 gap-4 w-full">
<Link href="/docs/components/atom-components" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px]">
<HomepageSvgHandler name="atoms" className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium text-foreground">Atom components</h3>
<p className="text-sm text-foreground-light">Building blocks of User interfaces</p>
</div>
</div>
</Link>
<Link href="/docs/components/fragment-components">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<HomepageSvgHandler name="fragments-illustration" />
<Link href="/docs/fragments/fragment-components" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px]">
<HomepageSvgHandler name="fragments" className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium text-foreground">Fragment components</h3>
<p className="text-sm text-foreground-light">Components assembled from Atoms</p>
</div>
</div>
</Link>
<Link href="/docs/color-usage">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex flex-col gap-1">
<div className="flex flex-row gap-0.5">
<div className="w-3 h-16 rounded border bg-foreground"></div>
<div className="w-3 h-16 rounded border bg-foreground-light"></div>
<div className="w-3 h-16 rounded border bg-foreground-lighter"></div>
<div className="w-3 h-16 rounded border bg-foreground-muted"></div>
<div className="w-3 h-16 rounded border"></div>
<div className="w-3 h-16 rounded border bg"></div>
<div className="w-3 h-16 rounded border bg-200"></div>
<div className="w-3 h-16 rounded border bg-surface-75"></div>
<div className="w-3 h-16 rounded border bg-surface-100"></div>
<div className="w-3 h-16 rounded border bg-surface-200"></div>
<div className="w-3 h-16 rounded border bg-surface-300"></div>
<div className="w-3 h-16 rounded border"></div>
<div className="w-3 h-16 rounded border bg-brand-200"></div>
<div className="w-3 h-16 rounded border bg-brand-300"></div>
<div className="w-3 h-16 rounded border bg-brand-400"></div>
<div className="w-3 h-16 rounded border bg-brand-500"></div>
<div className="w-3 h-16 rounded border bg-brand"></div>
<div className="w-3 h-16 rounded border bg-brand-600"></div>
<div className="w-3 h-16 rounded border"></div>
<div className="w-3 h-16 rounded border bg-warning-200"></div>
<div className="w-3 h-16 rounded border bg-warning-300"></div>
<div className="w-3 h-16 rounded border bg-warning-400"></div>
<div className="w-3 h-16 rounded border bg-warning-500"></div>
<div className="w-3 h-16 rounded border bg-warning"></div>
<div className="w-3 h-16 rounded border bg-warning-600"></div>
<div className="w-3 h-16 rounded border"></div>
<div className="w-3 h-16 rounded border bg-destructive-200"></div>
<div className="w-3 h-16 rounded border bg-destructive-300"></div>
<div className="w-3 h-16 rounded border bg-destructive-400"></div>
<div className="w-3 h-16 rounded border bg-destructive-500"></div>
<div className="w-3 h-16 rounded border bg-destructive"></div>
<div className="w-3 h-16 rounded border bg-destructive-600"></div>
<div className="w-3 h-16 rounded border"></div>
</div>
<Link href="/docs/components/ui-patterns" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px]">
<HomepageSvgHandler name="ui-patterns" className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium text-foreground">UI Patterns components</h3>
<p className="text-sm text-foreground-light">
Components assembled from Atoms &amp; Fragments
</p>
</div>
</div>
</Link>
<Link href="/docs/color-usage" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex flex-col gap-2 min-h-[24px]">
<HomepageSvgHandler name="colours" className="w-6 h-6" />
</div>
<div>
<h3 className="font-medium text-foreground">Colors</h3>
@@ -88,27 +62,33 @@ export default function Home() {
</div>
</Link>
<Link href="/docs/theming">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<HomepageSvgHandler name="fragments-illustration" />
<Link href="/docs/theming" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px] text-brand">
<Paintbrush className="w-6 h-6" strokeWidth={1.5} stroke="currentColor" />
</div>
<div>
<h3 className="font-medium text-foreground">Theming</h3>
<p className="text-sm text-foreground-light">Components assembled from Atoms</p>
<p className="text-sm text-foreground-light">Simple extensible theming system</p>
</div>
</div>
</Link>
<Link href="/docs/icons">
<div className="px-10 py-8 min-h-[18rem] flex flex-col justify-between bg-surface-75/50 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<HomepageSvgHandler name="fragments-illustration" />
<Link href="/docs/icons" className="h-full flex">
<div className="p-6 gap-4 flex flex-col justify-between h-full w-full bg-surface-75 hover:bg-overlay/50 hover:border-foreground-muted cursor-pointer transition-all border rounded-md">
<div className="flex items-center justify-start min-h-[24px] gap-3 text-brand">
<Realtime className="w-5 h-5" strokeWidth={1.5} stroke="currentColor" />
<Database className="w-5 h-5 opacity-60" strokeWidth={1.5} stroke="currentColor" />
<Auth className="w-5 h-5 opacity-30" strokeWidth={1.5} stroke="currentColor" />
</div>
<div>
<h3 className="font-medium text-foreground">Icons</h3>
<p className="text-sm text-foreground-light">Components assembled from Atoms</p>
<p className="text-sm text-foreground-light">Custom icons for Supabase</p>
</div>
</div>
</Link>
</div>
</div>
</main>
</div>
)
}
+4 -1
View File
@@ -5,12 +5,15 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { ThemeProviderProps } from 'next-themes/dist/types'
import { TooltipProvider } from 'ui'
import { MobileSidebarProvider } from '@/context/mobile-sidebar-context'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<JotaiProvider>
<NextThemesProvider {...props}>
<TooltipProvider delayDuration={0}>{children}</TooltipProvider>
<TooltipProvider delayDuration={0}>
<MobileSidebarProvider>{children}</MobileSidebarProvider>
</TooltipProvider>
</NextThemesProvider>
</JotaiProvider>
)
+11 -19
View File
@@ -1,6 +1,6 @@
'use client'
import { CircleIcon, LaptopIcon, MoonIcon, SunIcon } from 'lucide-react'
import { CircleIcon, LaptopIcon, MoonIcon, SunIcon, Search } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useRouter } from 'next/navigation'
import * as React from 'react'
@@ -17,6 +17,7 @@ import {
CommandList_Shadcn_,
CommandSeparator_Shadcn_,
DialogProps,
DialogTitle,
} from 'ui'
export function CommandMenu({ ...props }: DialogProps) {
@@ -55,8 +56,7 @@ export function CommandMenu({ ...props }: DialogProps) {
<Button
type="outline"
className={cn(
`relative h-8 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-foreground-muted shadow-none sm:pr-12 md:w-40 lg:w-64
hover:border-foreground-muted hover:bg-surface-100 hover:text-foreground-lighter
`lg:flex hidden relative h-8 w-full justify-start rounded-[0.5rem] bg-background text-sm font-normal text-foreground-muted shadow-none sm:pr-12 md:w-40 lg:w-64 hover:border-foreground-muted hover:bg-surface-100 hover:text-foreground-lighter
`
)}
onClick={() => setOpen(true)}
@@ -68,26 +68,18 @@ export function CommandMenu({ ...props }: DialogProps) {
<span className="text-sm">⌘</span>K
</kbd>
</Button>
<Button
type="text"
size="tiny"
className="px-1 group lg:hidden"
onClick={() => setOpen(true)}
icon={<Search size={16} />}
/>
<CommandDialog open={open} onOpenChange={setOpen}>
<DialogTitle className="sr-only">Search Design System...</DialogTitle>
<CommandInput_Shadcn_ placeholder="Type a command or search..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
{/* <CommandGroup_Shadcn_ heading="Links">
{docsConfig.mainNav
.filter((navitem) => !navitem.external)
.map((navItem) => (
<CommandItem_Shadcn_
key={navItem.href}
value={navItem.title}
onSelect={() => {
runCommand(() => router.push(navItem.href as string))
}}
>
<FileIcon className="mr-2 h-4 w-4" strokeWidth={1} />
{navItem.title}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_> */}
{docsConfig.sidebarNav.map((group) => (
<CommandGroup_Shadcn_ key={group.title} heading={group.title}>
{group.items.map((navItem) => (
@@ -0,0 +1,20 @@
'use client'
import { SideNavigation } from '@/components/side-navigation'
import { useMobileSidebar } from '@/hooks/use-mobile-sidebar'
import { Sheet, SheetContent, SheetTitle, ScrollArea } from 'ui'
export function MobileSidebarSheet() {
const { open, setOpen } = useMobileSidebar()
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="left" className="p-0 w-[240px] sm:w-[280px]" showClose={false}>
<SheetTitle className="sr-only">Navigation</SheetTitle>
<ScrollArea className="h-full py-6 lg:py-8">
<SideNavigation />
</ScrollArea>
</SheetContent>
</Sheet>
)
}
+3 -2
View File
@@ -4,7 +4,6 @@ import { NavItem, NavItemWithChildren } from '@/types/nav'
import { docsConfig } from '@/config/docs'
import { cn } from '@/lib/utils'
import { buttonVariants } from 'ui'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface DocsPagerProps {
@@ -18,8 +17,10 @@ export function DocsPager({ doc }: DocsPagerProps) {
return null
}
const hasBoth = pager?.prev?.href && pager?.next?.href
return (
<div className="flex flex-row items-center justify-between">
<div className={cn('flex flex-row items-center', hasBoth ? 'justify-between' : 'justify-end')}>
{pager?.prev?.href && (
<Link
href={pager.prev.href}
@@ -5,15 +5,22 @@ import { cn } from 'ui/src/lib/utils/cn'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import React from 'react'
import { useMobileSidebar } from '@/hooks/use-mobile-sidebar'
const NavigationItem: React.FC<{ item: SidebarNavItem }> = React.memo(({ item }) => {
export const NavigationItem: React.FC<{ item: SidebarNavItem }> = React.memo(({ item }) => {
const pathname = usePathname()
const { setOpen } = useMobileSidebar()
const isActive = pathname === item.href
const handleClick = () => {
setOpen(false)
}
return (
<Link
href={`${item.href}`}
onClick={handleClick}
className={cn(
'relative',
'flex',
@@ -40,5 +47,3 @@ const NavigationItem: React.FC<{ item: SidebarNavItem }> = React.memo(({ item })
})
NavigationItem.displayName = 'NavigationItem'
export default NavigationItem
@@ -1,9 +1,9 @@
import NavigationItem from '@/components/side-navigation-item'
import { NavigationItem } from '@/components/side-navigation-item'
import { docsConfig } from '@/config/docs'
function SideNavigation() {
export const SideNavigation = () => {
return (
<nav className="min-w-[220px]">
<nav className="min-w-[220px] py-6 lg:py-8">
{docsConfig.sidebarNav.map((section, i) => (
<div key={`${section.title}-${i}`} className="pb-10 space-y-0.5">
<div className="font-mono uppercase text-xs text-foreground-lighter/75 mb-2 px-6 tracking-widest">
@@ -26,5 +26,3 @@ function SideNavigation() {
</nav>
)
}
export default SideNavigation
@@ -2,8 +2,8 @@ import { siteConfig } from '@/config/site'
export function SiteFooter() {
return (
<footer className="py-6 md:px-8 md:py-0 mx-auto border-r border-l border-b w-full max-w-site">
<div className="flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row">
<footer className="py-6 px-6 mx-auto border-r border-l border-b w-full max-w-site">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<p className="text-balance text-center text-sm leading-loose text-foreground-muted md:text-left">
Built by{' '}
<a
@@ -3,7 +3,6 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import SVG from 'react-inlinesvg'
import {
Button,
DropdownMenu,
@@ -13,8 +12,6 @@ import {
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
RadioGroupLargeItem_Shadcn_,
RadioGroup_Shadcn_,
Theme,
singleThemes,
} from 'ui'
@@ -36,22 +33,7 @@ const ThemeSwitcherDropdown = () => {
return null
}
function SingleThemeSelection() {
return (
<form>
<RadioGroup_Shadcn_
name="theme"
onValueChange={setTheme}
aria-label="Choose a theme"
defaultValue={theme}
value={theme}
className="flex flex-wrap gap-3"
></RadioGroup_Shadcn_>
</form>
)
}
const iconClasses = 'text-foreground-light group-data-[state=open]:text-foreground'
const iconClasses = 'text-foreground-lighter group-data-[state=open]:text-foreground'
return (
<>
@@ -68,7 +50,7 @@ const ThemeSwitcherDropdown = () => {
<Moon className={iconClasses} />
)
}
></Button>
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>Theme</DropdownMenuLabel>
@@ -1,30 +1,37 @@
// import { docsConfig } from '@/config/docs'
'use client'
import Link from 'next/link'
import { Menu } from 'lucide-react'
import { Button } from 'ui'
import { CommandMenu } from './command-menu'
import { HomepageSvgHandler } from './homepage-svg-handler'
import { ThemeSwitcherDropdown } from './theme-switcher-dropdown'
import { useMobileSidebar } from '@/hooks/use-mobile-sidebar'
export const TopNavigation = () => {
const { toggle } = useMobileSidebar()
function TopNavigation() {
return (
<header className="sticky top-0 z-50 w-full border-t bg-studio/95 backdrop-blur supports-[backdrop-filter]:bg-studio/60">
<div className="absolute border-b border-dashed w-full top-[3.4rem] -z-10"></div>
<nav className="h-14 w-full flex">
<div className="max-w-site border-b w-full flex flex-row items-center gap-6 mx-auto px-6 border-r border-l justify-between">
<div className="flex items-center gap-8">
<Link href="/">
<h1>Supabase Design System</h1>
<header className="sticky top-0 z-50 w-full bg-studio/95 backdrop-blur supports-[backdrop-filter]:bg-studio/60 border-b border-l border-r">
<nav className="py-3 w-full flex">
<div className="max-w-site w-full flex flex-row items-center gap-6 mx-auto md:px-6 px-4 justify-between">
<div className="flex items-center gap-2">
<Button
type="text"
size="tiny"
className="px-1 md:hidden"
onClick={toggle}
icon={<Menu size={16} />}
aria-label="Toggle navigation menu"
/>
<Link href="/" className="flex items-center lg:gap-6 gap-4">
<h1 className="hidden md:flex line-clamp-1 lg:text-2xl text-xl">
Supabase Design System
</h1>
<HomepageSvgHandler name="logo" className="h-4 w-auto" />
</Link>
<HomepageSvgHandler name="design-system-marks" className="h-4 w-auto" />
</div>
{/* {docsConfig.mainNav.map((section) => (
<>
<div className="font-mono uppercase text-xs text-foreground-lighter">
{section.title}
</div>
</>
))} */}
<div className="flex items-center gap-8">
{/* <TopNavigationSearch /> */}
<div className="flex items-center gap-2">
<CommandMenu />
<ThemeSwitcherDropdown />
</div>
@@ -33,5 +40,3 @@ function TopNavigation() {
</header>
)
}
export default TopNavigation
+139 -139
View File
@@ -48,145 +48,6 @@ export const docsConfig: DocsConfig = {
},
],
},
{
title: 'UI Patterns',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs/ui-patterns/ui-patterns',
items: [],
priority: true,
},
{
title: 'Charts',
href: '/docs/ui-patterns/charts',
items: [],
},
{
title: 'Empty States',
href: '/docs/ui-patterns/empty-states',
items: [],
},
{
title: 'Forms',
href: '/docs/ui-patterns/forms',
items: [],
},
{
title: 'Layout',
href: '/docs/ui-patterns/layout',
items: [],
},
{
title: 'Navigation',
href: '/docs/ui-patterns/navigation',
items: [],
},
{
title: 'Tables',
href: '/docs/ui-patterns/tables',
items: [],
},
],
},
{
title: 'Fragment Components',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs/fragments/fragment-components',
items: [],
priority: true,
},
{
title: 'Admonition',
href: '/docs/fragments/admonition',
items: [],
},
{
title: 'Assistant Chat',
href: '/docs/fragments/assistant-chat',
items: [],
},
{
title: 'EmptyStatePresentational',
href: '/docs/fragments/empty-state-presentational',
items: [],
},
{
title: 'Modal',
href: '/docs/fragments/modal',
items: [],
},
{
title: 'Page Container',
href: '/docs/fragments/page-container',
items: [],
},
{
title: 'Page Header',
href: '/docs/fragments/page-header',
items: [],
},
{
title: 'Page Section',
href: '/docs/fragments/page-section',
items: [],
},
{
title: 'Text Confirm Dialog',
href: '/docs/fragments/text-confirm-dialog',
items: [],
},
{
title: 'Info Tooltip',
href: '/docs/fragments/info-tooltip',
items: [],
},
{
title: 'Inner Side Menu',
href: '/docs/fragments/inner-side-menu',
items: [],
},
{
title: 'Data Input',
href: '/docs/fragments/data-input',
items: [],
},
{
title: 'Form Item Layout',
href: '/docs/fragments/form-item-layout',
items: [],
},
{
title: 'Multi Select',
href: '/docs/fragments/multi-select',
items: [],
},
{
title: 'Filter Bar',
href: '/docs/fragments/filter-bar',
items: [],
},
{
title: 'Logs Bar Chart',
href: '/docs/fragments/logs-bar-chart',
items: [],
},
{
title: 'Metric Card',
href: '/docs/fragments/metric-card',
items: [],
},
{
title: 'Table of Contents (TOC)',
href: '/docs/fragments/toc',
items: [],
},
],
},
{
title: 'Atom Components',
sortOrder: 'alphabetical',
@@ -477,5 +338,144 @@ export const docsConfig: DocsConfig = {
},
],
},
{
title: 'Fragment Components',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs/fragments/fragment-components',
items: [],
priority: true,
},
{
title: 'Admonition',
href: '/docs/fragments/admonition',
items: [],
},
{
title: 'Assistant Chat',
href: '/docs/fragments/assistant-chat',
items: [],
},
{
title: 'EmptyStatePresentational',
href: '/docs/fragments/empty-state-presentational',
items: [],
},
{
title: 'Modal',
href: '/docs/fragments/modal',
items: [],
},
{
title: 'Page Container',
href: '/docs/fragments/page-container',
items: [],
},
{
title: 'Page Header',
href: '/docs/fragments/page-header',
items: [],
},
{
title: 'Page Section',
href: '/docs/fragments/page-section',
items: [],
},
{
title: 'Text Confirm Dialog',
href: '/docs/fragments/text-confirm-dialog',
items: [],
},
{
title: 'Info Tooltip',
href: '/docs/fragments/info-tooltip',
items: [],
},
{
title: 'Inner Side Menu',
href: '/docs/fragments/inner-side-menu',
items: [],
},
{
title: 'Data Input',
href: '/docs/fragments/data-input',
items: [],
},
{
title: 'Form Item Layout',
href: '/docs/fragments/form-item-layout',
items: [],
},
{
title: 'Multi Select',
href: '/docs/fragments/multi-select',
items: [],
},
{
title: 'Filter Bar',
href: '/docs/fragments/filter-bar',
items: [],
},
{
title: 'Logs Bar Chart',
href: '/docs/fragments/logs-bar-chart',
items: [],
},
{
title: 'Metric Card',
href: '/docs/fragments/metric-card',
items: [],
},
{
title: 'Table of Contents (TOC)',
href: '/docs/fragments/toc',
items: [],
},
],
},
{
title: 'UI Patterns',
sortOrder: 'alphabetical',
items: [
{
title: 'Introduction',
href: '/docs/ui-patterns/ui-patterns',
items: [],
priority: true,
},
{
title: 'Charts',
href: '/docs/ui-patterns/charts',
items: [],
},
{
title: 'Empty States',
href: '/docs/ui-patterns/empty-states',
items: [],
},
{
title: 'Forms',
href: '/docs/ui-patterns/forms',
items: [],
},
{
title: 'Layout',
href: '/docs/ui-patterns/layout',
items: [],
},
{
title: 'Navigation',
href: '/docs/ui-patterns/navigation',
items: [],
},
{
title: 'Tables',
href: '/docs/ui-patterns/tables',
items: [],
},
],
},
],
}
@@ -42,6 +42,11 @@ A [Table](../components/table) instance with zero results should display a singl
<ComponentPreview name="empty-state-zero-items-table" peekCode wide />
Studio contains two pre-built components to handle these cases consistently:
- No Filter Results
- No Search Results
#### Data Grid
[Data Grid](../ui-patterns/tables#data-grid) and [Data Table](../ui-patterns/tables#data-table) component patterns typically span the full height and width of a container. A classic example is [Users](https://supabase.com/dashboard/project/_/auth/users), which (as it sounds) displays a list of the project’s registered users. Any instance with zero results should display a more prominent empty with a clear title, description, and supporting illustration.
@@ -0,0 +1,34 @@
'use client'
import React, { ReactNode, useState, useCallback } from 'react'
import { MobileSidebarContext, MobileSidebarContextType } from '@/hooks/use-mobile-sidebar'
interface MobileSidebarProviderProps {
children: ReactNode
}
/**
* Provider component for mobile sidebar state
* Wraps children with context that provides mobile sidebar state and controls
*/
export function MobileSidebarProvider({ children }: MobileSidebarProviderProps) {
const [open, setOpen] = useState(false)
// Use useCallback for stable function references
const handleSetOpen = useCallback((value: boolean) => {
setOpen(value)
}, [])
const toggle = useCallback(() => {
setOpen((prev) => !prev)
}, [])
// Memoize the context value to prevent unnecessary re-renders
const value: MobileSidebarContextType = {
open,
setOpen: handleSetOpen,
toggle,
}
return <MobileSidebarContext.Provider value={value}>{children}</MobileSidebarContext.Provider>
}
+1 -1
View File
@@ -4,7 +4,7 @@ const supabaseConfig = require('eslint-config-supabase/next')
module.exports = defineConfig([
supabaseConfig,
{
files: ['registry/**/*.tsx', '__registry__/**/*.tsx'],
files: ['registry/**/*.tsx', '__registry__/**/*.tsx', 'app/**/*.tsx'],
rules: {
'no-restricted-exports': 'off',
},
@@ -0,0 +1,24 @@
'use client'
import { createContext, useContext } from 'react'
export interface MobileSidebarContextType {
open: boolean
setOpen: (open: boolean) => void
toggle: () => void
}
export const MobileSidebarContext = createContext<MobileSidebarContextType | null>(null)
export function useMobileSidebar(): MobileSidebarContextType {
const context = useContext(MobileSidebarContext)
if (context === null) {
throw new Error(
'useMobileSidebar must be used within a MobileSidebarProvider. ' +
'Ensure your component is wrapped with MobileSidebarProvider.'
)
}
return context
}
@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3733_538" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3733_538)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 48L48 0L0 48L48 96L96 48ZM7.54099 48L48 7.54133L88.459 48L48 88.4587L7.54099 48Z" fill="#B4B4B4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 459 B

@@ -0,0 +1 @@
<svg fill="none" height="96" viewBox="0 0 96 96" width="96" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><mask id="a" height="96" maskUnits="userSpaceOnUse" width="96" x="0" y="0"><path d="m96 0h-96v96h96z" fill="#fff"/></mask><g mask="url(#a)"><path clip-rule="evenodd" d="m96 48-48-48-48 48 48 48zm-88.45901 0 40.45901-40.45867 40.459 40.45867-40.459 40.4587z" fill="#16b674" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 442 B

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3733_544" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3733_544)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M96 48L48 0L0 48L48 96L96 48ZM7.54099 48L48 7.54133L88.459 48L48 88.4587L7.54099 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 459 B

@@ -0,0 +1,6 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 48L3.771 51.7707L17.227 65.2267L21.003 68.9973L24.773 65.2267L38.229 51.7707L42 48L38.229 44.2293L24.773 30.7733L21.003 27.0027L17.227 30.7733L3.771 44.2293L0 48Z" fill="#E54D2E"/>
<path d="M54 48L57.771 51.7707L71.227 65.2267L75.003 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#FFB224"/>
<path d="M27 74.9973L30.771 78.768L44.227 92.224L48.003 95.9946L51.773 92.224L65.229 78.768L69 74.9973L65.229 71.2266L51.773 57.7706L48.003 54L44.227 57.7706L30.771 71.2266L27 74.9973Z" fill="#16B674"/>
<path d="M27 20.9973L30.771 24.768L44.227 38.224L48.003 41.9946L51.773 38.224L65.229 24.768L69 20.9973L65.229 17.2266L51.773 3.7706L48.003 0L44.227 3.7706L30.771 17.2266L27 20.9973Z" fill="#A0A0A0"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

@@ -0,0 +1,6 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 48L3.771 51.7707L17.227 65.2267L21.003 68.9973L24.773 65.2267L38.229 51.7707L42 48L38.229 44.2293L24.773 30.7733L21.003 27.0027L17.227 30.7733L3.771 44.2293L0 48Z" fill="#E54D2E"/>
<path d="M54 48L57.771 51.7707L71.227 65.2267L75.003 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#FFB224"/>
<path d="M27 74.9973L30.771 78.768L44.227 92.224L48.003 95.9946L51.773 92.224L65.229 78.768L69 74.9973L65.229 71.2266L51.773 57.7706L48.003 54L44.227 57.7706L30.771 71.2266L27 74.9973Z" fill="#16B674"/>
<path d="M27 20.9973L30.771 24.768L44.227 38.224L48.003 41.9946L51.773 38.224L65.229 24.768L69 20.9973L65.229 17.2266L51.773 3.7706L48.003 0L44.227 3.7706L30.771 17.2266L27 20.9973Z" fill="#A0A0A0"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

@@ -0,0 +1,6 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 48L3.771 51.7707L17.227 65.2267L21.003 68.9973L24.773 65.2267L38.229 51.7707L42 48L38.229 44.2293L24.773 30.7733L21.003 27.0027L17.227 30.7733L3.771 44.2293L0 48Z" fill="#E54D2E"/>
<path d="M54 48L57.771 51.7707L71.227 65.2267L75.003 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#FFB224"/>
<path d="M27 74.9973L30.771 78.768L44.227 92.224L48.003 95.9946L51.773 92.224L65.229 78.768L69 74.9973L65.229 71.2266L51.773 57.7706L48.003 54L44.227 57.7706L30.771 71.2266L27 74.9973Z" fill="#16B674"/>
<path d="M27 20.9973L30.771 24.768L44.227 38.224L48.003 41.9946L51.773 38.224L65.229 24.768L69 20.9973L65.229 17.2266L51.773 3.7706L48.003 0L44.227 3.7706L30.771 17.2266L27 20.9973Z" fill="#1C1C1C"/>
</svg>

After

Width:  |  Height:  |  Size: 896 B

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3807" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3807)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#B4B4B4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3855" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3855)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3855" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3855)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,26 @@
<svg width="570" height="96" viewBox="0 0 570 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_75_339" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_75_339)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#B4B4B4"/>
</g>
<mask id="mask1_75_339" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="158" y="0" width="96" height="96">
<path d="M254 0H158V96H254V0Z" fill="white"/>
</mask>
<g mask="url(#mask1_75_339)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#B4B4B4"/>
</g>
<mask id="mask2_75_339" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="316" y="0" width="96" height="96">
<path d="M412 0H316V96H412V0Z" fill="white"/>
</mask>
<g mask="url(#mask2_75_339)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#B4B4B4"/>
</g>
<mask id="mask3_75_339" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="474" y="0" width="96" height="96">
<path d="M570 0H474V96H570V0Z" fill="white"/>
</mask>
<g mask="url(#mask3_75_339)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#B4B4B4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,26 @@
<svg width="570" height="96" viewBox="0 0 570 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_75_361" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_75_361)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#15593B"/>
</g>
<mask id="mask1_75_361" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="158" y="0" width="96" height="96">
<path d="M254 0H158V96H254V0Z" fill="white"/>
</mask>
<g mask="url(#mask1_75_361)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#16B674"/>
</g>
<mask id="mask2_75_361" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="316" y="0" width="96" height="96">
<path d="M412 0H316V96H412V0Z" fill="white"/>
</mask>
<g mask="url(#mask2_75_361)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#15593B"/>
</g>
<mask id="mask3_75_361" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="474" y="0" width="96" height="96">
<path d="M570 0H474V96H570V0Z" fill="white"/>
</mask>
<g mask="url(#mask3_75_361)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,26 @@
<svg width="570" height="96" viewBox="0 0 570 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_75_340" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_75_340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.0027 21.0027L30.7733 24.7733L44.2293 38.2293L48 42L51.7707 38.2293L65.2267 24.7733L68.9973 21.0027L65.2267 17.2267L51.7707 3.77067L48 0L44.2293 3.77067L30.7733 17.2267L27.0027 21.0027ZM61.456 21.0027L48 34.4533L34.544 20.9973L48 7.54133L61.456 21.0027ZM27.0027 74.9973L30.7733 78.7733L44.2293 92.2293L48 96L51.7707 92.2293L65.2267 78.7733L68.9973 75.0027L65.2267 71.2267L51.7707 57.7707L48 54L44.2293 57.7707L30.7733 71.2267L27.0027 74.9973ZM61.456 74.9973L48 88.464L34.544 75.008L48 61.536L61.456 74.9973ZM3.77067 51.7707L0 48L3.77067 44.2293L17.2267 30.7733L21.0027 27.0027L24.7733 30.7733L38.2293 44.2293L42 48L38.2293 51.7707L24.7733 65.2267L21.0027 68.9973L17.2267 65.2267L3.77067 51.7707ZM20.9973 61.456L34.464 48L20.9973 34.544L7.54133 48L20.9973 61.456ZM54 48L57.7707 51.7707L71.2267 65.2267L74.9973 68.9973L78.7733 65.2267L92.2293 51.7707L96 48L92.2293 44.2293L78.7733 30.7733L75.0027 27.0027L71.2267 30.7733L57.7707 44.2293L54 48ZM88.4533 48L74.9973 61.456L61.5467 48L75.008 34.544L88.4533 48Z" fill="#16B674"/>
</g>
<mask id="mask1_75_340" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="158" y="0" width="96" height="96">
<path d="M254 0H158V96H254V0Z" fill="white"/>
</mask>
<g mask="url(#mask1_75_340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M254 48L206 0L158 48L206 96L254 48Z" fill="#16B674"/>
</g>
<mask id="mask2_75_340" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="316" y="0" width="96" height="96">
<path d="M412 0H316V96H412V0Z" fill="white"/>
</mask>
<g mask="url(#mask2_75_340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M412 48L364 0L316 48L364 96L412 48ZM323.541 48L364 7.54133L404.459 48L364 88.4587L323.541 48Z" fill="#16B674"/>
</g>
<mask id="mask3_75_340" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="474" y="0" width="96" height="96">
<path d="M570 0H474V96H570V0Z" fill="white"/>
</mask>
<g mask="url(#mask3_75_340)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M501.003 21.0027L504.773 24.7733L518.229 38.2293L522 42L525.771 38.2293L539.227 24.7733L542.997 21.0027L539.227 17.2267L525.771 3.77067L522 0L518.229 3.77067L504.773 17.2267L501.003 21.0027ZM501.003 74.9973L504.773 78.7733L518.229 92.2293L522 96L525.771 92.2293L539.227 78.7733L542.997 75.0027L539.227 71.2267L525.771 57.7707L522 54L518.229 57.7707L504.773 71.2267L501.003 74.9973ZM477.771 51.7707L474 48L477.771 44.2293L491.227 30.7733L495.003 27.0027L498.773 30.7733L512.229 44.2293L516 48L512.229 51.7707L498.773 65.2267L495.003 68.9973L491.227 65.2267L477.771 51.7707ZM528 48L531.771 51.7707L545.227 65.2267L548.997 68.9973L552.773 65.2267L566.229 51.7707L570 48L566.229 44.2293L552.773 30.7733L549.003 27.0027L545.227 30.7733L531.771 44.2293L528 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3812" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3812)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.003 21.0027L30.773 24.7733L44.229 38.2293L48 42L51.771 38.2293L65.227 24.7733L68.997 21.0027L65.227 17.2267L51.771 3.77067L48 0L44.229 3.77067L30.773 17.2267L27.003 21.0027ZM27.003 74.9973L30.773 78.7733L44.229 92.2293L48 96L51.771 92.2293L65.227 78.7733L68.997 75.0027L65.227 71.2267L51.771 57.7707L48 54L44.229 57.7707L30.773 71.2267L27.003 74.9973ZM3.771 51.7707L0 48L3.771 44.2293L17.227 30.7733L21.003 27.0027L24.773 30.7733L38.229 44.2293L42 48L38.229 51.7707L24.773 65.2267L21.003 68.9973L17.227 65.2267L3.771 51.7707ZM54 48L57.771 51.7707L71.227 65.2267L74.997 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#B4B4B4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3860" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3860)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.003 21.0027L30.773 24.7733L44.229 38.2293L48 42L51.771 38.2293L65.227 24.7733L68.997 21.0027L65.227 17.2267L51.771 3.77067L48 0L44.229 3.77067L30.773 17.2267L27.003 21.0027ZM27.003 74.9973L30.773 78.7733L44.229 92.2293L48 96L51.771 92.2293L65.227 78.7733L68.997 75.0027L65.227 71.2267L51.771 57.7707L48 54L44.229 57.7707L30.773 71.2267L27.003 74.9973ZM3.771 51.7707L0 48L3.771 44.2293L17.227 30.7733L21.003 27.0027L24.773 30.7733L38.229 44.2293L42 48L38.229 51.7707L24.773 65.2267L21.003 68.9973L17.227 65.2267L3.771 51.7707ZM54 48L57.771 51.7707L71.227 65.2267L74.997 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,8 @@
<svg width="96" height="96" viewBox="0 0 96 96" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_3732_3860" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="96" height="96">
<path d="M96 0H0V96H96V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_3732_3860)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.003 21.0027L30.773 24.7733L44.229 38.2293L48 42L51.771 38.2293L65.227 24.7733L68.997 21.0027L65.227 17.2267L51.771 3.77067L48 0L44.229 3.77067L30.773 17.2267L27.003 21.0027ZM27.003 74.9973L30.773 78.7733L44.229 92.2293L48 96L51.771 92.2293L65.227 78.7733L68.997 75.0027L65.227 71.2267L51.771 57.7707L48 54L44.229 57.7707L30.773 71.2267L27.003 74.9973ZM3.771 51.7707L0 48L3.771 44.2293L17.227 30.7733L21.003 27.0027L24.773 30.7733L38.229 44.2293L42 48L38.229 51.7707L24.773 65.2267L21.003 68.9973L17.227 65.2267L3.771 51.7707ZM54 48L57.771 51.7707L71.227 65.2267L74.997 68.9973L78.773 65.2267L92.229 51.7707L96 48L92.229 44.2293L78.773 30.7733L75.003 27.0027L71.227 30.7733L57.771 44.2293L54 48Z" fill="#16B674"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

-6
View File
@@ -19,12 +19,6 @@ export const fragments: Registry = [
files: ['/Dialogs/TextConfirmModal.tsx'],
optionalPath: '/Dialogs',
},
{
name: 'ConfirmDialog',
type: 'components:fragment',
files: ['/Dialogs/ConfirmDialog.tsx'],
optionalPath: '/Dialogs',
},
{
name: 'PageContainer',
type: 'components:fragment',
@@ -0,0 +1,78 @@
import { KJUR } from 'jsrsasign'
import { useState } from 'react'
import { Button, CodeBlock } from 'ui'
const JWT_HEADER = { alg: 'HS256', typ: 'JWT' }
const generateRandomString = (length: number) => {
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
const MAX = Math.floor(256 / CHARS.length) * CHARS.length - 1
const randomUInt8Array = new Uint8Array(1)
for (let i = 0; i < length; i++) {
let randomNumber: number
do {
crypto.getRandomValues(randomUInt8Array)
randomNumber = randomUInt8Array[0]
} while (randomNumber > MAX)
result += CHARS[randomNumber % CHARS.length]
}
return result
}
const generateKeys = () => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const fiveYears = new Date(now.getFullYear() + 5, now.getMonth(), now.getDate())
const iat = Math.floor(today.valueOf() / 1000)
const exp = Math.floor(fiveYears.valueOf() / 1000)
const anonToken = { role: 'anon', iss: 'supabase', iat, exp }
const serviceToken = { role: 'service_role', iss: 'supabase', iat, exp }
const secret = generateRandomString(40)
const anonKey = KJUR.jws.JWS.sign(null, JWT_HEADER, anonToken, secret)
const serviceRoleKey = KJUR.jws.JWS.sign(null, JWT_HEADER, serviceToken, secret)
return { secret, anonKey, serviceRoleKey }
}
export default function JwtGeneratorSimple() {
const [keys, setKeys] = useState(generateKeys)
const regenerate = () => {
setKeys(generateKeys())
}
return (
<div className="border rounded-lg p-4">
<div className="grid mb-8">
<label htmlFor="secret">JWT_SECRET</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.secret}
</CodeBlock>
</div>
<div className="grid mb-8">
<label htmlFor="anon">ANON_KEY</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.anonKey}
</CodeBlock>
</div>
<div className="grid mb-8">
<label htmlFor="service">SERVICE_ROLE_KEY</label>
<CodeBlock language="bash" className="relative font-mono">
{keys.serviceRoleKey}
</CodeBlock>
</div>
<Button type="primary" onClick={regenerate}>
Generate new
</Button>
</div>
)
}
+10 -1
View File
@@ -4,6 +4,7 @@ import dynamic from 'next/dynamic'
import { Suspense } from 'react'
const DynamicJwtGenerator = dynamic(() => import('./JwtGenerator'), { ssr: false })
const DynamicJwtGeneratorSimple = dynamic(() => import('./JwtGeneratorSimple'), { ssr: false })
const JwtGenerator = () => {
return (
@@ -13,4 +14,12 @@ const JwtGenerator = () => {
)
}
export { JwtGenerator }
const JwtGeneratorSimple = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<DynamicJwtGeneratorSimple />
</Suspense>
)
}
export { JwtGenerator, JwtGeneratorSimple }
@@ -496,7 +496,7 @@ export const gettingstarted: NavMenuConstant = {
},
{
name: 'Deploy MCP servers',
url: '/guides/getting-started/byom' as `/${string}`,
url: '/guides/getting-started/byo-mcp' as `/${string}`,
},
],
},
@@ -0,0 +1,44 @@
### Universal links
For a better user experience, you can use **Universal Links** instead of custom URL schemes. Universal Links allow your app to open directly from web links without showing a browser redirect prompt.
To enable Universal Links, you need to:
1. **Configure Associated Domains** in your Xcode project:
- Add your domain to the Associated Domains capability
- Format: `applinks:yourdomain.com`
2. **Host the Apple App Site Association (AASA) file** on your own infrastructure:
- The file must be accessible at `https://yourdomain.com/.well-known/apple-app-site-association` or `https://yourdomain.com/apple-app-site-association`
- The file must be served with `Content-Type: application/json` (or `text/json`)
- The file must be accessible over HTTPS without redirects
- The file should not have a file extension
<Admonition type="caution">
Supabase does not currently support hosting the AASA file. You must host this file on your own
infrastructure following [Apple's best
practices](https://developer.apple.com/documentation/xcode/supporting-associated-domains).
</Admonition>
The AASA file format should look like this:
```json
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAM_ID.BUNDLE_ID",
"paths": ["*"]
}
]
}
}
```
Replace `TEAM_ID` with your Apple Developer Team ID and `BUNDLE_ID` with your app's bundle identifier.
For detailed setup instructions, see [Apple's documentation on supporting associated domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains).
@@ -191,6 +191,8 @@ With Deep Linking, you can configure this redirect to open a specific page. This
For more info: https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app
<$Partial path="universal_links_apple.mdx" />
</TabPanel>
<TabPanel id="windows" label="Windows">
@@ -343,6 +345,8 @@ With Deep Linking, you can configure this redirect to open a specific page. This
</plist>
```
<$Partial path="universal_links_apple.mdx" />
</TabPanel>
</$Show>
<$Show if="sdk:kotlin">
@@ -494,7 +494,71 @@ Store the client secret securely. It will only be shown once. If you lose it, yo
</TabPanel>
<TabPanel id="programmatically" label="Programmatically">
You can register clients programmatically by calling your project's auth server admin endpoint with a secret key:
You can register clients programmatically using the SDK admin endpoints or by calling your project's auth server admin endpoint directly.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-service-role-key' // Use service_role key for admin operations
)
// Create an OAuth client
const { data, error } = await supabase.auth.admin.oauth.createClient({
name: 'My Third-Party App',
redirect_uris: ['https://my-app.com/auth/callback', 'https://my-app.com/auth/silent-callback'],
client_type: 'confidential',
})
if (error) {
console.error('Error creating client:', error)
} else {
console.log('Client created:', data)
console.log('Client ID:', data.client_id)
console.log('Client Secret:', data.client_secret) // Store this securely!
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
supabase = create_client(
'https://your-project.supabase.co',
'your-service-role-key' # Use service_role key for admin operations
)
# Create an OAuth client
response = supabase.auth.admin.oauth.create_client({
'name': 'My Third-Party App',
'redirect_uris': [
'https://my-app.com/auth/callback',
'https://my-app.com/auth/silent-callback'
],
'client_type': 'confidential'
})
print('Client created:', response)
print('Client ID:', response.client_id)
print('Client Secret:', response.client_secret) # Store this securely!
```
</TabPanel>
</$Show>
<TabPanel id="curl" label="cURL">
**Production:**
@@ -538,9 +602,12 @@ Response:
}
```
</TabPanel>
</Tabs>
<Admonition type="note">
For complete API documentation, see the [OAuth Admin API reference](/todo).
For complete API documentation, see the [OAuth Admin API reference](/docs/reference/javascript/auth-admin-oauth-admin).
</Admonition>
@@ -548,6 +615,39 @@ For complete API documentation, see the [OAuth Admin API reference](/todo).
To view all registered OAuth clients:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
const { data, error } = await supabase.auth.admin.oauth.listClients()
if (error) {
console.error('Error listing clients:', error)
} else {
console.log('OAuth clients:', data)
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
clients = supabase.auth.admin.oauth.list_clients()
print('OAuth clients:', clients)
```
</TabPanel>
</$Show>
<TabPanel id="curl" label="cURL">
**Production:**
```bash
@@ -565,6 +665,9 @@ curl 'http://localhost:54321/auth/v1/admin/oauth/clients' \
</TabPanel>
</Tabs>
</TabPanel>
</Tabs>
## Customizing tokens (optional)
By default, OAuth access tokens include standard claims like `user_id`, `role`, and `client_id`. If you need to customize tokens—for example, to set a specific `audience` claim for third-party validation or add client-specific metadata—use [Custom Access Token Hooks](/docs/guides/auth/auth-hooks/access-token-hook).
@@ -79,7 +79,7 @@ When building your own MCP server, integrate with Supabase Auth to authenticate
**Looking for an easier way to build MCP servers?**
[FastMCP](https://gofastmcp.com) provides a streamlined way to build MCP servers with built-in Supabase Auth integration. FastMCP handles OAuth configuration, token management, and authentication flows automatically, letting you focus on building your AI agent's functionality. Check out their [Supabase integration guide](https://gofastmcp.com/todo) to get started quickly.
[FastMCP](https://gofastmcp.com) provides a streamlined way to build MCP servers with built-in Supabase Auth integration. FastMCP handles OAuth configuration, token management, and authentication flows automatically, letting you focus on building your AI agent's functionality. Check out their [Supabase integration guide](https://gofastmcp.com/integrations/supabase) to get started quickly.
</Admonition>
@@ -1,13 +1,16 @@
---
id: 'ai-tools-byom'
id: 'ai-tools-byo-mcp'
title: 'Deploy MCP servers'
description: 'Build and deploy remote MCP servers on Supabase Edge Functions'
subtitle: 'Deploy custom MCP servers on Supabase Edge Functions'
---
Deploy your [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) (MCP) servers on Supabase take advantage of features like [Edge Functions](/docs/guides/functions), [OAuth](/docs/guides/auth/oauth-server), and scaling for AI applications.
Build and deploy [Model Context Protocol](https://modelcontextprotocol.io/specification/2025-11-25) (MCP) servers on Supabase using [Edge Functions](/docs/guides/functions).
- Get started with [deploying](#deploy-your-mcp-server) MCP servers on Supabase
<Admonition type="note">
This guide covers MCP servers that do not require authentication. Auth support for MCP on Edge Functions is coming soon.
</Admonition>
## Deploy your MCP server
@@ -34,17 +37,16 @@ supabase init
Create a new Edge Function for your MCP server:
```bash
supabase functions new simple-mcp-server
supabase functions new mcp
```
Create a `deno.json` file in `supabase/functions/simple-mcp-server/` with the required dependencies:
Create a `deno.json` file in `supabase/functions/mcp/` with the required dependencies:
```json
{
"imports": {
"@hono/mcp": "npm:@hono/mcp@^0.1.1",
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.24.3",
"@modelcontextprotocol/sdk/": "npm:/@modelcontextprotocol/sdk@^1.24.3/",
"hono": "npm:hono@^4.9.2",
"zod": "npm:zod@^4.1.13"
}
@@ -57,7 +59,7 @@ This tutorial uses the [official MCP TypeScript SDK](https://github.com/modelcon
</Admonition>
Replace the contents of `supabase/functions/simple-mcp-server/index.ts` with:
Replace the contents of `supabase/functions/mcp/index.ts` with:
```ts
// Setup type definitions for built-in Supabase Runtime APIs
@@ -68,14 +70,13 @@ import { StreamableHTTPTransport } from '@hono/mcp'
import { Hono } from 'hono'
import { z } from 'zod'
// Change this to your function name
const functionName = 'simple-mcp-server'
const app = new Hono().basePath(`/${functionName}`)
// Create Hono app
const app = new Hono()
// Create your MCP server
const server = new McpServer({
name: 'simple-mcp-server',
version: '1.0.0',
name: 'mcp',
version: '0.1.0',
})
// Register a simple addition tool
@@ -91,8 +92,8 @@ server.registerTool(
})
)
// Handle MCP requests
app.all('/mcp', async (c) => {
// Handle MCP requests at the root path
app.all('/', async (c) => {
const transport = new StreamableHTTPTransport()
await server.connect(transport)
return transport.handleRequest(c)
@@ -101,52 +102,6 @@ app.all('/mcp', async (c) => {
Deno.serve(app.fetch)
```
### Understanding the code
The MCP server implementation uses several key components:
**Hono routing**: Supabase Edge Functions route all requests to `/<function-name>/*`. The Hono app uses `basePath` to handle this:
```ts
const functionName = 'simple-mcp-server'
const app = new Hono().basePath(`/${functionName}`)
```
**MCP server setup**: The `McpServer` class from the official SDK handles the MCP protocol:
```ts
const server = new McpServer({
name: 'simple-mcp-server',
version: '1.0.0',
})
```
**Tool registration**: Tools are registered with a name, metadata, input schema (using Zod), and a handler function:
```ts
server.registerTool(
'add',
{
title: 'Addition Tool',
description: 'Add two numbers together',
inputSchema: { a: z.number(), b: z.number() },
},
({ a, b }) => ({
content: [{ type: 'text', text: String(a + b) }],
})
)
```
**HTTP transport**: The `StreamableHTTPTransport` from `@hono/mcp` connects your MCP server to HTTP requests:
```ts
app.all('/mcp', async (c) => {
const transport = new StreamableHTTPTransport()
await server.connect(transport)
return transport.handleRequest(c)
})
```
### Local development
Start the Supabase local development stack:
@@ -158,18 +113,18 @@ supabase start
In a separate terminal, serve your function:
```bash
supabase functions serve --no-verify-jwt simple-mcp-server
supabase functions serve --no-verify-jwt mcp
```
Your MCP server is now running at:
```
http://localhost:54321/functions/v1/simple-mcp-server/mcp
http://localhost:54321/functions/v1/mcp
```
<Admonition type="note">
The `--no-verify-jwt` flag disables JWT verification at the Edge Function layer. This is required because MCP authentication is handled by the MCP server itself, not by Supabase's standard JWT validation.
The `--no-verify-jwt` flag disables JWT verification at the Edge Function layer so your MCP server can accept unauthenticated requests. Authenticated MCP support is coming soon.
</Admonition>
@@ -181,7 +136,7 @@ Test your server with the official [MCP Inspector](https://github.com/modelconte
npx -y @modelcontextprotocol/inspector
```
Enter your MCP endpoint URL in the inspector UI to explore available tools and test them interactively.
Use the local endpoint `http://localhost:54321/functions/v1/mcp` in the inspector UI to explore available tools and test them interactively.
### Deploy to production
@@ -189,13 +144,13 @@ When you're ready to deploy, link your project and deploy the function:
```bash
supabase link --project-ref <your-project-ref>
supabase functions deploy --no-verify-jwt simple-mcp-server
supabase functions deploy --no-verify-jwt mcp
```
Your MCP server will be available at:
```
https://<your-project-ref>.supabase.co/functions/v1/simple-mcp-server/mcp
https://<your-project-ref>.supabase.co/functions/v1/mcp
```
Update your MCP client configuration to use the production URL.
@@ -240,14 +195,6 @@ server.registerTool(
)
```
## Add authentication
<Admonition type="caution">
MCP authentication is not yet supported on Edge Functions. For now, MCP servers deployed on Supabase Edge Functions are publicly accessible.
</Admonition>
## Examples
You can find ready-to-use MCP server implementations here:
@@ -114,6 +114,19 @@ As a prerequisite, you must install a container runtime compatible with Docker A
4. View your local Supabase instance at [http://localhost:54323](http://localhost:54323).
<Admonition type="caution">
If your local development machine is connected to an untrusted public network, you should create a separate docker network and bind to 127.0.0.1 before starting the local development stack. This restricts network access to only your localhost machine.
```sh
docker network create -o 'com.docker.network.bridge.host_binding_ipv4=127.0.0.1' local-network
npx supabase start --network-id local-network
```
You should never expose your local development stack publicly.
</Admonition>
## Local development
Local development with Supabase allows you to work on your projects in a self-contained environment on your local machine. Working locally has several advantages:
+35 -41
View File
@@ -1,19 +1,29 @@
---
title: 'Self-Hosting'
description: 'Host Supabase on your own infrastructure.'
subtitle: 'Host Supabase on your own infrastructure.'
subtitle: 'Install and run your own Supabase.'
hideToc: true
---
There are several ways to host Supabase on your own computer, server, or cloud.
Self-hosted Supabase lets you run the entire Supabase stack on your own computer, server, or cloud infrastructure. This is different from:
- **Supabase CLI / Local Development**: A lightweight [local environment](/docs/guides/local-development) for development and testing only.
- **Managed Supabase** platform: If you want to try managed Supabase for free, visit [supabase.com/dashboard](/dashboard).
Self-hosting is a good fit if you need full control over your data, have compliance requirements that prevent using managed services, or want to run Supabase in an isolated environment.
## No telemetry
Self-hosted Supabase does not phone home or collect any telemetry.
## Enterprise
If you're an enterprise using self-hosted Supabase, we'd love to hear from you. Reach out to our [Growth Team](https://forms.supabase.com/enterprise) to discuss your use case, share feedback, or explore design partnership opportunities.
## Officially supported
<div className="grid md:grid-cols-12 gap-4 not-prose">
<div className="md:col-span-6 xl:col-span-3 relative" key="/guides/self-hosting/docker">
<span className=" absolute left-28 top-[34px] uppercase text-xs whitespace-nowrap text-foreground-lighter font-mono z-10 border rounded-full px-2 py-0.5">
Most common
</span>
<Link href="/guides/self-hosting/docker" passHref>
<GlassPanel title="Docker">
@@ -22,24 +32,29 @@ There are several ways to host Supabase on your own computer, server, or cloud.
</Link>
</div>
<div className="md:col-span-6 xl:col-span-3" key="/pricing">
<Link href="https://supabase.com/pricing" passHref>
<GlassPanel title="BYO Cloud">Contact our Enterprise sales team if you need Supabase managed in your own cloud.</GlassPanel>
</Link>
</div>
</div>
Supabase is also a hosted platform. If you want to get started for free, visit [supabase.com/dashboard](/dashboard).
## Community supported
There are several community-driven projects to help you deploy Supabase. We encourage you to try them out and contribute back to the community.
{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
There are several community-driven projects to help you deploy Supabase. These projects may be outdated and are seeking active maintainers. If you're interested in maintaining one of these projects, [contact the community team](/open-source/contributing/supasquad).
<div className="grid md:grid-cols-12 gap-4 not-prose">
{community.map((x) => (
<div className="md:col-span-6 xl:col-span-3" key={x.href}>
<Link href={x.href} passHref>
<GlassPanel title={x.name}>{x.description}</GlassPanel>
<GlassPanel
title={
<span className="flex items-center gap-2">
{x.name}
<span className="uppercase text-xs whitespace-nowrap text-foreground-lighter font-mono border rounded-full px-2 py-0.5">
Maintainer needed
</span>
</span>
}
>
{x.description}
</GlassPanel>
</Link>
</div>
))}
@@ -56,34 +71,13 @@ export const community = [
description: 'A self-hosted Supabase setup with Traefik as a reverse proxy.',
href: 'https://github.com/supabase-community/supabase-traefik',
},
{
name: 'AWS',
description: 'A CloudFormation template for Supabase.',
href: 'https://github.com/supabase-community/supabase-on-aws',
},
]
## Third-party guides
## Responsibility model
The following third-party providers have shown consistent support for the self-hosted version of Supabase:.
When you self-host, **you are responsible for**:
<div className="grid md:grid-cols-12 gap-4 not-prose">
{[
{
name: 'StackGres',
description: 'Deploys using Kubernetes.',
href: 'https://stackgres.io/blog/running-supabase-on-top-of-stackgres/',
},
{
name: 'Pigsty',
description: 'Deploys using Ansible.',
href: 'https://pigsty.io/blog/db/supabase/',
},
].map((x) => (
<div className="md:col-span-6" key={x.href}>
<Link href={x.href} passHref>
<GlassPanel title={x.name}>{x.description}</GlassPanel>
</Link>
</div>
))}
</div>
- Server provisioning and maintenance
- Security hardening and keeping OS and services updated
- Backups and disaster recovery
- Monitoring and uptime
+252 -211
View File
@@ -5,21 +5,52 @@ subtitle: "Learn how to configure and deploy Supabase with Docker."
tocVideo: "FqiQKRKsfZE"
---
Docker is the easiest way to get started with self-hosted Supabase. It should only take you a few minutes to get up and running. This guide assumes you are running the command from the machine you intend to host from.
Docker is the easiest way to get started with self-hosted Supabase. It should take you less than 30 minutes to get up and running.
## Contents
1. [Before you begin](#before-you-begin)
2. [Installing and running Supabase](#installing-and-running-supabase)
3. [Accessing your services](#accessing-supabase-studio)
4. [Updating your services](#updating-your-services)
5. [Securing your services](#securing-your-services)
2. [System requirements](#system-requirements)
3. [Installing Supabase](#installing-supabase)
4. [Configuring and securing Supabase](#configuring-and-securing-supabase)
5. [Starting and stopping](#starting-and-stopping)
6. [Accessing Supabase services](#accessing-supabase-services)
7. [Updating](#updating)
8. [Uninstalling](#uninstalling)
9. [Advanced topics](#advanced-topics)
## Before you begin
You need the following installed in your system: [Git](https://git-scm.com/downloads) and Docker ([Windows](https://docs.docker.com/desktop/install/windows-install/), [macOS](https://docs.docker.com/desktop/install/mac-install/), or [Linux](https://docs.docker.com/desktop/install/linux-install/)).
This guide assumes you're comfortable with:
## Installing and running Supabase
- Linux server administration basics
- Docker and Docker Compose
- Networking fundamentals (ports, DNS, firewalls)
If you're new to these topics, consider starting with [managed Supabase](/dashboard) for free, or try [local development with the CLI](/docs/guides/local-development).
You need the following installed on your system:
- [Git](https://git-scm.com/downloads)
- [Docker](https://docs.docker.com/manuals/):
- **Linux server/VPS**: Install [Docker Engine](https://docs.docker.com/engine/install/) and [Docker Compose](https://docs.docker.com/compose/install/)
- **Linux desktop**: Install [Docker Desktop](https://docs.docker.com/desktop/setup/install/linux/)
- **macOS**: Install [Docker Desktop](https://docs.docker.com/desktop/install/mac-install/)
- **Windows**: Install [Docker Desktop](https://docs.docker.com/desktop/install/windows-install/)
## System requirements
Baseline server requirements for running all Supabase components. These are suitable for development and small to medium production workloads:
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| RAM | 4 GB | 8 GB+ |
| CPU | 2 cores | 4 cores+ |
| Disk | 50 GB SSD | 80 GB+ SSD |
If you don't need specific services, such as Analytics (Logflare), Realtime, Storage, Auth, or PostgREST, you can exclude them from `docker-compose.yml` to reduce resource requirements.
## Installing Supabase
Follow these steps to start Supabase on your machine:
@@ -54,9 +85,6 @@ cd supabase-project
# Pull the latest images
docker compose pull
# Start the services (in detached mode)
docker compose up -d
```
</TabPanel>
@@ -88,9 +116,6 @@ cd supabase-project
# Pull the latest images
docker compose pull
# Start the services (in detached mode)
docker compose up -d
```
</TabPanel>
@@ -98,216 +123,210 @@ docker compose up -d
<Admonition type="tip">
If you are using rootless docker, edit `.env` and set `DOCKER_SOCKET_LOCATION` to your docker socket location. For example: `/run/user/1000/docker.sock`. Otherwise, you will see an error like `container supabase-vector exited (0)`.
If you are using rootless Docker, edit `.env` and set `DOCKER_SOCKET_LOCATION` to your docker socket location. For example: `/run/user/1000/docker.sock`. Otherwise, you will see an error like `container supabase-vector exited (0)`.
</Admonition>
## Configuring and securing Supabase
While we provided example placeholder passwords and keys in the `.env.example` file, you should NEVER start your self-hosted Supabase using these defaults.
<Admonition type="danger">
Follow all of the steps in this section to ensure you have a secure setup, and only then start all services.
</Admonition>
### Configure database password
Change the placeholder password in the `.env` file **before** starting your Supabase for the first time.
- `POSTGRES_PASSWORD`: the password for the `postgres` and `supabase_admin` database roles
Follow the [password guidelines](/docs/guides/database/postgres/roles#passwords) for choosing a secure password. For easier configuration, **use only letters and numbers** to avoid URL encoding issues in connection strings.
### Generate and configure API keys
Use the key generator below to obtain and configure the following secure keys in `.env`:
- `JWT_SECRET`: Used by PostgREST and GoTrue to sign and verify JWTs.
- `ANON_KEY`: Client-side API key with limited permissions (`anon` role). Use this in your frontend applications.
- `SERVICE_ROLE_KEY`: Server-side API key with full database access (`service_role` role). Never expose this in client code.
<JwtGeneratorSimple />
1. Copy the generated value and update `JWT_SECRET` in the `.env` file. Do not share this secret publicly or commit it to version control.
2. Copy the generated value and update `ANON_KEY` in the `.env` file.
3. Copy the generated value and update `SERVICE_ROLE_KEY` in the `.env` file.
The generated keys expire in 5 years. You can verify them at [jwt.io](https://jwt.io) using the JWT secret.
### Configure other keys, and important URLs
Edit the following settings in the `.env` file:
- `SECRET_KEY_BASE`: encryption key for securing Realtime and Supavisor communications. (Must be at least 64 characters; generate with `openssl rand -base64 48`)
- `VAULT_ENC_KEY`: encryption key used by Supavisor for storing encrypted configuration. (Must be exactly 32 characters; generate with `openssl rand -hex 16`)
- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings used by Studio against postgres-meta. (Must be at least 32 characters; generate with `openssl rand -base64 24`)
- `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion and querying. Used by Vector and Studio to send and query logs. (Must be at least 32 characters; generate with `openssl rand -base64 24`)
- `LOGFLARE_PRIVATE_ACCESS_TOKEN`: API token for Logflare management operations. Used by Studio for administrative tasks. Never expose client-side. (Must be at least 32 characters; generate with `openssl rand -base64 24`)
Review and change URL environment variables:
- `SUPABASE_PUBLIC_URL`: the base URL for accessing your Supabase via the Internet, e.g, `http://example.com:8000`
- `API_EXTERNAL_URL`: the base URL for API requests, e.g., `http://example.com:8000`
- `SITE_URL`: the base URL of your site, e.g., `http://example.com:3000`
<Admonition type="tip">
If you are only using self-hosted Supabase locally, you can use `localhost`.
</Admonition>
### Studio authentication
Access to Studio dashboard and internal API is protected with **HTTP basic authentication**.
<Admonition type="danger">
The default password MUST be changed before starting Supabase.
</Admonition>
Change the password in the `.env` file:
- `DASHBOARD_PASSWORD`: password for Studio / dashboard
The password must include at least one letter (do not use numbers only).
Optionally change the user:
- `DASHBOARD_USERNAME`: username for Studio / dashboard
## Starting and stopping
You can start all services by using the following command in the same directory as your `docker-compose.yml` file:
```sh
# Start the services (in detached mode)
docker compose up -d
```
After all the services have started you can see them running in the background:
```sh
docker compose ps
```
All of the services should have a status `running (healthy)`. If you see a status like `created` but not `running`, try starting that service manually with `docker compose start <service-name>`.
After a minute or less, all services should have a status `Up [...] (healthy)`. If you see a status such as `created` but not `Up`, try inspecting the Docker logs for a specific container, e.g.,
<Admonition type="danger">
```sh
docker compose logs analytics
```
Your app is now running with default credentials.
[Secure your services](#securing-your-services) as soon as possible using the instructions below.
To stop Supabase, use:
</Admonition>
```sh
docker compose down
```
## Accessing Supabase services
After the Supabase services are configured and running, you can access the dashboard, connect to the database, and use edge functions.
### Accessing Supabase Studio
You can access Supabase Studio through the API gateway on port `8000`. For example: `http://<your-ip>:8000`, or [localhost:8000](http://localhost:8000) if you are running Docker locally.
You can access Supabase Studio through the API gateway on port `8000`.
You will be prompted for a username and password. By default, the credentials are:
For example: `http://example.com:8000`, or `http://<your-ip>:8000` (or `localhost:8000` if you are running Docker Compose locally).
- Username: `supabase`
- Password: `this_password_is_insecure_and_should_be_updated`
You will be prompted for a username and password. Use the credentials that you set up earlier in [Studio authentication](#studio-authentication).
You should change these credentials as soon as possible using the [instructions](#dashboard-authentication) below.
### Accessing Postgres
By default, the Supabase stack provides the [Supavisor](https://supabase.github.io/supavisor/development/docs/) connection pooler for accessing Postgres and managing database connections.
You can connect to the Postgres database via Supavisor using the methods described below. Use your domain name, your server IP, or `localhost` depending on whether you are running self-hosted Supabase on a VPS, or locally.
The default POOLER_TENANT_ID is `your-tenant-id` (can be changed in `.env`), and the password is the one you set previously in [Configure database password](#configure-database-password).
For session-based connections (equivalent to a direct Postgres connection):
```sh
psql 'postgres://postgres.[POOLER_TENANT_ID]:[POSTGRES_PASSWORD]@[your-domain]:5432/postgres'
```
For pooled transactional connections:
```sh
psql 'postgres://postgres.[POOLER_TENANT_ID]:[POSTGRES_PASSWORD]@[your-domain]:6543/postgres'
```
When using `psql` with command-line parameters instead of a connection string to connect to Supavisor, the `-U` parameter should also be `postgres.[POOLER_TENANT_ID]`, and not just `postgres`.
If for some reason you need to configure Postgres to be directly accessible from the Internet, read [Exposing your Postgres database](#exposing-your-postgres-database) below.
### Accessing Edge Functions
Edge Functions are stored in `volumes/functions`. The default setup has a `hello` function that you can invoke on `http://<your-domain>:8000/functions/v1/hello`.
You can add new Functions as `volumes/functions/<FUNCTION_NAME>/index.ts`. Restart the `functions` service to pick up the changes: `docker compose restart functions --no-deps`
### Accessing the APIs
Each of the APIs are available through the same API gateway:
Each of the APIs is available through the same API gateway:
- REST: `http://<your-ip>:8000/rest/v1/`
- REST: `http://<your-domain>:8000/rest/v1/`
- Auth: `http://<your-domain>:8000/auth/v1/`
- Storage: `http://<your-domain>:8000/storage/v1/`
- Realtime: `http://<your-domain>:8000/realtime/v1/`
### Accessing your Edge Functions
## Updating
Edge Functions are stored in `volumes/functions`. The default setup has a `hello` Function that you can invoke on `http://<your-domain>:8000/functions/v1/hello`.
We publish stable releases of the Docker Compose setup approximately once a month. To update, pull the latest changes from the repository and restart the services. If you want to run different versions of individual services, you can change the image tags in the Docker Compose file, but compatibility is not guaranteed. All Supabase images are available on [Docker Hub](https://hub.docker.com/u/supabase).
You can add new Functions as `volumes/functions/<FUNCTION_NAME>/index.ts`. Restart the `functions` service to pick up the changes: `docker compose restart functions --no-deps`
To follow the changes and updates, refer to the self-hosted Supabase [changelog](https://github.com/supabase/supabase/blob/master/docker/CHANGELOG.md).
### Accessing Postgres
You need to restart services to pick up the changes, which may result in downtime for your applications and users.
By default, the Supabase stack runs the [Supavisor](https://supabase.github.io/supavisor/development/docs/) connection pooler. Supavisor provides efficient management of database connections.
**Example:**
You'd like to update or rollback the Studio image. Follow the steps below:
You can connect to the Postgres database using the following methods:
1. For session-based connections (equivalent to direct Postgres connections):
```bash
psql 'postgres://postgres.your-tenant-id:your-super-secret-and-long-postgres-password@localhost:5432/postgres'
```
2. For pooled transactional connections:
```bash
psql 'postgres://postgres.your-tenant-id:your-super-secret-and-long-postgres-password@localhost:6543/postgres'
```
The default tenant ID is `your-tenant-id`, and the default password is `your-super-secret-and-long-postgres-password`. You should change these as soon as possible using the [instructions below](#update-secrets).
By default, the database is not accessible from outside the local machine but the pooler is. You can [change this](#exposing-your-postgres-database) by updating the `docker-compose.yml` file.
You may also want to connect to your Postgres database via an ORM or another direct method other than `psql`.
For this you can use the standard Postgres connection string.
You can find the environment values mentioned below in the `.env` file which will be covered in the next section.
```
postgres://postgres:[POSTGRES_PASSWORD]@[your-server-ip]:5432/[POSTGRES_DB]
```
## Updating your services
For security reasons, we "pin" the versions of each service in the docker-compose file (these versions are updated ~monthly). If you want to update any services immediately, you can do so by updating the version number in the docker compose file and then running `docker compose pull`. You can find all the latest docker images in the [Supabase Docker Hub](https://hub.docker.com/u/supabase).
You should update your services frequently to get the latest features and bug fixes and security patches. Note that you will need to restart the services to pick up the changes, which will result in some downtime for your services.
**Example**
You'll want to update the Studio(Dashboard) frequently to get the latest features and bug fixes. To update the Dashboard:
1. Visit the [supabase/studio](https://hub.docker.com/r/supabase/studio/tags) image in the [Supabase Docker Hub](https://hub.docker.com/u/supabase)
2. Find the latest version (tag) number. It will look something like `20241029-46e1e40`
3. Update the `image` field in the `docker-compose.yml` file to the new version. It should look like this: `image: supabase/studio:20241028-a265374`
4. Run `docker compose pull` and then `docker compose up -d` to restart the service with the new version.
## Securing your services
While we provided you with some example secrets for getting started, you should NEVER deploy your Supabase setup using the defaults we have provided. Follow all of the steps in this section to ensure you have a secure setup, and then [restart all services](#restarting-all-services) to pick up the changes.
### Generate API keys
We need to generate secure keys for accessing your services. We'll use the `JWT Secret` to generate `anon` and `service` API keys using the form below.
1. **Obtain a Secret**: Use the 40-character secret provided, or create your own. If creating, ensure it's a strong, random string of 40 characters.
2. **Store Securely**: Save the secret in a secure location on your local machine. Don't share this secret publicly or commit it to version control.
3. **Generate a JWT**: Use the form below to generate a new `JWT` using your secret.
<JwtGenerator />
### Update API keys
Run this form twice to generate new `anon` and `service` API keys. Replace the values in the `./docker/.env` file:
- `ANON_KEY` - replace with an `anon` key
- `SERVICE_ROLE_KEY` - replace with a `service` key
You will need to [restart](#restarting-all-services) the services for the changes to take effect.
### Update secrets
Update the `./docker/.env` file with your own secrets. In particular, these are required:
- `POSTGRES_PASSWORD`: the password for the `postgres` role.
- `JWT_SECRET`: used by PostgREST and GoTrue, among others.
- `SITE_URL`: the base URL of your site.
- `SMTP_*`: mail server credentials. You can use any SMTP server.
- `POOLER_TENANT_ID`: the tenant-id that will be used by Supavisor pooler for your connection string
- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings between Studio and postgres-meta
- `SECRET_KEY_BASE`: encryption key for securing Realtime and Supavisor communications. (Must be at least 64 characters; generate with `openssl rand -base64 48`)
You will need to [restart](#restarting-all-services) the services for the changes to take effect.
### Dashboard authentication
The Dashboard is protected with basic authentication. The default user and password MUST be updated before using Supabase in production.
Update the following values in the `./docker/.env` file:
- `DASHBOARD_USERNAME`: The default username for the Dashboard
- `DASHBOARD_PASSWORD`: The default password for the Dashboard
You can also add more credentials for multiple users in `./docker/volumes/api/kong.yml`. For example:
```yaml docker/volumes/api/kong.yml
basicauth_credentials:
- consumer: DASHBOARD
username: user_one
password: password_one
- consumer: DASHBOARD
username: user_two
password: password_two
```
To enable all dashboard features outside of `localhost`, update the following value in the `./docker/.env` file:
- `SUPABASE_PUBLIC_URL`: The URL or IP used to access the dashboard
You will need to [restart](#restarting-all-services) the services for the changes to take effect.
## Restarting all services
You can restart services to pick up any configuration changes by running:
```sh
# Stop and remove the containers
docker compose down
# Recreate and start the containers
docker compose up -d
```
{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
Be aware that this will result in downtime. Simply restarting the services does not apply configuration changes.
## Stopping all services
You can stop Supabase by running `docker compose stop` in same directory as your `docker-compose.yml` file.
1. Check the [supabase/studio](https://hub.docker.com/r/supabase/studio/tags) images on [Supabase Docker Hub](https://hub.docker.com/u/supabase)
2. Find the latest version (tag) number. It looks something like `2025.11.26-sha-8f096b5`
3. Update the `image` field in the `docker-compose.yml` file. It should look like this: `image: supabase/studio:2025.11.26-sha-8f096b5`
4. Run `docker compose pull`, followed by `docker compose down && docker compose up -d` to restart Supabase.
## Uninstalling
You can stop Supabase by running the following in same directory as your `docker-compose.yml` file:
To uninstall, stop Supabase (while in the same directory as your `docker-compose.yml` file):
```sh
# Stop docker and remove volumes:
docker compose down -v
```
# Remove Postgres data:
<Admonition type="danger">
Be careful — the following destroys all data, including the database and storage volumes!
</Admonition>
Remove all Postgres data:
```sh
rm -rf volumes/db/data/
```
This will destroy all data in the database and storage volumes, so be careful!
## Managing your secrets
Many components inside Supabase use secure secrets and passwords. These are listed in the self-hosting [env file](https://github.com/supabase/supabase/blob/master/docker/.env.example), but we strongly recommend using a secrets manager when deploying to production. Plain text files like dotenv lead to accidental costly leaks.
Some suggested systems include:
- [Doppler](https://www.doppler.com/)
- [Infisical](https://infisical.com/)
- [Key Vault](https://docs.microsoft.com/en-us/azure/key-vault/general/overview) by Azure (Microsoft)
- [Secrets Manager](https://aws.amazon.com/secrets-manager/) by AWS
- [Secrets Manager](https://cloud.google.com/secret-manager) by GCP
- [Vault](https://www.hashicorp.com/products/vault) by HashiCorp
## Advanced
## Advanced topics
Everything beyond this point in the guide helps you understand how the system works and how you can modify it to suit your needs.
### Architecture
Supabase is a combination of open source tools, each specifically chosen for Enterprise-readiness.
Supabase is a combination of open source tools specifically developed for enterprise-readiness.
If the tools and communities already exist, with an MIT, Apache 2, or equivalent open license, we will use and support that tool.
If the tool doesn't exist, we build and open source it ourselves.
If the tools and communities already exist, with an MIT, Apache 2, or equivalent open source license, we will use and support that tool. If the tool doesn't exist, we build and open source it ourselves.
<Image
alt="Diagram showing the architecture of Supabase. The Kong API gateway sits in front of 7 services: GoTrue, PostgREST, Realtime, Storage, pg_meta, Functions, and pg_graphql. All the services talk to a single Postgres instance."
@@ -317,32 +336,31 @@ If the tool doesn't exist, we build and open source it ourselves.
}}
/>
- [Kong](https://github.com/Kong/kong) is a cloud-native API gateway.
- [GoTrue](https://github.com/supabase/gotrue) is an JWT based API for managing users and issuing JWT tokens.
- [PostgREST](http://postgrest.org/) is a web server that turns your Postgres database directly into a RESTful API
- [Realtime](https://github.com/supabase/realtime) is an Elixir server that allows you to listen to Postgres inserts, updates, and deletes using WebSockets. Realtime polls Postgres' built-in replication functionality for database changes, converts changes to JSON, then broadcasts the JSON over WebSockets to authorized clients.
- [Storage](https://github.com/supabase/storage-api) provides a RESTful interface for managing Files stored in S3, using Postgres to manage permissions.
- [`postgres-meta`](https://github.com/supabase/postgres-meta) is a RESTful API for managing your Postgres, allowing you to fetch tables, add roles, and run queries, etc.
- [Postgres](https://www.postgresql.org/) is an object-relational database system with over 30 years of active development that has earned it a strong reputation for reliability, feature robustness, and performance.
- [Supavisor](https://github.com/supabase/supavisor) is a scalable connection pooler for Postgres, allowing for efficient management of database connections.
- **[Studio](https://github.com/supabase/supabase/tree/master/apps/studio)** - A dashboard for managing your self-hosted Supabase project
- **[Kong](https://github.com/Kong/kong)** - Kong API gateway
- **[GoTrue](https://github.com/supabase/auth)** - JWT-based authentication API for user sign-ups, logins, and session management
- **[PostgREST](https://github.com/PostgREST/postgrest)** - Web server that turns your Postgres database directly into a RESTful API
- **[Realtime](https://github.com/supabase/realtime)** - Elixir server that listens to Postgres database changes and broadcasts them to subscribed clients
- **[Storage](https://github.com/supabase/storage)** - RESTful API for managing files in S3, with Postgres handling permissions
{/* supa-mdx-lint-disable-next-line Rule003Spelling */}
- **[ImgProxy](https://github.com/imgproxy/imgproxy)** - Fast and secure image processing server
- **[postgres-meta](https://github.com/supabase/postgres-meta)** - RESTful API for managing Postgres (fetch tables, add roles, run queries)
{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
- **[PostgreSQL](https://github.com/supabase/postgres)** - Object-relational database with over 30 years of active development
- **[Edge Runtime](https://github.com/supabase/edge-runtime)** - Web server based on Deno runtime for running JavaScript, TypeScript, and WASM services
- **[Logflare](https://github.com/Logflare/logflare)** - Log management and event analytics platform
- **[Vector](https://github.com/vectordotdev/vector)** - High-performance observability data pipeline for logs
- **[Supavisor](https://github.com/supabase/supavisor)** - Supabase's Postgres connection pooler
For the system to work cohesively, some services require additional configuration within the Postgres database. For example, the APIs and Auth system require several [default roles](/docs/guides/database/postgres/roles) and the `pgjwt` Postgres extension.
For the system to work cohesively, some services require additional configuration within the Postgres database. For example, the APIs and Auth system require several [default roles](/docs/guides/database/postgres/roles#supabase-roles).
You can find all the default extensions inside the [schema migration scripts repo](https://github.com/supabase/postgres/tree/develop/migrations). These scripts are mounted at `/docker-entrypoint-initdb.d` to run automatically when starting the database container.
### Configuring services
Each system has a number of configuration options which can be found in the relevant product documentation.
Each service has a number of configuration options you can find in the related documentation.
- [Postgres](https://hub.docker.com/_/postgres/)
- [PostgREST](https://postgrest.org/en/stable/configuration.html)
- [Realtime](https://github.com/supabase/realtime#server)
- [Auth](https://github.com/supabase/auth)
- [Storage](https://github.com/supabase/storage-api)
- [Kong](https://docs.konghq.com/gateway/latest/install/docker/)
- [Supavisor](https://supabase.github.io/supavisor/development/docs/)
These configuration items are generally added to the `env` section of each service, inside the `docker-compose.yml` section. If these configuration items are sensitive, they should be stored in a [secret manager](/docs/guides/self-hosting#managing-your-secrets) or using an `.env` file and then referenced using the `${}` syntax.
Configuration options are generally added to the `.env` file and referenced in `docker-compose.yml` service definitions, e.g.,
<$CodeTabs>
@@ -361,9 +379,9 @@ JWT_SECRET=${JWT_SECRET}
</$CodeTabs>
### Common configuration
### Common configuration tasks
Each system can be [configured](../self-hosting#configuration) independently. Some of the most common configuration options are listed below.
You can configure each Supabase service separately through environment variables and configuration files. Below are the most common configuration options.
#### Configuring an email server
@@ -420,25 +438,39 @@ By default, `docker compose` sets the database's `log_min_messages` configuratio
#### Accessing Postgres through Supavisor
By default, the Postgres database is accessible through the Supavisor connection pooler. This allows for more efficient management of database connections. You can connect to the pooled database using the `POOLER_PROXY_PORT_TRANSACTION` port and `POSTGRES_PORT` for session based connections.
By default, Postgres connections go through the Supavisor connection pooler for efficient connection management. Two ports are available:
- `POSTGRES_PORT` (default: 5432) – Session mode, behaves like a direct Postgres connection
- `POOLER_PROXY_PORT_TRANSACTION` (default: 6543) – Transaction mode, uses connection pooling
For more information on configuring and using Supavisor, see the [Supavisor documentation](https://supabase.github.io/supavisor/).
#### Exposing your Postgres database
If you need direct access to the Postgres database without going through Supavisor, you can expose it by updating the `docker-compose.yml` file:
By default, Postgres is only accessible through Supavisor. If you need direct access to the database (bypassing the connection pooler), you need to disable Supavisor and expose the Postgres port.
<Admonition type="danger">
Exposing Postgres directly bypasses connection pooling and exposes your database to the network. Configure firewall rules or network policies to restrict access to trusted IPs only.
</Admonition>
Update `docker-compose.yml`:
1. **Disable Supavisor** - Comment out or remove the entire `supavisor` service section
2. **Expose Postgres port** - Add the port mapping to the `db` service:
```yaml docker-compose.yml
# Comment or remove the supavisor section of the docker-compose file
# supavisor:
# ports:
# ...
db:
ports:
- ${POSTGRES_PORT}:${POSTGRES_PORT}
```
This is less-secure, so make sure you are running a firewall in front of your server.
You can then connect to the database directly using a standard Postgres connection string:
```sh
postgres://postgres:[POSTGRES_PASSWORD]@[your-server-ip]:5432/[POSTGRES_DB]
```
#### File storage backend on macOS
@@ -454,7 +486,7 @@ Due to the changes in the Analytics server, you will need to run the following c
<Admonition type="caution">
All data in analytics will be deleted when you run the commands below.
All data in analytics will be deleted when you run the commands below.
</Admonition>
@@ -469,12 +501,23 @@ DROP PUBLICATION logflare_pub; DROP SCHEMA _analytics CASCADE; CREATE SCHEMA _an
docker rm supabase-analytics
```
## Managing your secrets
Many components inside Supabase use secure secrets and passwords. These are listed in the self-hosting [env file](https://github.com/supabase/supabase/blob/master/docker/.env.example), but we strongly recommend using a secrets manager when deploying to production.
Some suggested systems include:
- [Doppler](https://www.doppler.com/)
- [Infisical](https://infisical.com/)
- [Key Vault](https://docs.microsoft.com/en-us/azure/key-vault/general/overview) by Azure (Microsoft)
- [Secrets Manager](https://aws.amazon.com/secrets-manager/) by AWS
- [Secrets Manager](https://cloud.google.com/secret-manager) by GCP
- [Vault](https://www.hashicorp.com/products/vault) by HashiCorp
---
## Demo
A minimal setup working on Ubuntu, hosted on DigitalOcean.
<div className="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/FqiQKRKsfZE"
@@ -484,8 +527,6 @@ A minimal setup working on Ubuntu, hosted on DigitalOcean.
></iframe>
</div>
### Demo using DigitalOcean
1. A DigitalOcean Droplet with 1 GB memory and 25 GB solid-state drive (SSD) is sufficient to start
2. To access the Dashboard, use the ipv4 IP address of your Droplet.
3. If you're unable to access Dashboard, run `docker compose ps` to see if the Studio service is running and healthy.
1. The VPS instance is a DigitalOcean droplet. (For server requirements refer to [System requirements](#system-requirements))
2. To access Studio, use the IPv4 IP address of your Droplet.
3. If you're unable to use Studio, run `docker compose ps` to see if all services are up and healthy.
@@ -17,7 +17,16 @@ You can create an analytics bucket using either the Supabase SDK or the Supabase
### Using the Supabase SDK
```ts
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://your-project.supabase.co', 'your-service-key')
@@ -31,6 +40,24 @@ if (error) {
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
supabase = create_client('https://your-project.supabase.co', 'your-service-key')
response = supabase.storage.analytics().create('analytics-data')
print('Analytics bucket created:', response)
```
</TabPanel>
</$Show>
</Tabs>
### Using the Supabase Dashboard
1. Navigate to the **Storage** section in the Supabase Dashboard.
@@ -306,6 +306,44 @@ Uppy has integrations with different frameworks:
Resumable uploads also supports using signed upload tokens to created time-limited URLs that you can share to your users by invoking the `createSignedUploadUrl` method on the SDK and including the returned token in the `x-signature` header of the resumable upload.
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
// Create a signed upload URL
const { data } = await supabase.storage.from('bucket_name').createSignedUploadUrl('file_path', {
upsert: true, // Optional: allow overwriting existing files
})
// Use the signed URL token in resumable upload headers
// Include data.token in the x-signature header
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
# Create a signed upload URL with upsert option
response = supabase.storage.from_('bucket_name').create_signed_upload_url(
'file_path',
options={'upsert': 'true'} # Optional: allow overwriting existing files
)
# Use the signed URL token in resumable upload headers
# Include response.token in the x-signature header
```
</TabPanel>
</$Show>
</Tabs>
See this [full example using Uppy with signed URLs](https://github.com/supabase/supabase/tree/master/examples/storage/resumable-upload-signed-uppy) for more context.
## Overwriting files
@@ -13,7 +13,7 @@ Vector buckets organize your vector data into logical units. Within each bucket,
## Creating a Vector bucket
You can create vector buckets using either the Supabase Dashboard or the JavaScript SDK.
You can create vector buckets using either the Supabase Dashboard or the SDK.
### Using the Supabase Dashboard
@@ -25,7 +25,16 @@ You can create vector buckets using either the Supabase Dashboard or the JavaScr
Your vector bucket is now ready. The next step is to create indexes within it.
### Using the JavaScript SDK
### Using the SDK
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
import { createClient } from '@supabase/supabase-js'
@@ -38,6 +47,25 @@ await supabase.storage.vectors.createBucket('embeddings')
console.log('✓ Vector bucket created: embeddings')
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
supabase = create_client('https://your-project.supabase.co', 'your-service-key')
# Create a vector bucket
supabase.storage.vectors().create_bucket('embeddings')
print('✓ Vector bucket created: embeddings')
```
</TabPanel>
</$Show>
</Tabs>
## Creating indexes
Indexes organize vectors within a bucket with consistent dimensions and distance metrics. For comprehensive index management documentation, see [Working with Vector Indexes](/docs/guides/storage/vector/working-with-indexes).
@@ -51,7 +79,16 @@ Indexes organize vectors within a bucket with consistent dimensions and distance
5. Select the distance metric (`cosine`, `euclidean`, or `l2`).
6. Click **Create**.
### Quick start: Creating an index via JavaScript SDK
### Quick start: Creating an index via SDK
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
const bucket = supabase.storage.vectors.from('embeddings')
@@ -67,6 +104,28 @@ await bucket.createIndex({
console.log('✓ Index created: documents-openai')
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
bucket = supabase.storage.vectors().from_('embeddings')
# Create an index
bucket.create_index(
index_name='documents-openai',
dimension=1536,
distance_metric='cosine',
data_type='float32'
)
print('✓ Index created: documents-openai')
```
</TabPanel>
</$Show>
</Tabs>
### Key details
- **Dimension** must match your embedding model (e.g., 1536 for OpenAI)
@@ -57,6 +57,34 @@ if (error) {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
supabase = create_client('https://your-project.supabase.co', 'your-service-key')
index = supabase.storage.vectors().from_('embeddings').index('documents-openai')
# Query with a vector embedding
results = index.query(
query_vector={'float32': [0.1, 0.2, 0.3]}, # ... embedding of 1536 dimensions
topK=5,
return_distance=True,
return_metadata=True
)
if results:
# Results are ranked by similarity (lowest distance = most similar)
for rank, result in enumerate(results.vectors, 1):
print(f'{rank}. {result.metadata.get("title") if result.metadata else "N/A"}')
if result.distance is not None:
print(f' Similarity score: {result.distance:.4f}')
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -139,6 +167,55 @@ results.forEach((result) => {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
from openai import OpenAI
import os
supabase = create_client(...)
openai = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
async def semantic_search(query, top_k=5):
# Embed the query
query_embedding = openai.embeddings.create(
model='text-embedding-3-small',
input=query
)
query_vector = query_embedding.data[0].embedding
# Search for similar vectors
results = supabase.storage.vectors() \
.from_('embeddings') \
.index('documents-openai') \
.query(
query_vector={'float32': query_vector},
topK=top_k,
return_distance=True,
return_metadata=True
)
return [
{
'id': result.key,
'title': result.metadata.get('title') if result.metadata else None,
'similarity': 1 - result.distance if result.distance else 0, # Convert distance to similarity (0-1)
'metadata': result.metadata
}
for result in results.vectors
]
# Usage
results = semantic_search('How do I use vector search?')
for result in results:
print(f'{result["title"]} ({result["similarity"] * 100:.1f}% similar)')
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -197,6 +274,31 @@ const { data } = await index.queryVectors({
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
index = supabase.storage.vectors() \
.from_('embeddings') \
.index('documents-openai')
# Search with metadata filter
results = index.query(
query_vector={'float32': [...embedding...]},
topK=10,
filter={
# Filter by metadata fields
'category': 'electronics',
'in_stock': True,
'price': {'$lte': 500} # Less than or equal to 500
},
return_distance=True,
return_metadata=True
)
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -248,6 +350,21 @@ if (!error) {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
index = supabase.storage.vectors().from_('embeddings').index('documents-openai')
results = index.get('doc-1', 'doc-2', 'doc-3', return_data=True, return_metadata=True)
if results:
for vector in results.vectors:
print(f'{vector.key}: {vector.metadata.get("title") if vector.metadata else "N/A"}')
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -303,6 +420,39 @@ do {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
index = supabase.storage.vectors().from_('embeddings').index('documents-openai')
next_token = None
page_count = 0
while True:
results = index.list(
max_results=100,
next_token=next_token,
return_data=False, # Don't return embeddings for faster response
return_metadata=True
)
if not results:
break
page_count += 1
print(f'Page {page_count}: {len(results.vectors)} vectors')
for vector in results.vectors:
print(f' - {vector.key}: {vector.metadata.get("title") if vector.metadata else "N/A"}')
next_token = results.nextToken
if not next_token:
break
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -368,6 +518,39 @@ async function hybridSearch(queryVector, filters) {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
def hybrid_search(query_vector, filters):
index = supabase.storage.vectors().from_('embeddings').index('documents-openai')
# Get similar vectors with filters
vector_results = index.query(
query_vector={'float32': query_vector},
topK=100,
filter=filters,
return_distance=True,
return_metadata=True
)
# Get additional details from relational database
doc_ids = [v.metadata.get('doc_id') for v in vector_results.vectors if v.metadata and v.metadata.get('doc_id')]
details_response = supabase.table('documents').select('*').in_('id', doc_ids).execute()
details = details_response.data if details_response.data else []
# Merge results
return [
{
**vector.__dict__ if hasattr(vector, '__dict__') else {},
**next((d for d in details if d['id'] == vector.metadata.get('doc_id')), {})
}
for vector in vector_results.vectors
]
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -398,6 +581,15 @@ LIMIT 50;
### RAG (retrieval-augmented generation)
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
import OpenAI from 'openai'
import { createClient } from '@supabase/supabase-js'
@@ -445,8 +637,72 @@ async function retrieveContextForLLM(userQuery) {
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from openai import OpenAI
from supabase import create_client
async def retrieve_context_for_llm(user_query):
supabase = create_client(...)
openai = OpenAI()
# 1. Embed the user query
query_embedding = openai.embeddings.create(
model='text-embedding-3-small',
input=user_query
)
# 2. Retrieve relevant documents
vector_results = supabase.storage.vectors() \
.from_('embeddings') \
.index('documents-openai') \
.query(
query_vector={'float32': query_embedding.data[0].embedding},
topK=5,
return_metadata=True
)
# 3. Use vectors to augment LLM prompt
context = '\n\n'.join(
v.metadata.get('content', '') if v.metadata else ''
for v in vector_results.vectors
)
response = openai.chat.completions.create(
model='gpt-4',
messages=[
{
'role': 'system',
'content': f'Use the following context to answer the user\'s question:\n\n{context}'
},
{
'role': 'user',
'content': user_query
}
]
)
return response.choices[0].message.content
```
</TabPanel>
</$Show>
</Tabs>
### Product recommendations
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
async function recommendProducts(userEmbedding, topK = 5) {
const supabase = createClient(...)
@@ -473,8 +729,51 @@ async function recommendProducts(userEmbedding, topK = 5) {
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
def recommend_products(user_embedding, top_k=5):
supabase = create_client(...)
# Find similar products
results = supabase.storage.vectors() \
.from_('embeddings') \
.index('products-openai') \
.query(
query_vector={'float32': user_embedding},
topK=top_k,
filter={'in_stock': True},
return_metadata=True
)
return [
{
'id': result.metadata.get('product_id') if result.metadata else None,
'name': result.metadata.get('name') if result.metadata else None,
'price': result.metadata.get('price') if result.metadata else None,
'similarity': 1 - result.distance if result.distance else 0
}
for result in results.vectors
]
```
</TabPanel>
</$Show>
</Tabs>
### Filtering before similarity search
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
// Use metadata filters to reduce search scope
const { data } = await index.queryVectors({
@@ -486,6 +785,23 @@ const { data } = await index.queryVectors({
})
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
# Use metadata filters to reduce search scope
results = index.query(
query_vector=query_vector,
topK=100,
filter={'category': 'electronics'} # Pre-filter by category
)
```
</TabPanel>
</$Show>
</Tabs>
## Next steps
- [Store vectors](/docs/guides/storage/vector/storing-vectors)
@@ -65,6 +65,43 @@ if (error) {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
supabase = create_client('https://your-project.supabase.co', 'your-service-key')
# Get bucket and index
bucket = supabase.storage.vectors().from_('embeddings')
index = bucket.index('documents-openai')
# Insert vectors
index.put([
{
'key': 'doc-1',
'data': {'float32': [0.1, 0.2, 0.3]}, # ... rest of embedding
'metadata': {
'title': 'Getting Started with Vector Buckets',
'source': 'documentation',
},
},
{
'key': 'doc-2',
'data': {'float32': [0.4, 0.5, 0.6]}, # ... rest of embedding
'metadata': {
'title': 'Advanced Vector Search',
'source': 'blog',
},
},
])
print('✓ Vectors stored successfully')
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -160,6 +197,56 @@ for (let i = 0; i < vectors.length; i += 500) {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
from openai import OpenAI
from datetime import datetime
supabase = create_client('https://your-project.supabase.co', 'your-service-key')
openai = OpenAI(api_key=os.environ.get('OPENAI_API_KEY'))
# Documents to embed and store
documents = [
{'id': '1', 'title': 'How to Train Your AI', 'content': 'Guide for training models...'},
{'id': '2', 'title': 'Vector Search Best Practices', 'content': 'Tips for semantic search...'},
{'id': '3', 'title': 'Building RAG Systems', 'content': 'Implementing retrieval-augmented generation...'},
]
# Generate embeddings
embeddings_response = openai.embeddings.create(
model='text-embedding-3-small',
input=[doc['content'] for doc in documents]
)
# Prepare vectors for storage
vectors = []
for doc, embedding_data in zip(documents, embeddings_response.data):
vectors.append({
'key': doc['id'],
'data': {'float32': embedding_data.embedding},
'metadata': {
'title': doc['title'],
'source': 'knowledge_base',
'created_at': datetime.now().isoformat(),
},
})
# Store vectors in batches (max 500 per request)
bucket = supabase.storage.vectors().from_('embeddings')
vector_index = bucket.index('documents-openai')
batch_size = 500
for i in range(0, len(vectors), batch_size):
batch = vectors[i:i + batch_size]
vector_index.put(batch)
print(f'✓ Stored batch {i // batch_size + 1} ({len(batch)} vectors)')
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -223,6 +310,29 @@ if (!error) {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
index = bucket.index('documents-openai')
# Update a vector (same key)
index.put([
{
'key': 'doc-1',
'data': {'float32': [0.15, 0.25, 0.35]}, # ... updated embedding
'metadata': {
'title': 'Getting Started with Vector Buckets - Updated',
'updated_at': datetime.now().isoformat(),
},
},
])
print('✓ Vector updated successfully')
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -266,6 +376,20 @@ if (!error) {
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
index = bucket.index('documents-openai')
# Delete specific vectors
index.delete(['doc-1', 'doc-2'])
print('✓ Vectors deleted successfully')
```
</TabPanel>
</$Show>
<TabPanel id="sql" label="SQL (via S3 Vector Wrapper)">
@@ -324,6 +448,15 @@ const { error } = await index.putVectors({ vectors })
For storing large numbers of vectors efficiently:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
import { createClient } from '@supabase/supabase-js'
import fs from 'fs'
@@ -369,12 +502,67 @@ for (let i = 0; i < vectors.length; i += BATCH_SIZE) {
console.log('✓ All vectors stored successfully')
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
import json
supabase = create_client(...)
index = supabase.storage.vectors().from_('embeddings').index('documents-openai')
# Read embeddings from file
with open('embeddings.jsonl', 'r') as f:
lines = [line.strip() for line in f if line.strip()]
vectors = []
for line in lines:
data = json.loads(line)
vectors.append({
'key': data['key'],
'data': {'float32': data['embedding']},
'metadata': data.get('metadata', {})
})
# Process in batches
BATCH_SIZE = 500
processed = 0
for i in range(0, len(vectors), BATCH_SIZE):
batch = vectors[i:i + BATCH_SIZE]
try:
index.put(batch)
processed += len(batch)
print(f'Progress: {processed}/{len(vectors)}')
except Exception as e:
print(f'Batch failed at offset {i}: {e}')
# Optionally implement retry logic
print('✓ All vectors stored successfully')
```
</TabPanel>
</$Show>
</Tabs>
## Performance optimization
### Batch operations
Always use batch operations for better performance:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
// ❌ Inefficient - Multiple requests
for (const vector of vectors) {
@@ -385,6 +573,23 @@ for (const vector of vectors) {
await index.putVectors({ vectors })
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
# ❌ Inefficient - Multiple requests
for vector in vectors:
index.put([vector])
# ✅ Efficient - Single batch operation
index.put(vectors)
```
</TabPanel>
</$Show>
</Tabs>
### Metadata considerations
Keep metadata concise:
@@ -33,7 +33,16 @@ Think of an index as a table in a traditional database. It has a schema (dimensi
5. Select the distance metric (`cosine`, `euclidean`, or `l2`).
6. Click **Create**.
### Via JavaScript SDK
### Via SDK
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
import { createClient } from '@supabase/supabase-js'
@@ -57,6 +66,32 @@ if (error) {
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
from supabase import create_client
supabase = create_client('https://your-project.supabase.co', 'your-service-key')
bucket = supabase.storage.vectors().from_('embeddings')
# Create an index
bucket.create_index(
index_name='documents-openai',
dimension=1536,
distance_metric='cosine',
data_type='float32'
)
print('✓ Index created: documents-openai')
```
</TabPanel>
</$Show>
</Tabs>
### Choosing the right metric
Most modern embedding models work best with **cosine** distance:
@@ -75,6 +110,15 @@ Most modern embedding models work best with **cosine** distance:
Create multiple indexes for different use cases or embedding models:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
const bucket = supabase.storage.vectors.from('embeddings')
@@ -107,6 +151,46 @@ const { data: indexes } = await bucket.listIndexes()
console.log('All indexes:', indexes)
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
bucket = supabase.storage.vectors().from_('embeddings')
# Index for OpenAI embeddings
bucket.create_index(
index_name='documents-openai',
dimension=1536,
distance_metric='cosine',
data_type='float32'
)
# Index for Cohere embeddings
bucket.create_index(
index_name='documents-cohere',
dimension=1024,
distance_metric='cosine',
data_type='float32'
)
# Index for different use case
bucket.create_index(
index_name='images-openai',
dimension=1536,
distance_metric='cosine',
data_type='float32'
)
# List all indexes
indexes = bucket.list_indexes()
print('All indexes:', indexes)
```
</TabPanel>
</$Show>
</Tabs>
### Use cases for multiple indexes
- **Different embedding models** - Store vectors from OpenAI, Cohere, and local models separately
@@ -118,6 +202,15 @@ console.log('All indexes:', indexes)
### List all indexes in a bucket
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
const bucket = supabase.storage.vectors.from('embeddings')
@@ -132,8 +225,35 @@ if (!error) {
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
bucket = supabase.storage.vectors().from_('embeddings')
indexes = bucket.list_indexes()
if indexes:
for index in indexes.indexes:
print(f'Index: {index.indexName}')
```
</TabPanel>
</$Show>
</Tabs>
### Get index details
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
const { data: indexDetails, error } = await bucket.getIndex('documents-openai')
@@ -145,10 +265,36 @@ if (!error && indexDetails) {
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
index_details = bucket.get_index('documents-openai')
if index_details:
print(f'Index: {index_details.index.index_name}')
print(f'Dimension: {index_details.index.dimension}')
print(f'Distance metric: {index_details.index.distance_metric}')
```
</TabPanel>
</$Show>
</Tabs>
## Deleting indexes
Delete an index to free storage space:
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
const bucket = supabase.storage.vectors.from('embeddings')
@@ -161,6 +307,22 @@ if (error) {
}
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
bucket = supabase.storage.vectors().from_('embeddings')
bucket.delete_index('documents-openai')
print('✓ Index deleted successfully')
```
</TabPanel>
</$Show>
</Tabs>
### Before deleting an index
**Warning**: Deleting an index is permanent and cannot be undone.
@@ -180,6 +342,15 @@ Once created, these properties **cannot be changed**:
### Optimizing index performance
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="javascript"
queryGroup="language"
>
<TabPanel id="javascript" label="JavaScript">
```typescript
// Good - Appropriate batch size
const batch = vectors.slice(0, 250)
@@ -205,6 +376,38 @@ const { data } = await index.queryVectors({
})
```
</TabPanel>
<$Show if="sdk:python">
<TabPanel id="python" label="Python">
```python
# Good - Appropriate batch size
batch = vectors[:250]
index.put(batch)
# Good - Filter metadata before query
results = index.query(
query_vector=query_vector,
topK=5,
filter={'category': 'electronics'}
)
# Avoid - Single vector inserts
for vector in vectors:
index.put([vector])
# Avoid - Returning unnecessary data
results = index.query(
query_vector=query_vector,
topK=1000, # Too many results
return_data=True # Include large embeddings
)
```
</TabPanel>
</$Show>
</Tabs>
## Next steps
- [Store vectors in indexes](/docs/guides/storage/vector/storing-vectors)
+2 -1
View File
@@ -13,7 +13,7 @@ import { AuthSmsProviderConfig } from '~/components/AuthSmsProviderConfig'
import { CostWarning } from '~/components/AuthSmsProviderConfig/AuthSmsProviderConfig.Warnings'
import ButtonCard from '~/components/ButtonCard'
import { Extensions } from '~/components/Extensions'
import { JwtGenerator } from '~/components/JwtGenerator'
import { JwtGenerator, JwtGeneratorSimple } from '~/components/JwtGenerator'
import { MetricsStackCards } from '~/components/MetricsStackCards'
import { NavData } from '~/components/NavData'
import { Price } from '~/components/Price'
@@ -59,6 +59,7 @@ const components = {
IconX: X,
Image: (props: any) => <Image fill alt="" className="object-contain" {...props} />,
JwtGenerator,
JwtGeneratorSimple,
Link,
McpConfigPanel,
MetricsStackCards,
+743 -11
View File
@@ -2815,6 +2815,215 @@ functions:
}
)
```
- id: oauth-admin-api
title: 'Overview'
notes: |
- OAuth 2.1 client administration endpoints are available via the `supabase.auth.admin.oauth` namespace.
- These methods require a `service_role` key and should only be called server-side.
- Only relevant when the OAuth 2.1 server is enabled in Supabase Auth.
- This is an alpha feature and may change in the future.
- id: auth-js-gotrueadminoauthapi-listclients
title: 'admin.oauth.list_clients()'
description: |
Lists OAuth clients with optional pagination.
notes: |
- Requires `service_role` key.
- Only available when OAuth 2.1 server is enabled.
- This is an alpha feature and may change in the future.
params:
- name: params
isOptional: true
type: PageParams
description: Optional pagination parameters.
subContent:
- name: page
isOptional: true
type: number
description: Page number (1-indexed).
- name: per_page
isOptional: true
type: number
description: Number of items per page.
examples:
- id: list-oauth-clients
name: List OAuth clients
code: |
```python
response = supabase.auth.admin.oauth.list_clients()
```
- id: list-oauth-clients-paginated
name: List OAuth clients with pagination
code: |
```python
response = supabase.auth.admin.oauth.list_clients(
params={"page": 1, "per_page": 10}
)
```
- id: auth-js-gotrueadminoauthapi-createclient
title: 'admin.oauth.create_client()'
description: |
Creates a new OAuth client.
notes: |
- Requires `service_role` key.
- Only available when OAuth 2.1 server is enabled.
- This is an alpha feature and may change in the future.
params:
- name: params
isOptional: false
type: CreateOAuthClientParams
description: Parameters for creating a new OAuth client.
subContent:
- name: name
isOptional: false
type: string
description: The name of the OAuth client.
- name: redirect_uris
isOptional: false
type: list[string]
description: List of allowed redirect URIs.
- name: grant_types
isOptional: true
type: list[string]
description: List of allowed grant types.
- name: response_types
isOptional: true
type: list[string]
description: List of allowed response types.
- name: client_type
isOptional: true
type: string
description: The client type.
- name: registration_type
isOptional: true
type: string
description: The registration type.
examples:
- id: create-oauth-client
name: Create OAuth client
code: |
```python
response = supabase.auth.admin.oauth.create_client(
{
"name": "My OAuth Client",
"redirect_uris": ["https://example.com/callback"]
}
)
```
- id: auth-js-gotrueadminoauthapi-updateclient
title: 'admin.oauth.update_client()'
description: |
Updates an existing OAuth client.
notes: |
- Requires `service_role` key.
- Only available when OAuth 2.1 server is enabled.
- This is an alpha feature and may change in the future.
params:
- name: client_id
isOptional: false
type: string
description: The ID of the OAuth client to update.
- name: params
isOptional: false
type: UpdateOAuthClientParams
description: Parameters for updating the OAuth client.
subContent:
- name: name
isOptional: true
type: string
description: The name of the OAuth client.
- name: redirect_uris
isOptional: true
type: list[string]
description: List of allowed redirect URIs.
- name: grant_types
isOptional: true
type: list[string]
description: List of allowed grant types.
- name: response_types
isOptional: true
type: list[string]
description: List of allowed response types.
examples:
- id: update-oauth-client
name: Update OAuth client
code: |
```python
response = supabase.auth.admin.oauth.update_client(
"client-id",
{
"name": "Updated OAuth Client",
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
}
)
```
- id: auth-js-gotrueadminoauthapi-getclient
title: 'admin.oauth.get_client()'
description: |
Retrieves details of an OAuth client by ID.
notes: |
- Requires `service_role` key.
- Only available when OAuth 2.1 server is enabled.
- This is an alpha feature and may change in the future.
params:
- name: client_id
isOptional: false
type: string
description: The ID of the OAuth client to retrieve.
examples:
- id: get-oauth-client
name: Get OAuth client
code: |
```python
response = supabase.auth.admin.oauth.get_client("client-id")
```
- id: auth-js-gotrueadminoauthapi-deleteclient
title: 'admin.oauth.delete_client()'
description: |
Deletes an OAuth client.
notes: |
- Requires `service_role` key.
- Only available when OAuth 2.1 server is enabled.
- This is an alpha feature and may change in the future.
params:
- name: client_id
isOptional: false
type: string
description: The ID of the OAuth client to delete.
examples:
- id: delete-oauth-client
name: Delete OAuth client
code: |
```python
supabase.auth.admin.oauth.delete_client("client-id")
```
- id: auth-js-gotrueadminoauthapi-regenerateclientsecret
title: 'admin.oauth.regenerate_client_secret()'
description: |
Regenerates the client secret for an OAuth client.
notes: |
- Requires `service_role` key.
- Only available when OAuth 2.1 server is enabled.
- This is an alpha feature and may change in the future.
params:
- name: client_id
isOptional: false
type: string
description: The ID of the OAuth client.
examples:
- id: regenerate-oauth-client-secret
name: Regenerate OAuth client secret
code: |
```python
response = supabase.auth.admin.oauth.regenerate_client_secret("client-id")
```
- id: mfa-delete-factor
title: 'mfa.delete_factor()'
params:
@@ -7150,14 +7359,6 @@ functions:
title: 'Overview'
notes: |
This section contains methods for working with File Buckets.
# - id: analytics-buckets
# title: 'Overview'
# notes: |
# This section contains methods for working with Analytics Buckets.
# - id: vector-buckets
# title: 'Overview'
# notes: |
# This section contains methods for working with Vector Buckets.
- id: list-buckets
title: 'list_buckets()'
@@ -7765,6 +7966,7 @@ functions:
- name: options
isOptional: true
type: CreateSignedUploadUrlOptions
description: Additional options for the upload URL creation.
subContent:
- name: upsert
isOptional: true
@@ -7789,14 +7991,17 @@ functions:
"token": "<TOKEN>"
}
```
- id: create-signed-url-upsert
name: Create Signed URL with upsert
- id: create-signed-url-with-upsert
name: Create Signed URL with upsert option
code: |
```python
response = (
supabase.storage
.from_("avatars")
.create_signed_upload_url("folder/avatar1.png", {"upsert": "true"})
.create_signed_upload_url(
"folder/avatar1.png",
options={"upsert": "true"}
)
)
```
response: |
@@ -8187,3 +8392,530 @@ functions:
)
)
```
- id: analytics-buckets
title: 'Overview'
notes: |
This section contains methods for working with Analytics Buckets.
- Analytics buckets are accessed via the `supabase.storage.analytics()` namespace.
- Analytics buckets are used for storing and querying analytics data.
- id: storageanalytics-createbucket
title: 'analytics().create()'
description: |
Creates a new Analytics bucket.
notes: |
- This is an alpha feature and may change in the future.
- Analytics buckets are used for storing and querying analytics data.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the analytics bucket to create.
examples:
- id: create-analytics-bucket
name: Create analytics bucket
code: |
```python
response = supabase.storage.analytics().create("analytics-bucket")
```
response: |
```json
{
"name": "analytics-bucket",
"type": "ANALYTICS",
"created_at": "2024-12-03T18:00:00.000Z",
"updated_at": "2024-12-03T18:00:00.000Z"
}
```
- id: storageanalytics-listbuckets
title: 'analytics().list()'
description: |
Lists all Analytics buckets with optional filtering and sorting.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: limit
isOptional: true
type: number
description: Maximum number of buckets to return.
- name: offset
isOptional: true
type: number
description: Number of buckets to skip.
- name: sort_column
isOptional: true
type: string
description: 'Column to sort by. Options: `id`, `name`, `created_at`, `updated_at`.'
- name: sort_order
isOptional: true
type: string
description: 'Sort order. Options: `asc`, `desc`.'
- name: search
isOptional: true
type: string
description: Search term to filter buckets by name.
examples:
- id: list-analytics-buckets
name: List analytics buckets
code: |
```python
response = supabase.storage.analytics().list()
```
response: |
```json
[
{
"name": "analytics-bucket",
"type": "ANALYTICS",
"created_at": "2024-12-03T18:00:00.000Z",
"updated_at": "2024-12-03T18:00:00.000Z"
}
]
```
- id: storageanalytics-deletebucket
title: 'analytics().delete()'
description: |
Deletes an Analytics bucket.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the analytics bucket to delete.
examples:
- id: delete-analytics-bucket
name: Delete analytics bucket
code: |
```python
response = supabase.storage.analytics().delete("analytics-bucket")
```
response: |
```json
{
"message": "Successfully deleted"
}
```
- id: vector-buckets
title: 'Overview'
notes: |
This section contains methods for working with Vector Buckets.
- id: storagevectors-create-bucket
title: 'vectors().create_bucket()'
description: |
Creates a new Vector bucket.
notes: |
- This is an alpha feature and may change in the future.
- Vector buckets are used for storing and querying vector embeddings.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket to create.
examples:
- id: create-vector-bucket
name: Create vector bucket
code: |
```python
supabase.storage.vectors().create_bucket("vectors-bucket")
```
- id: storagevectors-get-bucket
title: 'vectors().get_bucket()'
description: |
Retrieves details of a Vector bucket.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket to retrieve.
examples:
- id: get-vector-bucket
name: Get vector bucket
code: |
```python
response = supabase.storage.vectors().get_bucket("vectors-bucket")
```
response: |
```json
{
"vectorBucket": {
"vectorBucketName": "vectors-bucket",
"creationTime": "2024-12-03T18:00:00.000Z"
}
}
```
- id: storagevectors-list-buckets
title: 'vectors().list_buckets()'
description: |
Lists all Vector buckets with optional pagination.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: prefix
isOptional: true
type: string
description: Filter buckets by name prefix.
- name: max_results
isOptional: true
type: number
description: Maximum number of buckets to return.
- name: next_token
isOptional: true
type: string
description: Token for pagination.
examples:
- id: list-vector-buckets
name: List vector buckets
code: |
```python
response = supabase.storage.vectors().list_buckets()
```
response: |
```json
{
"vectorBuckets": [
{
"vectorBucketName": "vectors-bucket"
}
],
"nextToken": null
}
```
- id: storagevectors-delete-bucket
title: 'vectors().delete_bucket()'
description: |
Deletes a Vector bucket.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket to delete.
examples:
- id: delete-vector-bucket
name: Delete vector bucket
code: |
```python
supabase.storage.vectors().delete_bucket("vectors-bucket")
```
- id: vectorbucket-createindex
title: 'vectors().from_().create_index()'
description: |
Creates a new vector index in a vector bucket.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: index_name
isOptional: false
type: string
description: The name of the index to create.
- name: dimension
isOptional: false
type: number
description: The dimension of the vectors in this index.
- name: distance_metric
isOptional: false
type: string
description: 'The distance metric to use. Options: `cosine`, `euclidean`.'
- name: data_type
isOptional: false
type: string
description: The data type of the vectors.
- name: metadata
isOptional: true
type: MetadataConfiguration
description: Optional metadata configuration for the index.
examples:
- id: create-vector-index
name: Create vector index
code: |
```python
supabase.storage.vectors().from_("vectors-bucket").create_index(
index_name="my-index",
dimension=128,
distance_metric="cosine",
data_type="float32"
)
```
- id: vectorbucket-getindex
title: 'vectors().from_().get_index()'
description: |
Retrieves details of a vector index.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: index_name
isOptional: false
type: string
description: The name of the index to retrieve.
examples:
- id: get-vector-index
name: Get vector index
code: |
```python
response = supabase.storage.vectors().from_("vectors-bucket").get_index("my-index")
```
- id: vectorbucket-listindexes
title: 'vectors().from_().list_indexes()'
description: |
Lists all indexes in a vector bucket.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: next_token
isOptional: true
type: string
description: Token for pagination.
- name: max_results
isOptional: true
type: number
description: Maximum number of indexes to return.
- name: prefix
isOptional: true
type: string
description: Filter indexes by name prefix.
examples:
- id: list-vector-indexes
name: List vector indexes
code: |
```python
response = supabase.storage.vectors().from_("vectors-bucket").list_indexes()
```
- id: vectorbucket-deleteindex
title: 'vectors().from_().delete_index()'
description: |
Deletes a vector index.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: index_name
isOptional: false
type: string
description: The name of the index to delete.
examples:
- id: delete-vector-index
name: Delete vector index
code: |
```python
supabase.storage.vectors().from_("vectors-bucket").delete_index("my-index")
```
- id: vectorindex-putvectors
title: 'vectors().from_().index().put()'
description: |
Inserts or updates vectors in an index.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: index_name
isOptional: false
type: string
description: The name of the index.
- name: vectors
isOptional: false
type: list[VectorObject]
description: List of vector objects to insert or update.
examples:
- id: put-vectors
name: Put vectors
code: |
```python
supabase.storage.vectors().from_("vectors-bucket").index("my-index").put([
{
"key": "vector-1",
"data": {"float32": [0.1, 0.2, 0.3]},
"metadata": {"category": "example"}
}
])
```
- id: vectorindex-getvectors
title: 'vectors().from_().index().get()'
description: |
Retrieves vectors by their keys.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: index_name
isOptional: false
type: string
description: The name of the index.
- name: keys
isOptional: false
type: list[string]
description: List of vector keys to retrieve.
- name: return_data
isOptional: true
type: bool
description: Whether to return vector data. Defaults to `true`.
- name: return_metadata
isOptional: true
type: bool
description: Whether to return metadata. Defaults to `true`.
examples:
- id: get-vectors
name: Get vectors
code: |
```python
response = supabase.storage.vectors().from_("vectors-bucket").index("my-index").get("vector-1")
```
- id: vectorindex-listvectors
title: 'vectors().from_().index().list()'
description: |
Lists vectors in an index with optional pagination.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: index_name
isOptional: false
type: string
description: The name of the index.
- name: max_results
isOptional: true
type: number
description: Maximum number of vectors to return.
- name: next_token
isOptional: true
type: string
description: Token for pagination.
- name: return_data
isOptional: true
type: bool
description: Whether to return vector data. Defaults to `true`.
- name: return_metadata
isOptional: true
type: bool
description: Whether to return metadata. Defaults to `true`.
- name: segment_count
isOptional: true
type: number
description: Number of segments for distributed queries.
- name: segment_index
isOptional: true
type: number
description: Segment index for distributed queries.
examples:
- id: list-vectors
name: List vectors
code: |
```python
response = supabase.storage.vectors().from_("vectors-bucket").index("my-index").list()
```
- id: vectorindex-queryvectors
title: 'vectors().from_().index().query()'
description: |
Queries vectors using a query vector to find similar vectors.
notes: |
- This is an alpha feature and may change in the future.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: index_name
isOptional: false
type: string
description: The name of the index.
- name: query_vector
isOptional: false
type: VectorData
description: The query vector to search with.
- name: topK
isOptional: true
type: number
description: Number of results to return.
- name: filter
isOptional: true
type: VectorFilter
description: Optional filter to apply to the query.
- name: return_distance
isOptional: true
type: bool
description: Whether to return distance scores. Defaults to `true`.
- name: return_metadata
isOptional: true
type: bool
description: Whether to return metadata. Defaults to `true`.
examples:
- id: query-vectors
name: Query vectors
code: |
```python
response = supabase.storage.vectors().from_("vectors-bucket").index("my-index").query(
query_vector={"float32": [0.1, 0.2, 0.3]},
topK=10
)
```
- id: vectorindex-deletevectors
title: 'vectors().from_().index().delete()'
description: |
Deletes vectors by their keys.
notes: |
- This is an alpha feature and may change in the future.
- Keys batch size must be between 1 and 500.
params:
- name: bucket_name
isOptional: false
type: string
description: The name of the vector bucket.
- name: index_name
isOptional: false
type: string
description: The name of the index.
- name: keys
isOptional: false
type: list[string]
description: List of vector keys to delete. Must contain between 1 and 500 keys.
examples:
- id: delete-vectors
name: Delete vectors
code: |
```python
supabase.storage.vectors().from_("vectors-bucket").index("my-index").delete(["vector-1", "vector-2"])
```
@@ -34,7 +34,7 @@ export const APIKeyDeleteDialog = ({ apiKey, setKeyToDelete }: APIKeyDeleteDialo
},
}}
>
<Trash2 size={14} strokeWidth={1.5} /> Delete API key
<Trash2 size={14} /> Delete API key
</DropdownMenuItemTooltip>
)
}
@@ -21,15 +21,17 @@ export const APIKeyRow = ({
lastSeen,
isDeleting,
isDeleteModalOpen,
isLoadingLastSeen,
isLoadingLastSeen = false,
showLastSeen = true,
onDelete,
setKeyToDelete,
}: {
apiKey: Extract<APIKeysData[number], { type: 'secret' | 'publishable' }>
lastSeen?: { timestamp: number; relative: string }
showLastSeen?: boolean
isDeleting: boolean
isDeleteModalOpen: boolean
isLoadingLastSeen: boolean
isLoadingLastSeen?: boolean
onDelete: () => void
setKeyToDelete: (id: string | null) => void
}) => {
@@ -50,7 +52,7 @@ export const APIKeyRow = ({
mass: 1,
}}
>
<TableCell className="py-2">
<TableCell className="py-2 w-56">
<div className="flex flex-col">
<span className="font-medium">{apiKey.name}</span>
<div className="text-sm text-foreground-lighter">
@@ -58,13 +60,14 @@ export const APIKeyRow = ({
</div>
</div>
</TableCell>
<TableCell className="py-2">
<div className="flex flex-row gap-2">
<ApiKeyPill apiKey={apiKey} />
</div>
</TableCell>
{showApiKeysLastUsed && (
{showLastSeen && showApiKeysLastUsed && (
<TableCell className="py-2 min-w-0 whitespace-nowrap hidden lg:table-cell">
<div className="truncate" title={lastSeen?.timestamp.toString() || 'Never used'}>
{isLoadingLastSeen ? (
@@ -104,6 +107,7 @@ export const APIKeyRow = ({
</div>
</TableCell>
</MotionTableRow>
<TextConfirmModal
visible={isDeleteModalOpen}
onCancel={() => setKeyToDelete(null)}
@@ -1,6 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { Eye, EyeOff } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -8,7 +7,7 @@ import { toast } from 'sonner'
import { InputVariants } from '@ui/components/shadcn/ui/input'
import { useParams } from 'common'
import CopyButton from 'components/ui/CopyButton'
import { useAPIKeyIdQuery } from 'data/api-keys/[id]/api-key-id-query'
import { useAPIKeyIdQuery } from 'data/api-keys/api-key-id-query'
import { APIKeysData } from 'data/api-keys/api-keys-query'
import { apiKeysKeys } from 'data/api-keys/keys'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -127,7 +126,9 @@ export function ApiKeyPill({
<span>{show && data?.api_key ? data?.api_key.slice(15) : '••••••••••••••••'}</span>
</>
) : (
<span>{apiKey?.api_key}</span>
<span title={apiKey.api_key} className="truncate">
{apiKey.api_key}
</span>
)}
</div>
@@ -1,11 +1,11 @@
import { ExternalLink, Github } from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import { SupportCategories } from '@supabase/shared-types/out/constants'
import { LOCAL_STORAGE_KEYS } from 'common'
import { FeatureBanner } from 'components/ui/FeatureBanner'
import { InlineLink, InlineLinkClassName } from 'components/ui/InlineLink'
import { APIKeysData } from 'data/api-keys/api-keys-query'
import {
Button,
Card,
CardContent,
Separator,
@@ -19,7 +19,6 @@ import {
import { SupportLink } from '../Support/SupportLink'
import { ApiKeyPill } from './ApiKeyPill'
import { CreateNewAPIKeysButton } from './CreateNewAPIKeysButton'
import { useApiKeysVisibility } from './hooks/useApiKeysVisibility'
// Mock API Keys for demo
const mockApiKeys = [
@@ -46,7 +45,7 @@ const mockApiKeys = [
/**
* Reusable table illustration component
*/
export const ApiKeysTableIllustration = () => {
const ApiKeysTableIllustration = () => {
return (
<Card className="w-full overflow-hidden opacity-60 pointer-events-none bg-surface-100">
<CardContent className="p-0">
@@ -90,7 +89,7 @@ export const ApiKeysTableIllustration = () => {
/**
* Reusable illustration with gradient overlay component
*/
export const ApiKeysIllustrationWithOverlay = () => {
const ApiKeysIllustrationWithOverlay = () => {
return (
<>
{/* Gradient overlay - horizontal on desktop, vertical on mobile */}
@@ -108,41 +107,7 @@ export const ApiKeysIllustrationWithOverlay = () => {
)
}
/**
* "Coming Soon" banner for users who don't have the feature flag enabled
*/
export const ApiKeysComingSoonBanner = () => {
return (
<FeatureBanner illustration={<ApiKeysIllustrationWithOverlay />} bgAlt>
<div className="flex flex-col gap-0 z-[2]">
<p className="text-sm text-foreground">New API keys are coming soon</p>
<p className="text-sm text-foreground-lighter lg:max-w-sm 2xl:max-w-none">
We're rolling out new API keys to better support your application needs.
</p>
<div className="mt-4">
<Button type="default" icon={<Github />}>
<a
href="https://github.com/orgs/supabase/discussions/29260"
target="_blank"
rel="noreferrer"
>
Learn more
</a>
</Button>
</div>
</div>
</FeatureBanner>
)
}
/**
* Create API Keys callout for users who have the feature flag enabled but no keys yet
*/
export const ApiKeysCreateCallout = () => {
const { canInitApiKeys } = useApiKeysVisibility()
if (!canInitApiKeys) return null
return (
<FeatureBanner illustration={<ApiKeysIllustrationWithOverlay />} bgAlt>
<div className="flex flex-col gap-0 z-[2]">
@@ -158,15 +123,7 @@ export const ApiKeysCreateCallout = () => {
)
}
/**
* Feedback banner for users who have API keys and the feature is rolled out to them
*/
export const ApiKeysFeedbackBanner = () => {
const { hasApiKeys } = useApiKeysVisibility()
// Don't show anything if not in rollout or if keys don't exist
if (!hasApiKeys) return null
return (
<FeatureBanner
storageKey={LOCAL_STORAGE_KEYS.API_KEYS_FEEDBACK_DISMISSED}
@@ -177,14 +134,12 @@ export const ApiKeysFeedbackBanner = () => {
<p className="text-sm text-foreground">Your new API keys are here</p>
<p className="text-sm text-foreground-lighter">
We've updated our API keys to better support your application needs.{' '}
<a
<InlineLink
href="https://github.com/orgs/supabase/discussions/29260"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-foreground"
className="inline-flex items-center gap-1"
>
Join the discussion on GitHub <ExternalLink size={14} strokeWidth={1.5} />
</a>
Join the discussion on GitHub <ExternalLink aria-hidden size={14} strokeWidth={1.5} />
</InlineLink>
</p>
</div>
@@ -194,7 +149,7 @@ export const ApiKeysFeedbackBanner = () => {
<p className="text-sm text-foreground-lighter">
Having trouble with the new API keys?{' '}
<SupportLink
className="underline"
className={InlineLinkClassName}
queryParams={{
category: SupportCategories.PROBLEM,
subject: 'Help with API keys',
@@ -1,10 +1,10 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
@@ -16,10 +16,11 @@ import {
export const CreateNewAPIKeysButton = () => {
const { ref: projectRef } = useParams()
const [createKeysDialogOpen, setCreateKeysDialogOpen] = useState(false)
const [isCreatingKeys, setIsCreatingKeys] = useState(false)
const { mutate: createAPIKey } = useAPIKeyCreateMutation()
const [isCreatingKeys, setIsCreatingKeys] = useState(false)
const [createKeysDialogOpen, setCreateKeysDialogOpen] = useState(false)
const { mutateAsync: createAPIKey } = useAPIKeyCreateMutation()
const handleCreateNewApiKeys = async () => {
if (!projectRef) return
@@ -27,20 +28,13 @@ export const CreateNewAPIKeysButton = () => {
try {
// Create publishable key
await createAPIKey({
projectRef,
type: 'publishable',
name: 'default',
})
await createAPIKey({ projectRef, type: 'publishable', name: 'default' })
// Create secret key
await createAPIKey({
projectRef,
type: 'secret',
name: 'default',
})
await createAPIKey({ projectRef, type: 'secret', name: 'default' })
setCreateKeysDialogOpen(false)
toast.success('Successfully created a new set of API keys!')
} catch (error) {
console.error('Failed to create API keys:', error)
} finally {
@@ -56,15 +50,15 @@ export const CreateNewAPIKeysButton = () => {
<AlertDialogTitle>Create new API keys</AlertDialogTitle>
<AlertDialogDescription>
This will create a default publishable key and a default secret key named{' '}
<code>default</code>. These keys are required to connect your application to your
Supabase project.
<code className="!break-keep text-code-inline">default</code>. These keys are required
to connect your application to your Supabase project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleCreateNewApiKeys} disabled={isCreatingKeys}>
<Button onClick={handleCreateNewApiKeys} loading={isCreatingKeys}>
{isCreatingKeys ? 'Creating...' : 'Create keys'}
</AlertDialogAction>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -1,6 +1,11 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { parseAsString, useQueryState } from 'nuqs'
import { SubmitHandler, useForm } from 'react-hook-form'
import * as z from 'zod'
import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation'
import {
Button,
Dialog,
@@ -18,10 +23,6 @@ import {
Input_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation'
import { useParams } from 'next/navigation'
const FORM_ID = 'create-publishable-api-key'
const SCHEMA = z.object({
@@ -33,16 +34,22 @@ export interface CreatePublishableAPIKeyDialogProps {
projectRef: string
}
function CreatePublishableAPIKeyDialog() {
export const CreatePublishableAPIKeyDialog = () => {
const params = useParams()
const projectRef = params?.ref as string
const [visible, setVisible] = useState(false)
const [visible, setVisible] = useQueryState(
'new',
parseAsString.withDefault('').withOptions({ history: 'push', clearOnDefault: true })
)
const onClose = (value: boolean) => {
setVisible(value)
const onOpenChange = (value: boolean) => {
if (value) setVisible('publishable')
else setVisible('')
}
const defaultValues = { name: '', description: '' }
const form = useForm<z.infer<typeof SCHEMA>>({
resolver: zodResolver(SCHEMA),
defaultValues: {
@@ -63,17 +70,18 @@ function CreatePublishableAPIKeyDialog() {
},
{
onSuccess: () => {
onClose(false)
form.reset(defaultValues)
onOpenChange(false)
},
}
)
}
return (
<Dialog open={visible} onOpenChange={onClose}>
<Dialog open={visible === 'publishable'} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button type="default" className="pointer-events-auto">
Create new
<Button type="default" icon={<Plus />}>
New publishable key
</Button>
</DialogTrigger>
<DialogContent>
@@ -135,5 +143,3 @@ function CreatePublishableAPIKeyDialog() {
</Dialog>
)
}
export default CreatePublishableAPIKeyDialog
@@ -1,7 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { Plus, ShieldCheck } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { type SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
@@ -22,11 +27,6 @@ import {
Input_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import * as z from 'zod'
import { useParams } from 'common'
import { useAPIKeyCreateMutation } from 'data/api-keys/api-key-create-mutation'
import { Plus, ShieldCheck } from 'lucide-react'
const NAME_SCHEMA = z
.string()
@@ -45,23 +45,22 @@ const SCHEMA = z.object({
description: z.string().max(256, "Description shouldn't be too long").trim(),
})
const CreateSecretAPIKeyDialog = () => {
export const CreateSecretAPIKeyDialog = () => {
const { ref: projectRef } = useParams()
const [visible, setVisible] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
parseAsString.withDefault('').withOptions({ history: 'push', clearOnDefault: true })
)
const onClose = (value: boolean) => {
setVisible(value)
const onOpenChange = (value: boolean) => {
if (value) setVisible('secret')
else setVisible('')
}
const defaultValues = { name: '', description: '' }
const form = useForm<z.infer<typeof SCHEMA>>({
resolver: zodResolver(SCHEMA),
defaultValues: {
name: '',
description: '',
},
defaultValues,
})
const { mutate: createAPIKey, isPending: isCreatingAPIKey } = useAPIKeyCreateMutation()
@@ -77,14 +76,15 @@ const CreateSecretAPIKeyDialog = () => {
{
onSuccess: (data) => {
toast.success(`Your secret API key ${data.prefix}... is ready.`)
onClose(false)
form.reset(defaultValues)
onOpenChange(false)
},
}
)
}
return (
<Dialog open={visible} onOpenChange={onClose}>
<Dialog open={visible === 'secret'} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button type="default" className="mt-2" icon={<Plus />}>
New secret key
@@ -171,5 +171,3 @@ const CreateSecretAPIKeyDialog = () => {
</Dialog>
)
}
export default CreateSecretAPIKeyDialog
@@ -1,146 +1,147 @@
import { useMemo } from 'react'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useMemo, useRef } from 'react'
import { toast } from 'sonner'
import { InputVariants } from '@ui/components/shadcn/ui/input'
import { useParams } from 'common'
import CopyButton from 'components/ui/CopyButton'
import { AlertError } from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { NoPermission } from 'components/ui/NoPermission'
import { useAPIKeyDeleteMutation } from 'data/api-keys/api-key-delete-mutation'
import { APIKeysData, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect'
import {
cn,
EyeOffIcon,
Input_Shadcn_,
Skeleton,
Tooltip,
TooltipContent,
TooltipTrigger,
WarningIcon,
Card,
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { useApiKeysVisibility } from './hooks/useApiKeysVisibility'
// to add in later with follow up PR
// import CreatePublishableAPIKeyDialog from './CreatePublishableAPIKeyDialog'
// to add in later with follow up PR
// import ShowPublicJWTsDialogComposer from '../JwtSecrets/ShowPublicJWTsDialogComposer'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
import { APIKeyRow } from './APIKeyRow'
import { CreatePublishableAPIKeyDialog } from './CreatePublishableAPIKeyDialog'
export const PublishableAPIKeys = () => {
const { ref: projectRef } = useParams()
const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.SECRETS_READ,
'*'
)
const { canReadAPIKeys, isLoading: isLoadingVisibility } = useApiKeysVisibility()
const {
data: apiKeysData,
isPending: isLoadingApiKeys,
data: apiKeysData = [],
error,
isPending: isLoadingApiKeys,
isError: isErrorApiKeys,
} = useAPIKeysQuery({ projectRef, reveal: false }, { enabled: canReadAPIKeys })
const newApiKeys = useMemo(
() => apiKeysData.filter(({ type }) => type === 'publishable' || type === 'secret') ?? [],
[apiKeysData]
)
const hasApiKeys = newApiKeys.length > 0
const publishableApiKeys = useMemo(
() => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [],
() =>
apiKeysData?.filter(
(key): key is Extract<APIKeysData[number], { type: 'publishable' }> =>
key.type === 'publishable'
) ?? [],
[apiKeysData]
)
// The default publisahble key will always be the first one
const apiKey = publishableApiKeys[0]
// Track the ID being deleted to exclude it from error checking
const deletingAPIKeyIdRef = useRef<string | null>(null)
const { value: apiKeyToDelete, setValue: setAPIKeyToDelete } = useQueryStateWithSelect({
urlKey: 'deletePublishableKey',
select: (id: string) => (id ? publishableApiKeys?.find((key) => key.id === id) : undefined),
enabled: !!publishableApiKeys?.length,
onError: (_error, selectedId) => {
handleErrorOnDelete(deletingAPIKeyIdRef, selectedId, `API Key not found`)
},
})
const { mutate: deleteAPIKey, isPending: isDeletingAPIKey } = useAPIKeyDeleteMutation({
onSuccess: () => {
toast.success('Successfully deleted publishable key')
setAPIKeyToDelete(null)
},
onError: () => {
deletingAPIKeyIdRef.current = null
},
})
const onDeleteAPIKey = (apiKey: Extract<APIKeysData[number], { type: 'publishable' }>) => {
if (!projectRef) return console.error('Project ref is required')
if (!apiKey.id) return console.error('API key ID is required')
deletingAPIKeyIdRef.current = apiKey.id
deleteAPIKey({ projectRef, id: apiKey.id })
}
return (
<div>
<FormHeader
title="Publishable key"
description="This key is safe to use in a browser if you have enabled Row Level Security (RLS) for your tables and configured policies."
actions={<CreatePublishableAPIKeyDialog />}
/>
<div className="flex flex-col gap-8">
<div className="-space-y-px w-full lg:w-content lg:w-fit">
<div className="bg-surface-100 px-5 py-2 flex items-center gap-5 first:rounded-t-md border">
<span className="text-sm">Publishable key</span>
<div className="flex items-center gap-2">
<ApiKeyInput />
<Tooltip>
<TooltipTrigger asChild>
<CopyButton
iconOnly
size="tiny"
type="default"
className="px-2 rounded-full"
disabled={isLoadingVisibility || isLoadingApiKeys || !canReadAPIKeys}
text={apiKey?.api_key}
/>
</TooltipTrigger>
<TooltipContent side="bottom">
{!canReadAPIKeys
? 'You need additional permissions to copy publishable keys'
: isLoadingApiKeys
? 'Loading permissions...'
: 'Copy publishable key'}
</TooltipContent>
</Tooltip>
</div>
</div>
{error && canReadAPIKeys ? (
<div className="text-xs bg-warning-200 last:rounded-b-md border border-warning-400 px-5 text-foreground-lighter py-1">
<div className="text-warning">Failed to load publishable key: {error?.message}</div>
</div>
) : (
<div className="text-xs bg-200 last:rounded-b-md border px-5 text-foreground-lighter py-1">
The publishable key can be safely shared publicly
</div>
)}
</div>
</div>
{!canReadAPIKeys && !isLoadingPermissions ? (
<NoPermission resourceText="view API keys" />
) : isLoadingApiKeys || isLoadingPermissions ? (
<GenericSkeletonLoader />
) : isErrorApiKeys ? (
<AlertError error={error} subject="Failed to load API keys" />
) : (
<Card className="bg-surface-100">
<Table>
<TableHeader>
<TableRow className="bg-200">
<TableHead>Name</TableHead>
<TableHead>API Key</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{hasApiKeys && publishableApiKeys.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="p-0">
<Admonition showIcon={false} type="default" className="border-0 rounded-none">
<p className="text-foreground-light">No publishable keys created yet</p>
</Admonition>
</TableCell>
</TableRow>
)}
{publishableApiKeys.map((apiKey) => (
<APIKeyRow
showLastSeen={false}
key={apiKey.id}
apiKey={apiKey}
isDeleting={apiKeyToDelete?.id === apiKey.id && isDeletingAPIKey}
isDeleteModalOpen={apiKeyToDelete?.id === apiKey.id}
onDelete={() => onDeleteAPIKey(apiKey)}
setKeyToDelete={setAPIKeyToDelete}
/>
))}
</TableBody>
<TableFooter className="border-t">
<TableRow className="border-b-0">
<TableCell colSpan={3} className="py-2">
<p className="text-xs text-foreground-lighter font-normal">
Publishable keys can be safely shared publicly
</p>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
)}
</div>
)
}
const ApiKeyInput = () => {
const { ref: projectRef } = useParams()
const { canReadAPIKeys, isLoading: isPermissionsLoading } = useApiKeysVisibility()
const {
data: apiKeysData,
isPending: isApiKeysLoading,
error,
} = useAPIKeysQuery({ projectRef, reveal: false }, { enabled: canReadAPIKeys })
const publishableApiKeys = useMemo(
() => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [],
[apiKeysData]
)
// The default publisahble key will always be the first one
const apiKey = publishableApiKeys[0]
const baseClasses =
'flex-1 grow gap-1 rounded-full min-w-0 max-w-[200px] sm:max-w-[300px] md:max-w-[400px] lg:min-w-[24rem]'
const size = 'tiny'
if (!canReadAPIKeys && !isPermissionsLoading) {
return (
<div className={cn(InputVariants({ size }), baseClasses, 'items-center gap-2 font-normal')}>
<EyeOffIcon />
You do not have permission to read API Key
</div>
)
}
if (isApiKeysLoading || isPermissionsLoading) {
return (
<div className={cn(InputVariants({ size }), baseClasses, 'items-center')}>
<Skeleton className="h-2 w-48 rounded-full bg-foreground-muted" />
</div>
)
}
if (error) {
return (
<div className={cn(InputVariants({ size }), baseClasses, 'items-center gap-2 font-normal')}>
<WarningIcon />
Failed to load publishable key
</div>
)
}
return (
<Input_Shadcn_
key={apiKey?.id}
size={size}
className={cn(baseClasses, 'font-mono')}
value={apiKey?.api_key}
/>
)
}
@@ -1,16 +1,19 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import dayjs from 'dayjs'
import { useMemo, useRef } from 'react'
import { toast } from 'sonner'
import { useFlag, useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { AlertError } from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { NoPermission } from 'components/ui/NoPermission'
import { useAPIKeyDeleteMutation } from 'data/api-keys/api-key-delete-mutation'
import type { APIKeysData } from 'data/api-keys/api-keys-query'
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import useLogsQuery from 'hooks/analytics/useLogsQuery'
import { useLogsQuery } from 'hooks/analytics/useLogsQuery'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect'
import { Card, EyeOffIcon } from 'ui'
import { Card } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import {
Table,
@@ -20,8 +23,7 @@ import {
TableRow,
} from 'ui/src/components/shadcn/ui/table'
import { APIKeyRow } from './APIKeyRow'
import CreateSecretAPIKeyDialog from './CreateSecretAPIKeyDialog'
import { useApiKeysVisibility } from './hooks/useApiKeysVisibility'
import { CreateSecretAPIKeyDialog } from './CreateSecretAPIKeyDialog'
interface LastSeenData {
[hash: string]: { timestamp: number; relative: string }
@@ -67,8 +69,11 @@ function useLastSeen({ projectRef, enabled }: { projectRef: string; enabled?: bo
export const SecretAPIKeys = () => {
const { ref: projectRef } = useParams()
const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.SECRETS_READ,
'*'
)
const { canReadAPIKeys, isLoading: isLoadingPermissions } = useApiKeysVisibility()
const {
data: apiKeysData,
error,
@@ -96,7 +101,7 @@ export const SecretAPIKeys = () => {
const deletingAPIKeyIdRef = useRef<string | null>(null)
const { setValue: setAPIKeyToDelete, value: apiKeyToDelete } = useQueryStateWithSelect({
urlKey: 'delete',
urlKey: 'deleteSecretKey',
select: (id: string) => (id ? secretApiKeys?.find((key) => key.id === id) : undefined),
enabled: !!secretApiKeys?.length,
onError: (_error, selectedId) =>
@@ -105,7 +110,7 @@ export const SecretAPIKeys = () => {
const { mutate: deleteAPIKey, isPending: isDeletingAPIKey } = useAPIKeyDeleteMutation({
onSuccess: () => {
toast.success(`Successfully deleted API key`)
toast.success('Successfully deleted secret key')
setAPIKeyToDelete(null)
},
onError: () => {
@@ -129,17 +134,7 @@ export const SecretAPIKeys = () => {
/>
{!canReadAPIKeys && !isLoadingPermissions ? (
<Card>
<div className="!rounded-b-md overflow-hidden py-12 flex flex-col gap-1 items-center justify-center">
<EyeOffIcon />
<p className="text-sm text-foreground">
You do not have permission to read API Secret Keys
</p>
<p className="text-foreground-light">
Contact your organization owner/admin to request access.
</p>
</div>
</Card>
<NoPermission resourceText="view API keys" />
) : isLoadingApiKeys || isLoadingPermissions ? (
<GenericSkeletonLoader />
) : isErrorApiKeys ? (
@@ -1,57 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useMemo } from 'react'
import { useParams } from 'common'
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
interface ApiKeysVisibilityState {
hasApiKeys: boolean
isLoading: boolean
canReadAPIKeys: boolean
canInitApiKeys: boolean
shouldDisableUI: boolean
}
/**
* A hook that provides visibility states for API keys UI components
* Consolidates logic for determining access to API keys functionality
*/
export function useApiKeysVisibility(): ApiKeysVisibilityState {
const { ref: projectRef } = useParams()
const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.SECRETS_READ,
'*'
)
const { data: apiKeysData, isPending: isLoadingApiKeys } = useAPIKeysQuery(
{
projectRef,
reveal: false,
},
{ enabled: canReadAPIKeys }
)
const publishableApiKeys = useMemo(
() => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [],
[apiKeysData]
)
// Check if there are any publishable API keys
// we don't check for secret keys because they can be optionally deleted
const hasApiKeys = publishableApiKeys.length > 0
// Can initialize API keys when in rollout, has permissions, not loading, and no API keys yet
const canInitApiKeys = canReadAPIKeys && !isLoadingApiKeys && !hasApiKeys
// Disable UI for publishable keys and secrets keys if flag is not enabled OR no API keys created yet
const shouldDisableUI = !hasApiKeys
return {
hasApiKeys,
isLoading: isLoadingPermissions || (canReadAPIKeys && isLoadingApiKeys),
canReadAPIKeys,
canInitApiKeys,
shouldDisableUI,
}
}
@@ -3,7 +3,6 @@ import Image from 'next/image'
import { useState } from 'react'
import { toast } from 'sonner'
import Panel from 'components/ui/Panel'
import { useGitHubAuthorizationDeleteMutation } from 'data/integrations/github-authorization-delete-mutation'
import { useGitHubAuthorizationQuery } from 'data/integrations/github-authorization-query'
import { BASE_PATH } from 'lib/constants'
@@ -11,13 +10,24 @@ import { openInstallGitHubIntegrationWindow } from 'lib/github'
import {
Badge,
Button,
Card,
CardContent,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
export const AccountConnections = () => {
@@ -54,100 +64,103 @@ export const AccountConnections = () => {
}
return (
<Panel
className="mb-4 md:mb-8"
title={
<div>
<h5>Connections</h5>
<p className="text-sm text-foreground-lighter">
Connect your Supabase account with other services
</p>
</div>
}
>
{isLoading && (
<Panel.Content>
<ShimmeringLoader />
</Panel.Content>
)}
{isError && (
<Panel.Content>
<p className="text-sm text-destructive">
Failed to load GitHub connection status: {error?.message}
</p>
</Panel.Content>
)}
{isSuccess && (
<Panel.Content className="flex justify-between items-center">
<div className="flex gap-x-4 items-center">
<Image
className={cn('dark:invert')}
src={`${BASE_PATH}/img/icons/github-icon.svg`}
width={30}
height={30}
alt={`GitHub icon`}
/>
<div>
<p className="text-sm">GitHub</p>
<p className="text-sm text-foreground-lighter">
Sync GitHub repos to Supabase projects for automatic branch creation and merging
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Connections</PageSectionTitle>
<PageSectionDescription>
Connect your Supabase account with other services.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
{isLoading && (
<CardContent>
<ShimmeringLoader />
</CardContent>
)}
{isError && (
<CardContent>
<p className="text-sm text-destructive">
Failed to load GitHub connection status: {error?.message}
</p>
</div>
</div>
<div className="flex items-center gap-x-2 ml-2">
{isConnected ? (
<>
<Badge variant="success">Connected</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button iconRight={<ChevronDown size={14} />} type="default">
<span>Manage</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
className="space-x-2"
onSelect={(event) => {
event.preventDefault()
handleReauthenticate()
}}
>
<RefreshCw size={14} />
<p>Re-authenticate</p>
</DropdownMenuItem>
<DropdownMenuItem
className="space-x-2"
onSelect={() => setIsRemoveModalOpen(true)}
>
<Unlink size={14} />
<p>Remove connection</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<Button type="primary" onClick={handleConnect}>
Connect
</Button>
)}
</div>
</Panel.Content>
)}
<ConfirmationModal
variant="destructive"
size="small"
visible={isRemoveModalOpen}
title="Confirm to remove GitHub authorization"
confirmLabel="Remove connection"
onCancel={() => setIsRemoveModalOpen(false)}
onConfirm={handleRemove}
loading={isRemoving}
>
<p className="text-sm text-foreground-light">
Removing this authorization will disconnect your GitHub account from Supabase. You can
reconnect at any time.
</p>
</ConfirmationModal>
</Panel>
</CardContent>
)}
{isSuccess && (
<CardContent className="flex justify-between items-center">
<div className="flex gap-x-4 items-center">
<Image
className={cn('dark:invert')}
src={`${BASE_PATH}/img/icons/github-icon.svg`}
width={30}
height={30}
alt={`GitHub icon`}
/>
<div>
<p className="text-sm">GitHub</p>
<p className="text-sm text-foreground-lighter">
Sync repos to Supabase projects for automatic branch creation and merging
</p>
</div>
</div>
<div className="flex items-center gap-x-2 ml-2">
{isConnected ? (
<>
<Badge variant="success">Connected</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button iconRight={<ChevronDown size={14} />} type="default">
<span>Manage</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-44">
<DropdownMenuItem
className="space-x-2"
onSelect={(event) => {
event.preventDefault()
handleReauthenticate()
}}
>
<RefreshCw size={14} />
<p>Re-authenticate</p>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="space-x-2"
onSelect={() => setIsRemoveModalOpen(true)}
>
<Unlink size={14} />
<p>Remove connection</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<Button type="primary" onClick={handleConnect}>
Connect
</Button>
)}
</div>
</CardContent>
)}
</Card>
<ConfirmationModal
variant="destructive"
size="small"
visible={isRemoveModalOpen}
title="Confirm to remove GitHub authorization"
confirmLabel="Remove connection"
onCancel={() => setIsRemoveModalOpen(false)}
onConfirm={handleRemove}
loading={isRemoving}
>
<p className="text-sm text-foreground-light">
Removing this authorization will disconnect your GitHub account from Supabase. You can
reconnect at any time.
</p>
</ConfirmationModal>
</PageSectionContent>
</PageSection>
)
}
@@ -1,27 +1,39 @@
import Panel from 'components/ui/Panel'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_ } from 'ui'
import { CriticalIcon } from 'ui'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, CriticalIcon } from 'ui'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
import { DeleteAccountButton } from './DeleteAccountButton'
export const AccountDeletion = () => {
return (
<>
<Panel className="[&>div:first-child]:uppercase" title="Danger Zone">
<Panel.Content>
<Alert_Shadcn_ variant="destructive">
<CriticalIcon />
<AlertTitle_Shadcn_>Request for account deletion</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Deleting your account is permanent and cannot be undone. Your data will be deleted
within 30 days, except we may retain some metadata and logs for longer where required
or permitted by law.
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<DeleteAccountButton />
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</Panel.Content>
</Panel>
</>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Danger zone</PageSectionTitle>
<PageSectionDescription>
Permanently delete your Supabase account and data.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Alert_Shadcn_ variant="destructive">
<CriticalIcon />
<AlertTitle_Shadcn_>Request for account deletion</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Deleting your account is permanent and cannot be undone. Your data will be deleted
within 30 days, but we may retain some metadata and logs for longer where required or
permitted by law.
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<DeleteAccountButton />
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</PageSectionContent>
</PageSection>
)
}
@@ -7,13 +7,14 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import Panel from 'components/ui/Panel'
import { useProfileIdentitiesQuery } from 'data/profile/profile-identities-query'
import { useUnlinkIdentityMutation } from 'data/profile/profile-unlink-identity-mutation'
import { BASE_PATH } from 'lib/constants'
import {
Badge,
Button,
Card,
CardContent,
cn,
Dialog,
DialogContent,
@@ -30,6 +31,14 @@ import {
GitHubChangeEmailAddress,
SSOChangeEmailAddress,
} from './ChangeEmailAddress'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
const getProviderName = (provider: string) =>
provider === 'github'
@@ -71,126 +80,134 @@ export const AccountIdentities = () => {
}, [message])
return (
<>
<Panel className="mb-4 md:mb-8" title={<h5>Account Identities</h5>}>
{isLoading && (
<Panel.Content>
<ShimmeringLoader />
</Panel.Content>
)}
{isSuccess &&
identities.map((identity) => {
const { identity_id, provider } = identity
const username = identity.identity_data?.user_name
const providerName = getProviderName(provider)
const iconKey =
provider === 'github'
? 'github-icon'
: provider === 'email'
? 'email-icon2'
: 'saml-icon'
return (
<Panel.Content
key={identity_id}
className={cn('flex justify-between', identities.length > 1 ? 'last:border-t' : '')}
>
<div className="flex gap-x-4">
<Image
className={cn(iconKey === 'github-icon' ? 'dark:invert' : '')}
src={`${BASE_PATH}/img/icons/${iconKey}.svg`}
width={30}
height={30}
alt={`${identity.provider} icon`}
/>
<div>
<div className="flex items-center gap-x-2">
<p className="text-sm capitalize">{providerName}</p>
{provider === 'email' && data.new_email && !isChangeExpired && (
<Tooltip>
<TooltipTrigger className="flex items-center">
<Badge variant="default">Pending change</Badge>
</TooltipTrigger>
<TooltipContent>Changing to {data.new_email}</TooltipContent>
</Tooltip>
)}
{/* [Joshen] Below is not supported yet, but ideal UX */}
{/* {false && <Badge>Logged in as</Badge>} */}
</div>
<p className="text-sm text-foreground-lighter">
{!!username ? <span>{username} • </span> : null}
{identity.email}
</p>
</div>
</div>
<div className="flex items-center gap-x-1">
{provider === 'email' && (
<Button asChild type="default">
<Link href="/reset-password">Reset password</Link>
</Button>
)}
<ButtonTooltip
type="text"
icon={<Edit />}
className="w-7"
onClick={() => setSelectedProviderUpdateEmail(provider)}
tooltip={{ content: { side: 'bottom', text: 'Update email address' } }}
/>
{identities.length > 1 && (
<ButtonTooltip
type="text"
icon={<Unlink />}
className="w-7"
onClick={() => setSelectedProviderUnlink(provider)}
tooltip={{ content: { side: 'bottom', text: 'Unlink identity' } }}
/>
)}
</div>
</Panel.Content>
)
})}
</Panel>
<Dialog
open={!!selectedProviderUpdateEmail}
onOpenChange={(open: boolean) => {
if (!open) setSelectedProviderUpdateEmail(undefined)
}}
>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>
{selectedProviderUpdateEmail !== 'email'
? `Updating email address for ${getProviderName(selectedProviderUpdateEmail ?? '')} identity`
: 'Update email address'}
</DialogTitle>
</DialogHeader>
{selectedProviderUpdateEmail === 'github' ? (
<GitHubChangeEmailAddress />
) : selectedProviderUpdateEmail?.startsWith('sso') ? (
<SSOChangeEmailAddress />
) : (
<ChangeEmailAddressForm onClose={() => setSelectedProviderUpdateEmail(undefined)} />
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Account identities</PageSectionTitle>
<PageSectionDescription>
Manage the providers linked to your Supabase account and update their details.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
{isLoading && (
<CardContent>
<ShimmeringLoader />
</CardContent>
)}
</DialogContent>
</Dialog>
{isSuccess && (
<div className="divide-y">
{identities.map((identity) => {
const { identity_id, provider } = identity
const username = identity.identity_data?.user_name
const providerName = getProviderName(provider)
const iconKey =
provider === 'github'
? 'github-icon'
: provider === 'email'
? 'email-icon2'
: 'saml-icon'
<ConfirmationModal
variant="warning"
size="small"
loading={isUnlinking}
visible={!!selectedProviderUnlink}
title={`Unlink ${getProviderName(selectedProviderUnlink ?? '')} identity`}
onCancel={() => setSelectedProviderUnlink(undefined)}
onConfirm={onConfirmUnlinkIdentity}
confirmLabel="Unlink identity"
confirmLabelLoading="Unlinking identity"
alert={{
base: { variant: 'warning' },
title: `Confirm to disconnect your ${getProviderName(selectedProviderUnlink ?? '')} identity`,
description: `After disconnecting, you will only be able to sign in via ${selectedProviderUnlink === 'github' ? 'email and password' : 'your GitHub identity'}`,
}}
/>
</>
return (
<CardContent key={identity_id} className="flex justify-between items-center py-4">
<div className="flex gap-x-4">
<Image
className={cn(iconKey === 'github-icon' ? 'dark:invert' : '')}
src={`${BASE_PATH}/img/icons/${iconKey}.svg`}
width={30}
height={30}
alt={`${identity.provider} icon`}
/>
<div>
<div className="flex items-center gap-x-2">
<p className="text-sm capitalize">{providerName}</p>
{provider === 'email' && data.new_email && !isChangeExpired && (
<Tooltip>
<TooltipTrigger className="flex items-center">
<Badge variant="default">Pending change</Badge>
</TooltipTrigger>
<TooltipContent>Changing to {data.new_email}</TooltipContent>
</Tooltip>
)}
</div>
<p className="text-sm text-foreground-lighter">
{!!username ? <span>{username} • </span> : null}
{identity.email}
</p>
</div>
</div>
<div className="flex items-center gap-x-1">
{provider === 'email' && (
<Button asChild type="default">
<Link href="/reset-password">Reset password</Link>
</Button>
)}
<ButtonTooltip
type="text"
icon={<Edit />}
className="w-7"
onClick={() => setSelectedProviderUpdateEmail(provider)}
tooltip={{ content: { side: 'bottom', text: 'Update email address' } }}
/>
{identities.length > 1 && (
<ButtonTooltip
type="text"
icon={<Unlink />}
className="w-7"
onClick={() => setSelectedProviderUnlink(provider)}
tooltip={{ content: { side: 'bottom', text: 'Unlink identity' } }}
/>
)}
</div>
</CardContent>
)
})}
</div>
)}
</Card>
<Dialog
open={!!selectedProviderUpdateEmail}
onOpenChange={(open: boolean) => {
if (!open) setSelectedProviderUpdateEmail(undefined)
}}
>
<DialogContent>
<DialogHeader className="border-b">
<DialogTitle>
{selectedProviderUpdateEmail !== 'email'
? `Updating email address for ${getProviderName(selectedProviderUpdateEmail ?? '')} identity`
: 'Update email address'}
</DialogTitle>
</DialogHeader>
{selectedProviderUpdateEmail === 'github' ? (
<GitHubChangeEmailAddress />
) : selectedProviderUpdateEmail?.startsWith('sso') ? (
<SSOChangeEmailAddress />
) : (
<ChangeEmailAddressForm onClose={() => setSelectedProviderUpdateEmail(undefined)} />
)}
</DialogContent>
</Dialog>
<ConfirmationModal
variant="warning"
size="small"
loading={isUnlinking}
visible={!!selectedProviderUnlink}
title={`Unlink ${getProviderName(selectedProviderUnlink ?? '')} identity`}
onCancel={() => setSelectedProviderUnlink(undefined)}
onConfirm={onConfirmUnlinkIdentity}
confirmLabel="Unlink identity"
confirmLabelLoading="Unlinking identity"
alert={{
base: { variant: 'warning' },
title: `Confirm to disconnect your ${getProviderName(selectedProviderUnlink ?? '')} identity`,
description: `After disconnecting, you will only be able to sign in via ${selectedProviderUnlink === 'github' ? 'email and password' : 'your GitHub identity'}`,
}}
/>
</PageSectionContent>
</PageSection>
)
}
@@ -4,10 +4,17 @@ import { toast } from 'sonner'
import * as z from 'zod'
import { useConsentState } from 'common'
import Panel from 'components/ui/Panel'
import { useSendResetMutation } from 'data/telemetry/send-reset-mutation'
import { FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui'
import { Card, CardContent, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
const AnalyticsSchema = z.object({
telemetryEnabled: z.boolean(),
@@ -26,9 +33,11 @@ export const AnalyticsSettings = () => {
const handleToggle = (value: boolean) => {
if (!hasLoaded) {
return toast.error(
toast.error(
"We couldn't load the privacy settings due to an ad blocker or network error. Please disable any ad blockers and try again. If the problem persists, please contact support."
)
form.setValue('telemetryEnabled', !value)
return
}
if (value) {
@@ -42,32 +51,44 @@ export const AnalyticsSettings = () => {
}
return (
<Panel title={<h5 key="panel-title">Analytics and Marketing</h5>}>
<Panel.Content>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Analytics and Marketing</PageSectionTitle>
<PageSectionDescription>
Control whether telemetry and marketing data is sent from Supabase services.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form_Shadcn_ {...form}>
<FormField_Shadcn_
control={form.control}
name="telemetryEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Send telemetry data from Supabase services"
description="By opting in to sharing telemetry data, Supabase can analyze usage patterns to enhance user experience and use it for marketing and advertising purposes"
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
handleToggle(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="telemetryEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Send telemetry data from Supabase services"
description="By opting in to sharing telemetry data, Supabase can analyze usage patterns to enhance user experience and use it for marketing and advertising purposes"
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
handleToggle(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
</Card>
</Form_Shadcn_>
</Panel.Content>
</Panel>
</PageSectionContent>
</PageSection>
)
}
@@ -4,10 +4,25 @@ import * as z from 'zod'
import { LOCAL_STORAGE_KEYS } from 'common'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import Panel from 'components/ui/Panel'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, KeyboardShortcut, Switch } from 'ui'
import {
Card,
CardContent,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
KeyboardShortcut,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
const HotkeySchema = z.object({
commandMenuEnabled: z.boolean(),
@@ -39,99 +54,102 @@ export const HotkeySettings = () => {
})
return (
<Panel
title={
<div>
<h5 id="keyboard-shortcuts">Keyboard shortcuts</h5>
<p className="text-sm text-foreground-lighter">
Choose which shortcuts stay active while working in the dashboard
</p>
</div>
}
>
<Form_Shadcn_ {...form}>
<Panel.Content className="border-b">
<FormField_Shadcn_
control={form.control}
name="commandMenuEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'k']} />
<span>Command menu</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setCommandMenuEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Panel.Content>
<Panel.Content className="border-b">
<FormField_Shadcn_
control={form.control}
name="aiAssistantEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'i']} />
<span>AI Assistant Panel</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setAiAssistantEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Panel.Content>
<Panel.Content>
<FormField_Shadcn_
control={form.control}
name="inlineEditorEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'e']} />
<span>Inline SQL Editor Panel</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setInlineEditorEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</Panel.Content>
</Form_Shadcn_>
</Panel>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle id="keyboard-shortcuts">Keyboard shortcuts</PageSectionTitle>
<PageSectionDescription>
Choose which shortcuts stay active while working in the dashboard.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form_Shadcn_ {...form}>
<Card>
<CardContent className="border-b">
<FormField_Shadcn_
control={form.control}
name="commandMenuEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'k']} />
<span>Command menu</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setCommandMenuEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent className="border-b">
<FormField_Shadcn_
control={form.control}
name="aiAssistantEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'i']} />
<span>AI Assistant Panel</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setAiAssistantEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="inlineEditorEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label={
<div className="flex items-center gap-x-3">
<KeyboardShortcut keys={['Meta', 'e']} />
<span>Inline SQL Editor Panel</span>
</div>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
setInlineEditorEnabled(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
</Card>
</Form_Shadcn_>
</PageSectionContent>
</PageSection>
)
}
@@ -3,12 +3,19 @@ import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { LOCAL_STORAGE_KEYS } from 'common'
import Panel from 'components/ui/Panel'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui'
import { Card, CardContent, FormControl_Shadcn_, FormField_Shadcn_, Form_Shadcn_, Switch } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
const InlineEditorSchema = z.object({
inlineEditorEnabled: z.boolean(),
@@ -55,32 +62,44 @@ export const InlineEditorSettings = () => {
}
return (
<Panel title={<h5 id="inline-editor">Dashboard</h5>}>
<Panel.Content>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle id="inline-editor">Dashboard</PageSectionTitle>
<PageSectionDescription>
Choose your preferred experience when editing policies, triggers, and functions.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form_Shadcn_ {...form}>
<FormField_Shadcn_
control={form.control}
name="inlineEditorEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Edit entities in SQL"
description="When enabled, view and edit policies, triggers, and functions directly in the SQL editor instead of a more beginner-friendly UI panel. Ideal for those comfortable with SQL."
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
handleToggle(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="inlineEditorEnabled"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Edit entities in SQL"
description="When enabled, view and edit policies, triggers, and functions directly in the SQL editor instead of a more beginner-friendly UI panel. Ideal for those comfortable with SQL."
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value)
handleToggle(value)
}}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
</Card>
</Form_Shadcn_>
</Panel.Content>
</Panel>
</PageSectionContent>
</PageSection>
)
}
@@ -6,7 +6,6 @@ import {
Card,
CardContent,
CardFooter,
CardHeader,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
@@ -25,6 +24,13 @@ import { useProfile } from 'lib/profile'
import { groupBy } from 'lodash'
import type { FormSchema } from 'types'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
PageSection,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
const FormSchema = z.object({
first_name: z.string().optional(),
@@ -59,7 +65,7 @@ export const ProfileInformation = () => {
values: defaultValues,
})
const { mutate: updateProfile, isPending } = useProfileUpdateMutation({
const { mutate: updateProfile, isPending: isUpdatingProfile } = useProfileUpdateMutation({
onSuccess: (data) => {
toast.success('Successfully saved profile')
const { first_name, last_name, username, primary_email } = data
@@ -78,111 +84,128 @@ export const ProfileInformation = () => {
}
return (
<Form_Shadcn_ {...form}>
<form id={formId} className="space-y-6 w-full" onSubmit={form.handleSubmit(onSubmit)}>
<Card className="mb-8">
<CardHeader>Profile Information</CardHeader>
<CardContent className="flex flex-col gap-y-2">
<FormField_Shadcn_
control={form.control}
name="first_name"
render={({ field }) => (
<FormItemLayout label="First name" layout="horizontal">
<FormControl_Shadcn_ className="col-span-8">
<Input_Shadcn_ {...field} className="w-72" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="last_name"
render={({ field }) => (
<FormItemLayout label="Last name" layout="horizontal">
<FormControl_Shadcn_ className="col-span-8">
<Input_Shadcn_ {...field} className="w-72" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="primary_email"
render={({ field }) => (
<FormItemLayout label="Primary email" layout="horizontal">
<FormControl_Shadcn_ className="col-span-8">
<div className="flex flex-col gap-1">
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
disabled={profile?.is_sso_user}
>
<SelectTrigger_Shadcn_ className="col-span-8 w-72">
<SelectValue_Shadcn_ placeholder="Select primary email" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_ className="col-span-8">
{isIdentitiesSuccess &&
dedupedIdentityEmails.map((email) => (
<SelectItem_Shadcn_ key={email} value={email}>
{email}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
{(profile?.is_sso_user && (
<p className="text-xs text-foreground-light">
Primary email is managed by your SSO provider and cannot be changed here.
</p>
)) || (
<p className="text-xs text-foreground-light">
Primary email is used for account notifications.
</p>
)}
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="username"
render={({ field }) => (
<FormItemLayout label="Username" layout="horizontal">
<FormControl_Shadcn_ className="col-span-8">
<div className="flex flex-col gap-1">
<Input_Shadcn_ {...field} className="w-72" disabled={profile?.is_sso_user} />
{(profile?.is_sso_user && (
<p className="text-xs text-foreground-light">
Username is managed by your SSO provider and cannot be changed here.
</p>
)) || (
<p className="text-xs text-foreground-light">
Username appears as a display name throughout the dashboard.
</p>
)}
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isPending || isIdentitiesLoading}
disabled={!form.formState.isDirty}
>
Save
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Profile information</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Form_Shadcn_ {...form}>
<form id={formId} className="space-y-6 w-full" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="first_name"
render={({ field }) => (
<FormItemLayout label="First name" layout="flex-row-reverse">
<FormControl_Shadcn_ className="col-span-8">
<Input_Shadcn_ {...field} placeholder="First name" className="w-full" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="last_name"
render={({ field }) => (
<FormItemLayout label="Last name" layout="flex-row-reverse">
<FormControl_Shadcn_ className="col-span-8">
<Input_Shadcn_ {...field} placeholder="Last name" className="w-full" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="primary_email"
render={({ field }) => (
<FormItemLayout
label="Primary email"
description={
profile?.is_sso_user
? 'Primary email is managed by your SSO provider and cannot be changed here.'
: 'Primary email is used for account notifications.'
}
layout="flex-row-reverse"
>
<FormControl_Shadcn_ className="col-span-8">
<div className="flex flex-col gap-1">
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
disabled={profile?.is_sso_user}
>
<SelectTrigger_Shadcn_ className="col-span-8 w-full">
<SelectValue_Shadcn_ placeholder="Select primary email" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_ className="col-span-8">
{isIdentitiesSuccess &&
dedupedIdentityEmails.map((email) => (
<SelectItem_Shadcn_ key={email} value={email}>
{email}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="username"
render={({ field }) => (
<FormItemLayout
label="Username"
description={
profile?.is_sso_user
? 'Username is managed by your SSO provider and cannot be changed here.'
: 'Username appears as a display name throughout the dashboard.'
}
layout="flex-row-reverse"
>
<FormControl_Shadcn_ className="col-span-8">
<div className="flex flex-col gap-1">
<Input_Shadcn_
{...field}
className="w-full"
placeholder="Username"
disabled={profile?.is_sso_user}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdatingProfile || isIdentitiesLoading}
disabled={!form.formState.isDirty}
>
Save
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</PageSectionContent>
</PageSection>
)
}
@@ -2,11 +2,13 @@ import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import SVG from 'react-inlinesvg'
import { LOCAL_STORAGE_KEYS } from 'common'
import { DEFAULT_SIDEBAR_BEHAVIOR } from 'components/interfaces/Sidebar'
import Panel from 'components/ui/Panel'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { BASE_PATH } from 'lib/constants'
import {
Card,
CardContent,
Label_Shadcn_,
RadioGroup_Shadcn_,
RadioGroupLargeItem_Shadcn_,
@@ -20,7 +22,14 @@ import {
Theme,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { LOCAL_STORAGE_KEYS } from 'common'
import {
PageSection,
PageSectionContent,
PageSectionDescription,
PageSectionMeta,
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
export const ThemeSettings = () => {
const [mounted, setMounted] = useState(false)
@@ -41,73 +50,80 @@ export const ThemeSettings = () => {
function SingleThemeSelection() {
return (
<form>
<RadioGroup_Shadcn_
name="theme"
onValueChange={setTheme}
aria-label="Choose a theme"
defaultValue={theme}
value={theme}
className="flex flex-wrap gap-2 md:gap-5"
>
{singleThemes.map((theme: Theme) => (
<RadioGroupLargeItem_Shadcn_
className="p-3"
key={theme.value}
value={theme.value}
label={theme.name}
>
<SVG src={`${BASE_PATH}/img/themes/${theme.value}.svg?v=2`} />
</RadioGroupLargeItem_Shadcn_>
))}
</RadioGroup_Shadcn_>
</form>
<RadioGroup_Shadcn_
name="theme"
onValueChange={setTheme}
aria-label="Choose a theme"
defaultValue={theme}
value={theme}
className="grid grid-cols-2 gap-4"
>
{singleThemes.map((theme: Theme) => (
<RadioGroupLargeItem_Shadcn_
className="p-3 w-full"
key={theme.value}
value={theme.value}
label={theme.name}
>
<SVG src={`${BASE_PATH}/img/themes/${theme.value}.svg?v=2`} />
</RadioGroupLargeItem_Shadcn_>
))}
</RadioGroup_Shadcn_>
)
}
return (
<Panel title={<h5 key="panel-title">Appearance</h5>}>
<Panel.Content className="grid gap-8 !py-5">
<div className="grid grid-cols-12">
<div className="col-span-full md:col-span-4 flex flex-col gap-5">
<Label_Shadcn_ htmlFor="theme" className="text-light">
Theme mode
</Label_Shadcn_>
<p className="text-sm text-foreground-light max-w-[220px]">
Choose how Supabase looks to you. Select a single theme, or sync with your system.
</p>
</div>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Appearance</PageSectionTitle>
<PageSectionDescription>
Choose how Supabase looks and behaves in the dashboard.
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent className="grid grid-cols-12 gap-6">
<div className="col-span-full md:col-span-4 flex flex-col gap-2">
<Label_Shadcn_ htmlFor="theme" className="text-foreground">
Theme mode
</Label_Shadcn_>
<p className="text-sm text-foreground-light">
Choose how Supabase looks to you. Select a single theme, or sync with your system.
</p>
</div>
<div className="col-span-full md:col-span-8 flex flex-col gap-4">
<p className="text-sm text-light">Supabase will use your selected theme</p>
<SingleThemeSelection />
</div>
</div>
</Panel.Content>
<Separator />
<Panel.Content>
<FormItemLayout
isReactForm={false}
label="Sidebar behavior"
layout="flex-row-reverse"
description="Choose your preferred sidebar behavior: open, closed, or expand on hover."
>
<Select_Shadcn_
value={sidebarBehaviour}
onValueChange={setSidebarBehaviour}
aria-label="Select an option"
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Choose an option" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectItem_Shadcn_ value="open">Expanded</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="closed">Collapsed</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="expandable">Expand on hover</SelectItem_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormItemLayout>
</Panel.Content>
</Panel>
<div className="col-span-full md:col-span-8 flex flex-col gap-4">
<SingleThemeSelection />
</div>
</CardContent>
<Separator />
<CardContent>
<FormItemLayout
isReactForm={false}
label="Sidebar behavior"
layout="flex-row-reverse"
description="Choose your preferred sidebar behavior: open, closed, or expand on hover."
>
<Select_Shadcn_
value={sidebarBehaviour}
onValueChange={setSidebarBehaviour}
aria-label="Select an option"
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Choose an option" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectItem_Shadcn_ value="open">Expanded</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="closed">Collapsed</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="expandable">Expand on hover</SelectItem_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormItemLayout>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
)
}
@@ -1,8 +1,9 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Key } from 'lucide-react'
import { useMemo } from 'react'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Badge, copyToClipboard } from 'ui'
import type { ICommand } from 'ui-patterns/CommandMenu'
@@ -25,7 +26,11 @@ export function useApiKeysCommands() {
const { data: project } = useSelectedProjectQuery()
const ref = project?.ref || '_'
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.SECRETS_READ,
'*'
)
const { data: apiKeys } = useAPIKeysQuery(
{ projectRef: project?.ref, reveal: true },
{ enabled: canReadAPIKeys }
@@ -15,7 +15,7 @@ import { useDatabasePolicyDeleteMutation } from 'data/database-policies/database
import { useTableUpdateMutation } from 'data/tables/table-update-mutation'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Button, Card, CardContent } from 'ui'
import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
interface PoliciesProps {
search?: string
@@ -51,7 +51,7 @@ export const Policies = ({
}>()
const [selectedPolicyToDelete, setSelectedPolicyToDelete] = useState<any>({})
const { mutate: updateTable } = useTableUpdateMutation({
const { mutate: updateTable, isPending: isUpdatingTable } = useTableUpdateMutation({
onError: (error) => {
toast.error(`Failed to toggle RLS: ${error.message}`)
},
@@ -60,14 +60,15 @@ export const Policies = ({
},
})
const { mutate: deleteDatabasePolicy } = useDatabasePolicyDeleteMutation({
onSuccess: () => {
toast.success('Successfully deleted policy!')
},
onSettled: () => {
closeConfirmModal()
},
})
const { mutate: deleteDatabasePolicy, isPending: isDeletingPolicy } =
useDatabasePolicyDeleteMutation({
onSuccess: () => {
toast.success('Successfully deleted policy!')
},
onSettled: () => {
closeConfirmModal()
},
})
const closeConfirmModal = useCallback(() => {
setSelectedPolicyToDelete({})
@@ -180,30 +181,30 @@ export const Policies = ({
) : null}
</div>
<ConfirmModal
danger
<ConfirmationModal
visible={!isEmpty(selectedPolicyToDelete)}
title="Confirm to delete policy"
description={`This is permanent! Are you sure you want to delete the policy "${selectedPolicyToDelete.name}"`}
buttonLabel="Delete"
buttonLoadingLabel="Deleting"
onSelectCancel={closeConfirmModal}
onSelectConfirm={onDeletePolicy}
variant="destructive"
title="Delete policy"
description={`Are you sure you want to delete the policy “${selectedPolicyToDelete.name}”? This action cannot be undone.`}
confirmLabel="Delete"
confirmLabelLoading="Deleting"
loading={isDeletingPolicy}
onCancel={closeConfirmModal}
onConfirm={onDeletePolicy}
/>
<ConfirmModal
danger={selectedTableToToggleRLS?.rls_enabled}
<ConfirmationModal
visible={selectedTableToToggleRLS !== undefined}
title={`Confirm to ${
selectedTableToToggleRLS?.rls_enabled ? 'disable' : 'enable'
} Row Level Security`}
variant={selectedTableToToggleRLS?.rls_enabled ? 'destructive' : 'default'}
title={`${selectedTableToToggleRLS?.rls_enabled ? 'Disable' : 'Enable'} Row Level Security`}
description={`Are you sure you want to ${
selectedTableToToggleRLS?.rls_enabled ? 'disable' : 'enable'
} Row Level Security for the table "${selectedTableToToggleRLS?.name}"?`}
buttonLabel="Confirm"
buttonLoadingLabel="Saving"
onSelectCancel={closeConfirmModal}
onSelectConfirm={onToggleRLS}
} Row Level Security (RLS) for the table “${selectedTableToToggleRLS?.name}”?`}
confirmLabel={`${selectedTableToToggleRLS?.rls_enabled ? 'Disable' : 'Enable'} RLS`}
confirmLabelLoading={`${selectedTableToToggleRLS?.rls_enabled ? 'Disabling' : 'Enabling'} RLS`}
loading={isUpdatingTable}
onCancel={closeConfirmModal}
onConfirm={onToggleRLS}
/>
</>
)
@@ -0,0 +1,451 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
// Mock generateSqlPolicy for AI tests
const mockGenerateSqlPolicy = vi.fn()
vi.mock('data/ai/sql-policy-mutation', () => ({
generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args),
}))
// Import after mocks are set up
import {
generateAiPoliciesForTable,
generateProgrammaticPoliciesForTable,
generateStartingPoliciesForTable,
type GeneratedPolicy,
} from './Policies.utils'
// Helper to create a foreign key constraint
const createForeignKey = (overrides: Partial<ForeignKeyConstraint> = {}): ForeignKeyConstraint => ({
id: 1,
constraint_name: 'fk_constraint',
source_id: 100,
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_id: 200,
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
deletion_action: 'NO ACTION',
update_action: 'NO ACTION',
...overrides,
})
describe('Policies.utils - Policy Generation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('generateProgrammaticPoliciesForTable', () => {
it('should generate 4 CRUD policies for direct FK to auth.users', () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
expect(policies).toHaveLength(4)
const commands = policies.map((p) => p.command)
expect(commands).toContain('SELECT')
expect(commands).toContain('INSERT')
expect(commands).toContain('UPDATE')
expect(commands).toContain('DELETE')
})
it('should return empty array when no FK path to auth.users exists', () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['category_id'],
target_schema: 'public',
target_table: 'categories',
target_columns: ['id'],
}),
]
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
expect(policies).toHaveLength(0)
})
it('should return empty array when foreignKeyConstraints is empty', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints: [],
})
expect(policies).toHaveLength(0)
})
it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => {
// posts -> profiles -> auth.users
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
id: 1,
source_schema: 'public',
source_table: 'posts',
source_columns: ['profile_id'],
target_schema: 'public',
target_table: 'profiles',
target_columns: ['id'],
}),
createForeignKey({
id: 2,
source_schema: 'public',
source_table: 'profiles',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
expect(policies).toHaveLength(4)
// Check that the expression contains EXISTS for indirect path
const selectPolicy = policies.find((p) => p.command === 'SELECT')
expect(selectPolicy?.definition).toContain('exists')
expect(selectPolicy?.sql).toContain('exists')
})
describe('policy structure validation', () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
it('should include all required fields in generated policies', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
for (const policy of policies) {
expect(policy).toHaveProperty('name')
expect(policy).toHaveProperty('sql')
expect(policy).toHaveProperty('command')
expect(policy).toHaveProperty('table', 'posts')
expect(policy).toHaveProperty('schema', 'public')
expect(policy).toHaveProperty('action', 'PERMISSIVE')
expect(policy).toHaveProperty('roles')
expect(policy.roles).toContain('public')
}
})
it('SELECT policy should have definition but no check', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const selectPolicy = policies.find((p) => p.command === 'SELECT')
expect(selectPolicy?.definition).toBeDefined()
expect(selectPolicy?.check).toBeUndefined()
})
it('DELETE policy should have definition but no check', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const deletePolicy = policies.find((p) => p.command === 'DELETE')
expect(deletePolicy?.definition).toBeDefined()
expect(deletePolicy?.check).toBeUndefined()
})
it('INSERT policy should have check but no definition', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const insertPolicy = policies.find((p) => p.command === 'INSERT')
expect(insertPolicy?.definition).toBeUndefined()
expect(insertPolicy?.check).toBeDefined()
})
it('UPDATE policy should have both definition and check', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const updatePolicy = policies.find((p) => p.command === 'UPDATE')
expect(updatePolicy?.definition).toBeDefined()
expect(updatePolicy?.check).toBeDefined()
})
it('should generate correct SQL syntax for direct FK', () => {
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
})
const selectPolicy = policies.find((p) => p.command === 'SELECT')
expect(selectPolicy?.sql).toContain('CREATE POLICY')
expect(selectPolicy?.sql).toContain('public.posts')
expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT')
expect(selectPolicy?.sql).toContain('TO public')
expect(selectPolicy?.sql).toContain('USING')
expect(selectPolicy?.sql).toContain('auth.uid()')
})
})
it('should handle non-public schema', () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'private',
source_table: 'documents',
source_columns: ['owner_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = generateProgrammaticPoliciesForTable({
table: { name: 'documents', schema: 'private' },
foreignKeyConstraints,
})
expect(policies).toHaveLength(4)
expect(policies[0].schema).toBe('private')
expect(policies[0].sql).toContain('private.documents')
})
})
describe('generateAiPoliciesForTable', () => {
const mockAiPolicies: GeneratedPolicy[] = [
{
name: 'ai_select_policy',
sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);',
command: 'SELECT',
table: 'posts',
schema: 'public',
definition: 'true',
action: 'PERMISSIVE',
roles: ['public'],
},
]
it('should return policies from AI when called with valid inputs', async () => {
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
const policies = await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: 'id' }, { name: 'title' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
})
expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({
tableName: 'posts',
schema: 'public',
columns: ['id', 'title'],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
})
expect(policies).toEqual(mockAiPolicies)
})
it('should return empty array when connectionString is null', async () => {
const policies = await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: null,
})
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
expect(policies).toEqual([])
})
it('should return empty array when connectionString is undefined', async () => {
const policies = await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: undefined,
})
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
expect(policies).toEqual([])
})
it('should handle API errors gracefully and return empty array', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
mockGenerateSqlPolicy.mockRejectedValue(new Error('API error'))
const policies = await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
})
expect(policies).toEqual([])
expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error))
consoleLogSpy.mockRestore()
})
it('should trim column names before sending to API', async () => {
mockGenerateSqlPolicy.mockResolvedValue([])
await generateAiPoliciesForTable({
table: { name: 'posts', schema: 'public' },
columns: [{ name: ' id ' }, { name: ' title ' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
})
expect(mockGenerateSqlPolicy).toHaveBeenCalledWith(
expect.objectContaining({
columns: ['id', 'title'],
})
)
})
})
describe('generateStartingPoliciesForTable', () => {
const mockAiPolicies: GeneratedPolicy[] = [
{
name: 'ai_policy',
sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);',
command: 'SELECT',
table: 'posts',
schema: 'public',
definition: 'true',
action: 'PERMISSIVE',
roles: ['public'],
},
]
it('should use programmatic policies when FK path exists (does not call AI)', async () => {
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: true,
})
expect(policies).toHaveLength(4)
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
})
it('should fall back to AI when no FK path exists and enableAi is true', async () => {
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints: [],
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: true,
})
expect(mockGenerateSqlPolicy).toHaveBeenCalled()
expect(policies).toEqual(mockAiPolicies)
})
it('should return empty array when no FK path exists and enableAi is false', async () => {
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints: [],
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: false,
})
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
expect(policies).toEqual([])
})
it('should return empty array when no FK path and AI returns empty', async () => {
mockGenerateSqlPolicy.mockResolvedValue([])
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints: [],
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: true,
})
expect(policies).toEqual([])
})
it('should prioritize programmatic over AI even when both could generate policies', async () => {
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
const foreignKeyConstraints: ForeignKeyConstraint[] = [
createForeignKey({
source_schema: 'public',
source_table: 'posts',
source_columns: ['user_id'],
target_schema: 'auth',
target_table: 'users',
target_columns: ['id'],
}),
]
const policies = await generateStartingPoliciesForTable({
table: { name: 'posts', schema: 'public' },
foreignKeyConstraints,
columns: [{ name: 'id' }],
projectRef: 'test-project',
connectionString: 'postgresql://localhost:5432/test',
enableAi: true,
})
// Should return 4 programmatic policies, not 1 AI policy
expect(policies).toHaveLength(4)
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
})
})
})
@@ -1,5 +1,10 @@
import type { PostgresPolicy } from '@supabase/postgres-meta'
import { has, isEmpty, isEqual } from 'lodash'
import { ident } from '@supabase/pg-meta/src/pg-format'
import { generateSqlPolicy } from 'data/ai/sql-policy-mutation'
import type { CreatePolicyBody } from 'data/database-policies/database-policy-create-mutation'
import type { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
import {
PolicyFormField,
PolicyForReview,
@@ -73,7 +78,7 @@ const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): P
return { description, statement }
}
export const createSQLStatementForUpdatePolicy = (
const createSQLStatementForUpdatePolicy = (
policyFormFields: PolicyFormField,
fieldsToUpdate: Partial<PolicyFormField>
): PolicyForReview => {
@@ -151,3 +156,287 @@ export const createPayloadForUpdatePolicy = (
return payload
}
// --- Policy Generation ---
/**
* Generated policy extends CreatePolicyBody with additional fields for display.
* - sql: Full CREATE POLICY SQL statement for preview
* - Required fields that are optional in CreatePolicyBody
*/
export type GeneratedPolicy = Required<
Pick<CreatePolicyBody, 'name' | 'table' | 'schema' | 'action' | 'roles'>
> &
Pick<CreatePolicyBody, 'command' | 'definition' | 'check'> & {
sql: string
}
type Relationship = {
source_schema: string
source_table_name: string
source_column_name: string
target_table_schema: string
target_table_name: string
target_column_name: string
}
/**
* Gets relationships for a specific table from FK constraints.
* Returns relationships where the table is the source.
*/
const getRelationshipsForTable = ({
schema,
table,
fkConstraints,
}: {
schema: string
table: string
fkConstraints: ForeignKeyConstraint[]
}): Relationship[] => {
return fkConstraints
.filter((fk) => fk.source_schema === schema && fk.source_table === table)
.flatMap((fk) =>
fk.source_columns.map((sourceCol, i) => ({
source_schema: fk.source_schema,
source_table_name: fk.source_table,
source_column_name: sourceCol,
target_table_schema: fk.target_schema,
target_table_name: fk.target_table,
target_column_name: fk.target_columns[i],
}))
)
}
/**
* BFS to find shortest path from table to auth.users via foreign key relationships.
* Returns null if no path exists within maxDepth.
*/
const findPathToAuthUsers = (
startTable: { schema: string; name: string },
allForeignKeyConstraints: ForeignKeyConstraint[],
maxDepth = 3
): Relationship[] | null => {
const startRelationships = getRelationshipsForTable({
schema: startTable.schema,
table: startTable.name,
fkConstraints: allForeignKeyConstraints,
})
const queue: { table: { schema: string; name: string }; path: Relationship[] }[] = [
{ table: startTable, path: [] },
]
const visited = new Set<string>()
visited.add(`${startTable.schema}.${startTable.name}`)
while (queue.length > 0) {
const queueItem = queue.shift()
if (!queueItem) continue
const { table, path } = queueItem
if (path.length >= maxDepth) continue
const relationships =
path.length === 0
? startRelationships
: getRelationshipsForTable({
schema: table.schema,
table: table.name,
fkConstraints: allForeignKeyConstraints,
})
for (const rel of relationships) {
// Found path to auth.users
if (
rel.target_table_schema === 'auth' &&
rel.target_table_name === 'users' &&
rel.target_column_name === 'id'
) {
return [...path, rel]
}
const targetId = `${rel.target_table_schema}.${rel.target_table_name}`
if (visited.has(targetId)) continue
// Add target table to queue for further exploration
queue.push({
table: { schema: rel.target_table_schema, name: rel.target_table_name },
path: [...path, rel],
})
visited.add(targetId)
}
}
return null
}
/** Generates SQL expression for RLS policy based on FK path to auth.users */
const buildPolicyExpression = (path: Relationship[]): string => {
if (path.length === 0) return ''
// Direct FK to auth.users
if (path.length === 1) {
return `(select auth.uid()) = ${ident(path[0].source_column_name)}`
}
// Indirect path - build EXISTS with JOINs
const [first, ...rest] = path
const firstTarget = `${ident(first.target_table_schema)}.${ident(first.target_table_name)}`
const source = `${ident(first.source_schema)}.${ident(first.source_table_name)}`
const last = path[path.length - 1]
const joins = rest
.slice(0, -1)
.map((r) => {
const targetSchema = ident(r.target_table_schema)
const targetTable = ident(r.target_table_name)
const targetColumn = ident(r.target_column_name)
const sourceSchema = ident(r.source_schema)
const sourceTable = ident(r.source_table_name)
const sourceColumn = ident(r.source_column_name)
return `join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}`
})
.join('\n ')
return `exists (
select 1 from ${firstTarget}
${joins}
where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)}
and ${ident(last.source_schema)}.${ident(last.source_table_name)}.${ident(last.source_column_name)} = (select auth.uid())
)`
}
/** Builds policy SQL for all CRUD operations */
const buildPoliciesForPath = (
table: { name: string; schema: string },
path: Relationship[]
): GeneratedPolicy[] => {
const expression = buildPolicyExpression(path)
const targetCol = path[0].source_column_name
return (['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const).map((command) => {
const name = `Enable ${command.toLowerCase()} access for users based on ${ident(targetCol)}`
const base = `CREATE POLICY "${name}" ON ${ident(table.schema)}.${ident(table.name)} AS PERMISSIVE FOR ${command} TO public`
const sql =
command === 'INSERT'
? `${base} WITH CHECK (${expression});`
: command === 'UPDATE'
? `${base} USING (${expression}) WITH CHECK (${expression});`
: `${base} USING (${expression});`
// Structured data for mutation API
const definition = command === 'INSERT' ? undefined : expression
const check = command === 'SELECT' || command === 'DELETE' ? undefined : expression
return {
name,
sql,
command,
table: table.name,
schema: table.schema,
definition,
check,
action: 'PERMISSIVE' as const,
roles: ['public'],
}
})
}
/**
* Generates RLS policies programmatically based on FK relationships to auth.users.
*/
export const generateProgrammaticPoliciesForTable = ({
table,
foreignKeyConstraints,
}: {
table: { name: string; schema: string }
foreignKeyConstraints: ForeignKeyConstraint[]
}): GeneratedPolicy[] => {
try {
const path = findPathToAuthUsers(table, foreignKeyConstraints)
if (path?.length) {
return buildPoliciesForPath(table, path)
}
} catch (error) {
// Silently fail - caller will handle empty result
}
return []
}
/**
* Generates RLS policies using AI.
*/
export const generateAiPoliciesForTable = async ({
table,
columns,
projectRef,
connectionString,
}: {
table: { name: string; schema: string }
columns: { name: string }[]
projectRef: string
connectionString?: string | null
}): Promise<GeneratedPolicy[]> => {
if (!connectionString) return []
try {
const aiPolicies = await generateSqlPolicy({
tableName: table.name,
schema: table.schema,
columns: columns.map((col) => col.name.trim()),
projectRef,
connectionString: connectionString ?? '',
})
// AI response now includes all structured fields
return aiPolicies as GeneratedPolicy[]
} catch (error) {
console.log('AI policy generation failed:', error)
return []
}
}
/**
* Generates RLS policies for a table.
* First tries programmatic generation based on FK relationships to auth.users.
* Falls back to AI generation if no path exists.
*/
export const generateStartingPoliciesForTable = async ({
table,
foreignKeyConstraints,
columns,
projectRef,
connectionString,
enableAi,
}: {
table: { name: string; schema: string }
foreignKeyConstraints: ForeignKeyConstraint[]
columns: { name: string }[]
projectRef: string
connectionString?: string | null
enableAi: boolean
}): Promise<GeneratedPolicy[]> => {
// Try programmatic generation first
const programmaticPolicies = generateProgrammaticPoliciesForTable({
table,
foreignKeyConstraints,
})
if (programmaticPolicies.length > 0) {
return programmaticPolicies
}
// Fall back to AI generation
if (enableAi) {
return await generateAiPoliciesForTable({
table,
columns,
projectRef,
connectionString,
})
}
return []
}
@@ -1,12 +1,13 @@
import type { PostgresTable, PostgresTrigger } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Image from 'next/legacy/image'
import { MutableRefObject, useEffect } from 'react'
import { useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { uuidv4 } from 'lib/helpers'
import { Checkbox, Input, Listbox, Radio, SidePanel } from 'ui'
@@ -51,7 +52,7 @@ export const FormContents = ({
const restUrl = project?.restUrl
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: keys = [] } = useAPIKeysQuery(
{ projectRef: ref, reveal: true },
{ enabled: canReadAPIKeys }
@@ -1,12 +1,13 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChevronDown, Plus, X } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { uuidv4 } from 'lib/helpers'
import {
@@ -50,9 +51,9 @@ const HTTPRequestFields = ({
}: HTTPRequestFieldsProps) => {
const { ref } = useParams()
const { data: selectedProject } = useSelectedProjectQuery()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
const { canReadAPIKeys } = useApiKeysVisibility()
const { data: apiKeys } = useAPIKeysQuery(
{ projectRef: ref, reveal: true },
{ enabled: canReadAPIKeys }
@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { snakeCase } from 'lodash'
import { useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
@@ -6,7 +7,6 @@ import { toast } from 'sonner'
import * as z from 'zod'
import { useFlag, useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCheckPrimaryKeysExists } from 'data/database/primary-keys-exists-query'
@@ -19,6 +19,7 @@ import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutati
import { useUpdateDestinationPipelineMutation } from 'data/replication/update-destination-pipeline-mutation'
import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation'
import { useS3AccessKeyCreateMutation } from 'data/storage/s3-access-key-create-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
PipelineStatusRequestStatus,
@@ -130,7 +131,7 @@ export const DestinationPanel = ({
pipelineId: existingDestination?.pipelineId,
})
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery(
{ projectRef, reveal: true },
{ enabled: canReadAPIKeys }
@@ -1,16 +1,15 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { useState } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { InlineLink } from 'components/ui/InlineLink'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useAnalyticsBucketsQuery } from 'data/storage/analytics-buckets-query'
import { useIcebergNamespacesQuery } from 'data/storage/iceberg-namespaces-query'
import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Button,
FormControl_Shadcn_,
@@ -123,9 +122,8 @@ export const AnalyticsBucketFields = ({
const [showSecretAccessKey, setShowSecretAccessKey] = useState(false)
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery(
{ projectRef, reveal: true },
{ enabled: canReadAPIKeys }
@@ -133,8 +131,6 @@ export const AnalyticsBucketFields = ({
const { serviceKey } = getKeys(apiKeys)
const serviceApiKey = serviceKey?.api_key ?? ''
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef })
const {
data: keysData,
isSuccess: isSuccessKeys,
@@ -1,9 +1,10 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useParams } from 'common'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import CodeSnippet from './CodeSnippet'
import Snippets from './Snippets'
@@ -14,7 +15,7 @@ interface AuthenticationProps {
const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => {
const { ref: projectRef } = useParams()
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
@@ -1,9 +1,11 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Key } from 'lucide-react'
import { useMemo } from 'react'
import { useParams } from 'common'
import type { showApiKey } from 'components/interfaces/Docs/Docs.types'
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Button,
DropdownMenu,
@@ -15,7 +17,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility'
const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' }
@@ -34,7 +35,7 @@ export const LangSelector = ({
}: LangSelectorProps) => {
const { ref: projectRef } = useParams()
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys = [], isPending: isLoadingAPIKeys } = useAPIKeysQuery(
{
projectRef,
@@ -0,0 +1,77 @@
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
interface CostIndicatorProps {
cost: number
/** The maximum cost in the query plan, used to calculate bar width */
maxCost: number
}
type CostLevel = 'low' | 'moderate' | 'high'
function getCostLevel(cost: number): CostLevel {
if (cost < 100) return 'low'
if (cost < 1000) return 'moderate'
return 'high'
}
function getCostColorClass(level: CostLevel): string {
switch (level) {
case 'low':
return 'bg-brand'
case 'moderate':
return 'bg-warning'
case 'high':
return 'bg-destructive'
}
}
function getCostDescription(level: CostLevel): { label: string; description: string } {
switch (level) {
case 'low':
return {
label: 'Low cost',
description: 'This operation is efficient and performs well.',
}
case 'moderate':
return {
label: 'Moderate cost',
description: 'This operation may benefit from optimization, such as adding an index.',
}
case 'high':
return {
label: 'High cost',
description:
'This operation is expensive and likely needs optimization. Consider adding indexes or restructuring the query.',
}
}
}
export function CostIndicator({ cost, maxCost }: CostIndicatorProps) {
const rawWidth = maxCost > 0 ? (cost / maxCost) * 100 : 0
const costWidth = Math.min(Math.max(rawWidth, 0), 100)
const costLevel = getCostLevel(cost)
const colorClass = getCostColorClass(costLevel)
const { label, description } = getCostDescription(costLevel)
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-help">
<div className="w-16 h-1.5 bg-surface-300 rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all', colorClass)}
style={{ width: `${costWidth}%` }}
/>
</div>
<span className="text-xs text-foreground-lighter">
cost <span className="font-mono font-medium">{cost.toFixed(1)}</span>
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="font-medium">{label}</p>
<p className="text-foreground-lighter text-xs mt-1">{description}</p>
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,92 @@
import { Activity, ArrowUp, Clock, Database, GitMerge, Hash, Zap } from 'lucide-react'
import { Badge } from 'ui'
export interface ExplainSummary {
totalTime: number
hasSeqScan: boolean
hasIndexScan: boolean
}
export interface ExplainHeaderProps {
mode: 'visual' | 'raw'
onToggleMode: () => void
summary?: ExplainSummary
}
export function ExplainHeader({ mode, onToggleMode, summary }: ExplainHeaderProps) {
const isVisual = mode === 'visual'
const hasSummaryStats =
isVisual && summary && (summary.totalTime > 0 || (summary.hasSeqScan && !summary.hasIndexScan))
return (
<div className="bg-surface-100 border-b px-4 py-3 flex flex-col gap-3 text-xs">
{/* Title row */}
<div className="flex items-center gap-2">
<Activity size={16} className="text-foreground-light" />
<h3 className="text-sm font-medium text-foreground">Query Execution Plan</h3>
<button
type="button"
onClick={onToggleMode}
className="font-mono text-xs text-foreground-lighter hover:text-foreground transition-colors"
aria-label={isVisual ? 'Switch to raw explain output' : 'Switch to visual explain output'}
>
{isVisual ? '[VISUAL]' : '[RAW]'}
</button>
</div>
{/* How to read */}
<div className="flex items-center gap-2 text-foreground-lighter">
<ArrowUp size={12} className="text-foreground-light" />
<span className="font-medium text-foreground-light">How to read:</span>
<span>
Start at the bottom where data is read from tables, then follow upward as each step
processes the results.
</span>
</div>
{/* Summary stats - only show in visual mode when we have the data */}
{hasSummaryStats && (
<div className="flex items-center gap-4 flex-wrap">
{summary.totalTime > 0 && (
<div className="flex items-center gap-1.5">
<Clock size={12} className="text-foreground-muted" />
<span className="text-foreground-light">Total:</span>
<span className="font-mono font-medium text-foreground">
{summary.totalTime.toFixed(2)}ms
</span>
</div>
)}
{summary.hasSeqScan && !summary.hasIndexScan && (
<Badge variant="warning" className="text-xs gap-1">
<Database size={10} />
Sequential scan detected
</Badge>
)}
</div>
)}
{/* Icon legend - only relevant in visual mode */}
{isVisual && (
<div className="flex items-center gap-4 flex-wrap text-foreground-lighter">
<div className="flex items-center gap-1.5">
<Database size={12} className="text-warning" />
<span>Seq Scan (full table read)</span>
</div>
<div className="flex items-center gap-1.5">
<Zap size={12} className="text-brand" />
<span>Index Scan (fast lookup)</span>
</div>
<div className="flex items-center gap-1.5">
<GitMerge size={12} className="text-foreground-light" />
<span>Join</span>
</div>
<div className="flex items-center gap-1.5">
<Hash size={12} className="text-foreground-light" />
<span>Hash (lookup table)</span>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,143 @@
import { ArrowRight, ArrowUp, Clock } from 'lucide-react'
import { cn } from 'ui'
import { CostIndicator } from './ExplainVisualizer.CostIndicator'
import { RowCountIndicator } from './ExplainVisualizer.RowCountIndicator'
import {
getOperationColor,
getOperationDescription,
getOperationIcon,
} from './ExplainVisualizer.utils'
import type { ExplainNode } from './ExplainVisualizer.types'
import { useMemo } from 'react'
export interface ExplainNodeRendererProps {
node: ExplainNode
depth: number
maxCost: number
isRoot?: boolean
}
export function ExplainNodeRenderer({
node,
depth,
maxCost,
isRoot = false,
}: ExplainNodeRendererProps) {
const Icon = getOperationIcon(node.operation)
const colorClass = getOperationColor(node.operation)
const description = getOperationDescription(node.operation)
const isLeaf = node.children.length === 0
const detailLines = node.details ? node.details.split('\n').filter(Boolean) : []
const hasNonColonPrefixedFirstLine = detailLines.length > 0 && !detailLines[0].includes(':')
const targetName = hasNonColonPrefixedFirstLine ? detailLines[0] : null
const pipelineIndicatorClass = useMemo(() => {
if (isRoot) return 'bg-brand/20 text-brand'
if (isLeaf) return 'bg-surface-300 text-foreground-light'
return 'bg-surface-200 text-foreground-muted'
}, [isRoot, isLeaf])
return (
<div className="relative">
{/* Node content */}
<div
className={cn(
'flex items-start gap-3 py-3 px-3 rounded-lg border border-transparent hover:border-border hover:bg-surface-100 transition-all'
)}
style={{ marginLeft: `${depth * 32}px` }}
>
{/* Pipeline flow indicator */}
<div
className={cn(
'flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center',
pipelineIndicatorClass
)}
>
{isRoot ? (
<ArrowRight size={14} className={'text-brand'} />
) : (
<ArrowUp size={14} className={'text-foreground-light'} />
)}
</div>
{/* Icon */}
<div className={cn('flex-shrink-0 mt-0.5 rounded-lg px-1')}>
<Icon size={18} strokeWidth={2} className={colorClass} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Operation name and target */}
<div className="flex items-center gap-2 flex-wrap">
<span className={cn('font-semibold text-sm', colorClass)}>{node.operation}</span>
{targetName && (
<code className="text-xs text-foreground-light bg-surface-200 px-2 py-0.5 rounded-md font-medium">
{targetName}
</code>
)}
</div>
{/* Description */}
{description && <p className="text-xs text-foreground-lighter mt-1">{description}</p>}
{/* Metrics row */}
<div className="flex items-center gap-3 mt-2 flex-wrap">
{/* Cost visualization bar */}
{node.cost && <CostIndicator cost={node.cost.end} maxCost={maxCost} />}
{node.actualTime && (
<>
<span className="text-foreground-muted text-xs">|</span>
<div className="flex items-center gap-1 text-xs text-foreground-lighter">
<Clock size={12} />
<span className="font-mono font-medium">{node.actualTime.end.toFixed(2)}ms</span>
</div>
</>
)}
{/* Row count */}
{(node.actualRows !== undefined || node.rows !== undefined) && (
<>
<span className="text-foreground-muted text-xs">|</span>
<RowCountIndicator
actualRows={node.actualRows}
estimatedRows={node.rows}
rowsRemovedByFilter={node.rowsRemovedByFilter}
/>
</>
)}
</div>
{/* Detail lines (Filter, Hash Cond, etc.) */}
{detailLines.length > 0 && (
<div className="mt-2 space-y-0.5">
{detailLines.map((detail, idx) => {
// Skip the first line if it was already shown as the target
if (idx === 0 && !detail.includes(':')) return null
// Skip "Rows Removed" as we show it in the flow visualization
if (detail.includes('Rows Removed by Filter')) return null
return (
<div key={idx} className="text-xs text-foreground font-mono py-1 rounded">
{detail}
</div>
)
})}
</div>
)}
</div>
</div>
{/* Render children */}
{node.children.length > 0 && (
<div className="relative">
{node.children.map((child, idx) => (
<ExplainNodeRenderer key={idx} node={child} depth={depth + 1} maxCost={maxCost} />
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,189 @@
import { ArrowRight } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
interface RowCountIndicatorProps {
actualRows?: number
estimatedRows?: number
rowsRemovedByFilter?: number
}
const formatCount = (value?: number): string => value?.toLocaleString() ?? 'Unknown'
type EstimationAccuracy =
| 'severely underestimated'
| 'underestimated'
| 'severely overestimated'
| 'overestimated'
| 'accurate'
| 'unknown'
function getEstimationAccuracy(actualRows: number, estimatedRows: number): EstimationAccuracy {
if (!estimatedRows || estimatedRows === 0) {
// If estimatedRows is zero or falsy, we can't compute a ratio—treat as severely underestimated
return 'unknown'
}
const ratio = actualRows / estimatedRows
if (ratio > 10) return 'severely underestimated'
if (ratio > 2) return 'underestimated'
if (ratio < 0.1) return 'severely overestimated'
if (ratio < 0.5) return 'overestimated'
return 'accurate'
}
/** Shows: scanned → filtered out → remaining */
function FilterFlowIndicator({
totalRowsScanned,
rowsRemovedByFilter,
actualRows,
}: {
totalRowsScanned: number
rowsRemovedByFilter: number
actualRows: number
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-xs text-foreground-lighter cursor-help">
<span>{formatCount(totalRowsScanned)} rows</span>
<ArrowRight size={10} className="text-foreground-muted" />
<span className="text-destructive-600 font-medium">
-{formatCount(rowsRemovedByFilter)}
</span>
<ArrowRight size={10} className="text-foreground-muted" />
<span className="text-brand font-medium">{formatCount(actualRows)} rows</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="font-medium">Filter applied</p>
<p className="text-foreground-lighter text-xs mt-1">
{formatCount(totalRowsScanned)} rows were scanned, {formatCount(rowsRemovedByFilter)} were
filtered out, leaving {formatCount(actualRows)} rows. Consider adding an index to reduce
rows scanned.
</p>
</TooltipContent>
</Tooltip>
)
}
/** Shows a simple count when estimated equals actual */
function SimpleCountIndicator({ rows }: { rows: number }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-foreground-light font-medium text-xs cursor-help">
{formatCount(rows)} rows
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="font-medium">Row count</p>
<p className="text-foreground-lighter text-xs mt-1">
The planner's estimate matched the actual row count exactly.
</p>
</TooltipContent>
</Tooltip>
)
}
/** Shows comparison between estimated and actual rows */
function ComparisonIndicator({
actualRows,
estimatedRows,
}: {
actualRows?: number
estimatedRows?: number
}) {
const hasBothValues = actualRows !== undefined && estimatedRows !== undefined
const hasOnlyActual = actualRows !== undefined && estimatedRows === undefined
const title = hasBothValues
? 'Estimated vs actual rows'
: hasOnlyActual
? 'Actual rows'
: 'Estimated rows'
const description = getComparisonDescription({ actualRows, estimatedRows })
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-xs text-foreground-lighter cursor-help">
{estimatedRows !== undefined && <span>est. {formatCount(estimatedRows)}</span>}
{actualRows !== undefined && (
<>
{estimatedRows !== undefined && (
<ArrowRight size={10} className="text-foreground-muted" />
)}
<span className="text-foreground-light font-medium">
{formatCount(actualRows)} rows
</span>
</>
)}
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="font-medium">{title}</p>
<p className="text-foreground-lighter text-xs mt-1">{description}</p>
</TooltipContent>
</Tooltip>
)
}
function getComparisonDescription({
actualRows,
estimatedRows,
}: {
actualRows?: number
estimatedRows?: number
}): string {
const hasBothValues = actualRows !== undefined && estimatedRows !== undefined
if (!hasBothValues) {
if (actualRows !== undefined) {
return `The operation returned ${formatCount(actualRows)} rows.`
}
return `The planner estimates this operation will return ${formatCount(estimatedRows)} rows. Run with ANALYZE to see actual counts.`
}
const accuracy = getEstimationAccuracy(actualRows, estimatedRows)
const hasEstimationIssue = accuracy !== 'accurate'
if (hasEstimationIssue) {
return `The planner ${accuracy} the row count (estimated ${formatCount(estimatedRows)}, got ${formatCount(actualRows)}). This may indicate outdated statistics — consider running ANALYZE on the table.`
}
return `The planner estimated ${formatCount(estimatedRows)} rows and ${formatCount(actualRows)} were returned. The estimate was reasonably accurate.`
}
export function RowCountIndicator({
actualRows,
estimatedRows,
rowsRemovedByFilter,
}: RowCountIndicatorProps) {
const hasData = actualRows !== undefined || estimatedRows !== undefined
if (!hasData) return undefined
const hasFilterData = rowsRemovedByFilter !== undefined && actualRows !== undefined
const totalRowsScanned = hasFilterData ? actualRows + rowsRemovedByFilter : undefined
// Priority 1: Show filter flow if we have filter data
if (hasFilterData && totalRowsScanned !== undefined) {
return (
<FilterFlowIndicator
totalRowsScanned={totalRowsScanned}
rowsRemovedByFilter={rowsRemovedByFilter}
actualRows={actualRows}
/>
)
}
// Priority 2: Show simple count if estimate matches actual exactly
const estimateMatchesActual =
actualRows !== undefined && estimatedRows !== undefined && actualRows === estimatedRows
if (estimateMatchesActual) {
return <SimpleCountIndicator rows={actualRows} />
}
// Priority 3: Show comparison view
return <ComparisonIndicator actualRows={actualRows} estimatedRows={estimatedRows} />
}
@@ -0,0 +1,268 @@
import type { ExplainNode, QueryPlanRow } from './ExplainVisualizer.types'
export interface ExplainSummary {
totalTime: number
totalCost: number
hasSeqScan: boolean
seqScanTables: string[]
hasIndexScan: boolean
}
function parseFloatMetric(value: string): number | undefined {
const parsed = parseFloat(value)
return Number.isFinite(parsed) ? parsed : undefined
}
function parseIntMetric(value: string): number | undefined {
const parsed = parseInt(value, 10)
return Number.isNaN(parsed) ? undefined : parsed
}
// Parse the QUERY PLAN text into a tree structure
export function parseExplainOutput(rows: readonly QueryPlanRow[]): ExplainNode[] {
const lines = rows.map((row) => row['QUERY PLAN'] || '').filter(Boolean)
const root: ExplainNode[] = []
const stack: { node: ExplainNode; indent: number }[] = []
// Detail line patterns that should be attached to the previous node
const detailPatterns =
/^(Filter|Sort Key|Group Key|Hash Cond|Join Filter|Index Cond|Recheck Cond|Rows Removed by Filter|Rows Removed by Index Recheck|Output|Merge Cond|Sort Method|Worker \d+|Buffers|Planning Time|Execution Time|One-Time Filter|InitPlan|SubPlan):/
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Skip empty lines
if (!line.trim()) continue
// Calculate the indentation (number of leading spaces)
const leadingMatch = line.match(/^(\s*)/)
const leadingSpaces = leadingMatch ? leadingMatch[1].length : 0
// Check if this line has an arrow (indicates a child operation node)
const hasArrow = line.includes('->')
// Extract the content after any arrow
let content = line
let effectiveIndent = leadingSpaces
if (hasArrow) {
// Find position of -> and use that for indent calculation
const arrowIndex = line.indexOf('->')
effectiveIndent = arrowIndex
content = line.substring(arrowIndex + 2).trim()
} else {
content = line.trim()
}
// Skip Planning Time and Execution Time summary lines (at root level)
if (
content.startsWith('Planning Time:') ||
content.startsWith('Execution Time:') ||
content.startsWith('Planning:') ||
content.startsWith('Execution:')
) {
continue
}
// Check if this is a detail line (like Filter:, Sort Key:, etc.)
if (detailPatterns.test(content) && stack.length > 0) {
// Attach to the most recent node at or above this indentation
const currentNode = stack[stack.length - 1].node
currentNode.details += (currentNode.details ? '\n' : '') + content
continue
}
// Check if this is a continuation of details (indented text without operation pattern)
// These are typically wrapped condition expressions
if (!hasArrow && stack.length > 0 && leadingSpaces > 0) {
const lastItem = stack[stack.length - 1]
// If it's more indented than the last node and doesn't look like an operation
if (leadingSpaces > lastItem.indent && !content.match(/^\w+.*\(cost=/)) {
lastItem.node.details += (lastItem.node.details ? '\n' : '') + content
continue
}
}
// Parse main operation line: "Operation on table (metrics)"
// Match operation with optional metrics in parentheses
// Handle multiple metric groups like (cost=...) (actual time=...)
const metricsMatch = content.match(/^(.+?)\s*(\([^)]*cost=[^)]+\)(?:\s*\([^)]+\))*)?\s*$/)
if (!metricsMatch) {
continue
}
const [, operationPart, metricsStr] = metricsMatch
const metrics = metricsStr
? metricsStr.replace(/^\(|\)$/g, '').replace(/\)\s*\(/g, ' ')
: undefined
// Split operation and object name (e.g., "Seq Scan on users" -> operation: "Seq Scan", details: "users")
let operation = operationPart.trim()
let details = ''
// Check for "on tablename" or "using indexname" patterns
const onMatch = operationPart.match(/^(.+?)\s+on\s+(.+)$/i)
const usingMatch = operationPart.match(/^(.+?)\s+using\s+(.+)$/i)
if (onMatch) {
operation = onMatch[1].trim()
details = 'on ' + onMatch[2].trim()
} else if (usingMatch) {
operation = usingMatch[1].trim()
details = 'using ' + usingMatch[2].trim()
}
// Calculate the tree level based on indentation
// PostgreSQL typically uses 6 spaces per level for -> nodes
const level = hasArrow ? Math.floor(effectiveIndent / 6) + 1 : 0
const node = createNode(operation, details, metrics, level, line)
addNodeToTree(node, effectiveIndent, root, stack)
}
return root
}
function createNode(
operation: string,
details: string | undefined,
metrics: string | undefined,
level: number,
raw: string
): ExplainNode {
const node: ExplainNode = {
operation: operation.trim(),
details: details?.trim() || '',
level,
children: [],
raw,
}
if (metrics) {
// Parse cost=start..end
const costMatch = metrics.match(/cost=([\d.]+)\.\.([\d.]+)/)
if (costMatch) {
const start = parseFloatMetric(costMatch[1])
const end = parseFloatMetric(costMatch[2])
// Only set cost if both values are valid numbers
if (start !== undefined && end !== undefined) {
node.cost = { start, end }
}
}
// Parse rows=N (estimated rows, always the first occurrence)
const rowsMatch = metrics.match(/rows=(\d+)/)
if (rowsMatch) {
node.rows = parseIntMetric(rowsMatch[1])
}
// Parse width=N
const widthMatch = metrics.match(/width=(\d+)/)
if (widthMatch) {
node.width = parseIntMetric(widthMatch[1])
}
// Parse actual time=start..end
const actualTimeMatch = metrics.match(/actual time=([\d.]+)\.\.([\d.]+)/)
if (actualTimeMatch) {
const start = parseFloatMetric(actualTimeMatch[1])
const end = parseFloatMetric(actualTimeMatch[2])
// Only set actualTime if both values are valid numbers
if (start !== undefined && end !== undefined) {
node.actualTime = { start, end }
}
// When EXPLAIN ANALYZE is used, the second rows= value (after actual time) is the actual rows
const actualTimePart = metrics.substring(metrics.indexOf('actual time='))
const actualRowsMatch = actualTimePart.match(/rows=(\d+)/)
if (actualRowsMatch) {
node.actualRows = parseIntMetric(actualRowsMatch[1])
}
}
}
return node
}
// After node creation, parse detail fields like "Rows Removed by Filter"
export function parseNodeDetails(node: ExplainNode): void {
if (node.details) {
const rowsRemovedMatch = node.details.match(/Rows Removed by Filter:\s*(\d+)/)
if (rowsRemovedMatch) {
node.rowsRemovedByFilter = parseIntMetric(rowsRemovedMatch[1])
}
}
node.children.forEach(parseNodeDetails)
}
function addNodeToTree(
node: ExplainNode,
indent: number,
root: ExplainNode[],
stack: { node: ExplainNode; indent: number }[]
) {
// Remove nodes from stack that are at the same or deeper indentation
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
stack.pop()
}
if (stack.length === 0) {
root.push(node)
} else {
stack[stack.length - 1].node.children.push(node)
}
stack.push({ node, indent })
}
// Calculate max cost for scaling the visualization bars
function getNodeMax(node: ExplainNode): number {
const nodeCost = node.cost?.end || node.actualTime?.end || 0
const childrenMax = node.children.reduce((max, child) => Math.max(max, getNodeMax(child)), 0)
return Math.max(nodeCost, childrenMax)
}
export function calculateMaxCost(tree: ExplainNode[]): number {
return tree.reduce((max, node) => Math.max(max, getNodeMax(node)), 0)
}
// Calculate summary stats
export function calculateSummary(tree: ExplainNode[]): ExplainSummary {
const stats: ExplainSummary = {
totalTime: 0,
totalCost: 0,
hasSeqScan: false,
seqScanTables: [],
hasIndexScan: false,
}
const traverse = (node: ExplainNode) => {
if (node.actualTime) {
stats.totalTime = Math.max(stats.totalTime, node.actualTime.end)
}
if (node.cost) {
stats.totalCost = Math.max(stats.totalCost, node.cost.end)
}
const op = node.operation.toLowerCase()
if (op.includes('seq scan')) {
stats.hasSeqScan = true
const tableMatch = node.details.match(/on\s+((?:"[^"]+"|[\w]+)(?:\.(?:"[^"]+"|[\w]+))*)/)
if (tableMatch) stats.seqScanTables.push(tableMatch[1])
}
if (op.includes('index')) {
stats.hasIndexScan = true
}
node.children.forEach(traverse)
}
tree.forEach(traverse)
return stats
}
export function createNodeTree(rows: readonly QueryPlanRow[]): ExplainNode[] {
const tree = parseExplainOutput(rows)
// Parse additional details from each node
tree.forEach(parseNodeDetails)
return tree
}
@@ -0,0 +1,39 @@
import { useMemo } from 'react'
import { ExplainHeader } from './ExplainVisualizer.Header'
import { ExplainNodeRenderer } from './ExplainVisualizer.NodeRenderer'
import { calculateMaxCost, calculateSummary, createNodeTree } from './ExplainVisualizer.parser'
import type { QueryPlanRow } from './ExplainVisualizer.types'
export interface ExplainVisualizerProps {
rows: readonly QueryPlanRow[]
onShowRaw?: () => void
}
export function ExplainVisualizer({ rows, onShowRaw }: ExplainVisualizerProps) {
const parsedTree = useMemo(() => createNodeTree(rows), [rows])
const maxCost = useMemo(() => calculateMaxCost(parsedTree), [parsedTree])
const summary = useMemo(() => calculateSummary(parsedTree), [parsedTree])
if (parsedTree.length === 0) {
return (
<div className="bg-table-header-light [[data-theme*=dark]_&]:bg-table-header-dark">
<p className="m-0 border-0 px-4 py-3 font-mono text-sm text-foreground-light">
No execution plan data available
</p>
</div>
)
}
return (
<div className="bg-studio border-t h-full flex flex-col">
{onShowRaw && <ExplainHeader mode="visual" onToggleMode={onShowRaw} summary={summary} />}
{/* Tree visualization */}
<div className="p-4 mb-10 overflow-auto flex-1">
{parsedTree.map((node, idx) => (
<ExplainNodeRenderer key={idx} node={node} depth={0} maxCost={maxCost} isRoot={true} />
))}
</div>
</div>
)
}
@@ -0,0 +1,18 @@
export interface ExplainNode {
operation: string
details: string
cost?: { start: number; end: number }
rows?: number
width?: number
actualTime?: { start: number; end: number }
actualRows?: number
level: number
children: ExplainNode[]
raw: string
// Parsed detail fields
rowsRemovedByFilter?: number
}
export interface QueryPlanRow {
'QUERY PLAN': string
}
@@ -0,0 +1,128 @@
import {
Activity,
Database,
GitMerge,
Hash,
ListFilter,
SortAsc,
Zap,
Layers,
type LucideIcon,
} from 'lucide-react'
import type { ExplainNode } from './ExplainVisualizer.types'
// Get human-readable description for an operation
export function getOperationDescription(operation: string): string {
const op = operation.toLowerCase()
if (op.includes('seq scan')) {
return 'Reads entire table row by row'
}
if (op.includes('index only scan')) {
return 'Reads data directly from index (fastest)'
}
if (op.includes('bitmap index scan')) {
return 'Builds bitmap of matching rows from index'
}
if (op.includes('bitmap heap scan')) {
return 'Fetches rows using bitmap'
}
if (op.includes('index scan')) {
return 'Uses index to find matching rows'
}
if (op.includes('hash left join')) {
return 'Returns all left rows with matching right rows via hash'
}
if (op.includes('hash right join')) {
return 'Returns all right rows with matching left rows via hash'
}
if (op.includes('hash full join')) {
return 'Returns all rows from both tables via hash'
}
if (op.includes('hash anti join')) {
return 'Returns rows without matches via hash'
}
if (op.includes('hash semi join')) {
return 'Returns rows with at least one match via hash'
}
if (op.includes('hash join')) {
return 'Joins tables using hash lookup'
}
if (op.includes('merge left join')) {
return 'Returns all left rows with matching right rows via merge'
}
if (op.includes('merge right join')) {
return 'Returns all right rows with matching left rows via merge'
}
if (op.includes('merge full join')) {
return 'Returns all rows from both tables via merge'
}
if (op.includes('merge anti join')) {
return 'Returns rows without matches via merge'
}
if (op.includes('merge semi join')) {
return 'Returns rows with at least one match via merge'
}
if (op.includes('merge join')) {
return 'Joins pre-sorted tables'
}
if (op.includes('nested loop left join')) {
return 'Returns all left rows with matching right rows via loop'
}
if (op.includes('nested loop anti join')) {
return 'Returns rows without matches via loop'
}
if (op.includes('nested loop semi join')) {
return 'Returns rows with at least one match via loop'
}
if (op.includes('nested loop')) {
return 'Joins by looping through each row'
}
if (op === 'hash') {
return 'Builds hash table for fast lookups'
}
if (op.includes('sort')) {
return 'Sorts rows for output or join'
}
if (op.includes('aggregate') || op.includes('group')) {
return 'Groups rows and calculates aggregates'
}
if (op.includes('limit')) {
return 'Returns only first N rows'
}
if (op.includes('materialize')) {
return 'Stores results in memory for reuse'
}
if (op.includes('gather')) {
return 'Collects results from parallel workers'
}
return ''
}
// Get an icon for the operation type
export function getOperationIcon(operation: string): LucideIcon {
const op = operation.toLowerCase()
if (op === 'hash') return Hash
if (op.includes('hash join')) return GitMerge
if (op.includes('merge join')) return GitMerge
if (op.includes('nested loop')) return GitMerge
if (op.includes('join')) return Layers
if (op.includes('index')) return Zap
if (op.includes('seq scan')) return Database
if (op.includes('scan')) return Database
if (op.includes('filter')) return ListFilter
if (op.includes('sort')) return SortAsc
if (op.includes('aggregate') || op.includes('group')) return Activity
return Database
}
// Get a color class for the operation type
export function getOperationColor(operation: string): string {
const op = operation.toLowerCase()
if (op.includes('seq scan')) return 'text-warning'
if (op.includes('index')) return 'text-brand'
if (op.includes('join')) return 'text-foreground-light'
if (op.includes('sort') || op.includes('aggregate')) return 'text-foreground-light'
return 'text-foreground-light'
}
@@ -10,7 +10,6 @@ import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import AlertError from 'components/ui/AlertError'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
@@ -84,7 +83,7 @@ export const EdgeFunctionDetails = () => {
'*'
)
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery(
{
projectRef,
@@ -1,11 +1,11 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Loader2, Plus, Send, X } from 'lucide-react'
import { useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import * as z from 'zod'
import { useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query'
@@ -13,6 +13,7 @@ import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-co
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useEdgeFunctionTestMutation } from 'data/edge-functions/edge-function-test-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import { prettifyJSON } from 'lib/helpers'
@@ -88,7 +89,7 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester
const [response, setResponse] = useState<ResponseData | null>(null)
const [error, setError] = useState<string | null>(null)
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
@@ -1,3 +1,4 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react'
import { useRouter } from 'next/router'
import { ComponentPropsWithoutRef, ElementRef, forwardRef, useState } from 'react'
@@ -9,6 +10,7 @@ import { useAccessTokensQuery } from 'data/access-tokens/access-tokens-query'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { DOCS_URL } from 'lib/constants'
import {
Button,
@@ -16,7 +18,6 @@ import {
CollapsibleTrigger_Shadcn_,
Collapsible_Shadcn_,
} from 'ui'
import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility'
import type { Commands } from './Functions.types'
interface TerminalInstructionsProps extends ComponentPropsWithoutRef<typeof Collapsible_Shadcn_> {
@@ -33,7 +34,7 @@ export const TerminalInstructions = forwardRef<
const [showInstructions, setShowInstructions] = useState(!closable)
const { data: tokens } = useAccessTokensQuery()
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
@@ -1,14 +1,15 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
import { AlertCircle, Loader } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useParams } from 'common'
import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import Panel from 'components/ui/Panel'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { Input, SimpleCodeBlock } from 'ui'
@@ -55,7 +56,7 @@ export const APIKeys = () => {
isPending: isProjectSettingsLoading,
} = useProjectSettingsV2Query({ projectRef })
const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { anonKey, serviceKey } = getKeys(apiKeys)
@@ -7,7 +7,6 @@ import { BASE_PATH } from 'lib/constants'
import {
Button,
Card,
cn,
Skeleton,
Table,
TableBody,
@@ -36,62 +35,6 @@ export const Header = () => {
)
}
export const NoFilterResults = ({
filterStatus,
resetFilterStatus,
className,
}: {
filterStatus: string[]
resetFilterStatus?: () => void
className?: string
}) => {
return (
<div
className={cn(
'bg-surface-100 px-4 md:px-6 py-4 rounded flex items-center justify-between border border-default',
className
)}
>
<div className="space-y-1">
{/* [Joshen] Just keeping it simple for now unless we decide to extend this to other statuses */}
<p className="text-sm text-foreground">
{filterStatus.length === 0
? `No projects found`
: `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`}
</p>
<p className="text-sm text-foreground-light">
Your search for projects with the specified status did not return any results
</p>
</div>
{resetFilterStatus !== undefined && (
<Button type="default" onClick={() => resetFilterStatus()}>
Reset filter
</Button>
)}
</div>
)
}
export const LoadingTableRow = () => (
<TableRow>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-32"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-16"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-24"></Skeleton>
</TableCell>
</TableRow>
)
export const LoadingTableView = () => {
return (
<Card>
@@ -107,7 +50,23 @@ export const LoadingTableView = () => {
</TableHeader>
<TableBody>
{[...Array(3)].map((_, i) => (
<LoadingTableRow key={i} />
<TableRow key={i}>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-32"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-16"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-24"></Skeleton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
@@ -142,7 +101,7 @@ export const NoProjectsState = ({ slug }: { slug: string }) => {
)
}
export const NoOrganizationsState = ({}) => {
export const NoOrganizationsState = () => {
return (
<EmptyStatePresentational
title="Create an organization"
@@ -0,0 +1,60 @@
import { useIntersectionObserver } from '@uidotdev/usehooks'
import { useEffect } from 'react'
import { cn, Skeleton, TableCell, TableRow } from 'ui'
import { ShimmeringCard } from './ShimmeringCard'
interface LoadMoreRowProps {
type?: 'card' | 'table'
isFetchingNextPage: boolean
fetchNextPage: () => void
}
export const LoadMoreRows = ({ type, isFetchingNextPage, fetchNextPage }: LoadMoreRowProps) => {
const [sentinelRef, entry] = useIntersectionObserver({
threshold: 0,
rootMargin: '200px 0px 200px 0px',
})
useEffect(() => {
if (entry?.isIntersecting && !isFetchingNextPage) {
fetchNextPage?.()
}
}, [entry?.isIntersecting, isFetchingNextPage, fetchNextPage])
if (type === 'card') {
return (
<ul
ref={sentinelRef}
className={cn(
'grid grid-cols-1 gap-2 md:gap-4',
'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 pb-6'
)}
>
{[...Array(2)].map((_, i) => (
<ShimmeringCard key={i} />
))}
</ul>
)
}
return (
<TableRow ref={sentinelRef}>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-32"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-16"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-20"></Skeleton>
</TableCell>
<TableCell>
<Skeleton className="bg-surface-400 h-4 w-24"></Skeleton>
</TableCell>
</TableRow>
)
}
@@ -53,9 +53,13 @@ export const ProjectCard = ({
linkHref={rewriteHref ? rewriteHref : `/project/${projectRef}`}
className="h-44 !px-0 group pt-5 pb-0"
title={
<div className="w-full justify-between space-y-1.5 px-5">
<p className="flex-shrink truncate text-sm pr-4">{name}</p>
<span className="text-sm text-foreground-light">{desc}</span>
<div className="w-full flex flex-col gap-y-4 justify-between px-5">
{/* Text */}
<div className="flex flex-col gap-y-0.5">
<h5 className="text-sm flex-shrink truncate pr-5">{name}</h5>
<p className="text-sm text-foreground-lighter">{desc}</p>
</div>
{/* Compute and integrations */}
<div className="flex items-center gap-x-1.5">
{project.status !== 'INACTIVE' && projectHomepageShowInstanceSize && (
<ComputeBadgeWrapper
@@ -66,7 +70,7 @@ export const ProjectCard = ({
/>
)}
{isVercelIntegrated && (
<div className="w-fit p-1 border rounded-md flex items-center text-black dark:text-white">
<div className="bg-surface-100 w-5 h-5 p-1 border border-strong rounded-md flex items-center justify-center text-black dark:text-white">
<InlineSVG
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
title="Vercel Icon"
@@ -75,12 +79,12 @@ export const ProjectCard = ({
</div>
)}
{isGithubIntegrated && (
<>
<div className="w-fit p-1 border rounded-md flex items-center">
<div className="bg-surface-100 flex items-center gap-x-0.5 h-5 pr-1 border border-strong rounded-md">
<div className="w-5 h-5 p-1 flex items-center justify-center">
<Github size={12} strokeWidth={1.5} />
</div>
<p className="text-xs !ml-2 text-foreground-light truncate">{githubRepository}</p>
</>
<p className="text-xs text-foreground-light truncate">{githubRepository}</p>
</div>
)}
</div>
</div>
@@ -3,21 +3,13 @@ import { AlertTriangle, Info, PauseCircle, RefreshCcw } from 'lucide-react'
import { RESOURCE_WARNING_MESSAGES } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants'
import { getWarningContent } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils'
import type { ResourceWarning } from 'data/usage/resource-warnings-query'
import {
Alert_Shadcn_,
AlertTitle_Shadcn_,
Badge,
cn,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { InferredProjectStatus } from './ProjectCard.utils'
export interface ProjectCardWarningsProps {
resourceWarnings?: ResourceWarning
projectStatus: InferredProjectStatus
renderMode?: 'alert' | 'badge' // New prop to control rendering mode
renderMode?: 'alert' | 'badge'
}
export const ProjectCardStatus = ({
@@ -130,12 +122,20 @@ export const ProjectCardStatus = ({
projectStatus === 'isHealthy'
) {
if (renderMode === 'badge') {
return <Badge variant="success">Active</Badge>
return (
// Badge must be wrapped in a div in order to be centered in table cell
<div className="flex items-center">
<Badge variant="success">Active</Badge>
</div>
)
}
return null
}
if (renderMode === 'badge') {
// Render a fallback en dash if no title is available
if (!alertTitle) return <span className="text-xs text-foreground-muted">–</span>
const badgeVariant = isCritical
? 'destructive'
: activeWarnings.length > 0 ||
@@ -146,50 +146,60 @@ export const ProjectCardStatus = ({
? 'success'
: 'default'
return alertDescription ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge className="rounded-md" variant={badgeVariant}>
{alertTitle}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom">{alertDescription}</TooltipContent>
</Tooltip>
) : (
<Badge className="rounded-md" variant={badgeVariant}>
{alertTitle}
</Badge>
return (
// Badge must be wrapped in a div in order to be centered in table cell
<div className="flex items-center">
{alertDescription ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={badgeVariant}>{alertTitle}</Badge>
</TooltipTrigger>
<TooltipContent side="bottom">{alertDescription}</TooltipContent>
</Tooltip>
) : (
<Badge variant={badgeVariant}>{alertTitle}</Badge>
)}
</div>
)
}
// Only render if an alert title is available
if (!alertTitle) return null
return (
<Alert_Shadcn_
variant={alertType}
className={cn(
'border-0 p-5 pb-[1.25rem]',
'bg-transparent',
'[&>svg]:left-[1.25rem] [&>svg]:top-3.5 [&>svg]:border',
!isCritical ? '[&>svg]:text-foreground [&>svg]:bg-surface-100' : ''
)}
>
{['isPaused', 'isPausing'].includes(projectStatus ?? '') ? (
<PauseCircle strokeWidth={1.5} size={12} />
) : ['isRestoring', 'isComingUp', 'isRestarting', 'isResizing'].includes(
projectStatus ?? ''
) ? (
<RefreshCcw strokeWidth={1.5} size={12} />
) : (
<AlertTriangle strokeWidth={1.5} size={12} />
)}
<div className="flex justify-between items-center w-full gap-x-1">
<AlertTitle_Shadcn_ className="text-xs mb-0">{alertTitle}</AlertTitle_Shadcn_>
<div role="alert" className={cn('w-full p-5 pb-[1.25rem] flex flex-row gap-x-2 items-center')}>
{/* Icon */}
<div
className={cn(
'shrink-0 w-6 h-6 border rounded-md flex items-center justify-center',
alertType === 'destructive' && 'border-destructive-400 [&>svg]:text-destructive-600',
alertType === 'warning' && 'border-warning-400 [&>svg]:text-warning-600',
alertType === 'default' && 'border-strong [&>svg]:text-foreground'
)}
>
{['isPaused', 'isPausing'].includes(projectStatus ?? '') ? (
<PauseCircle strokeWidth={1.5} size={14} />
) : ['isRestoring', 'isComingUp', 'isRestarting', 'isResizing'].includes(
projectStatus ?? ''
) ? (
<RefreshCcw strokeWidth={1.5} size={14} />
) : (
<AlertTriangle strokeWidth={1.5} size={14} />
)}
</div>
{/* Text and tooltip icon */}
<div className="flex items-center w-full gap-x-2">
<p className="text-xs">{alertTitle}</p>
<Tooltip>
<TooltipTrigger>
<Info size={14} className="text-foreground-light hover:text-foreground" />
<Info
size={12}
className="text-foreground-lighter hover:text-foreground transition-colors"
/>
</TooltipTrigger>
<TooltipContent side="bottom">{alertDescription}</TooltipContent>
</Tooltip>
</div>
</Alert_Shadcn_>
</div>
)
}
@@ -1,4 +1,4 @@
import { UIEvent, useMemo } from 'react'
import { useMemo } from 'react'
import { keepPreviousData } from '@tanstack/react-query'
import { useDebounce } from '@uidotdev/usehooks'
@@ -13,30 +13,13 @@ import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import { isAtBottom } from 'lib/helpers'
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'
import type { Organization } from 'types'
import {
Card,
cn,
LoadingLine,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import {
LoadingCardView,
LoadingTableRow,
LoadingTableView,
NoFilterResults,
NoProjectsState,
} from './EmptyStates'
import { Card, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates'
import { LoadMoreRows } from './LoadMoreRow'
import { ProjectCard } from './ProjectCard'
import { ProjectTableRow } from './ProjectTableRow'
import { ShimmeringCard } from './ShimmeringCard'
export interface ProjectListProps {
organization?: Organization
@@ -65,7 +48,6 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec
isLoading: isLoadingProjects,
isSuccess: isSuccessProjects,
isError: isErrorProjects,
isFetching,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
@@ -106,6 +88,8 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec
const noResultsFromStatusFilter =
filterStatus.length > 0 && isSuccessProjects && orgProjects.length === 0
const noResults = noResultsFromStatusFilter || noResultsFromSearch
const githubConnections = connections?.map((connection) => ({
id: String(connection.id),
added_by: {
@@ -126,11 +110,6 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec
?.filter((integration) => integration.integration.name === 'Vercel')
.flatMap((integration) => integration.connections)
const handleScroll = (event: UIEvent<HTMLDivElement | HTMLUListElement>) => {
if (isLoadingProjects || isFetchingNextPage || !isAtBottom(event)) return
fetchNextPage()
}
if (isErrorPermissions) {
return (
<AlertError
@@ -159,38 +138,38 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec
if (viewMode === 'table') {
return (
<Card className="flex-1 min-h-0 overflow-y-auto mb-8" onScroll={handleScroll}>
<Card className="flex-1 min-h-0 overflow-y-auto mb-8">
<Table>
{/* [Joshen] Ideally we can figure out sticky table headers here */}
<TableHeader className="[&>tr>th]:sticky [&>tr>th]:top-0">
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Status</TableHead>
<TableHead>Compute</TableHead>
<TableHead>Region</TableHead>
<TableHead>Created</TableHead>
</TableRow>
<TableRow className="!border-b-0">
<TableCell colSpan={5} className="p-0">
<LoadingLine loading={isFetching} />
</TableCell>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Project</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Status</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Compute</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Region</TableHead>
<TableHead className={cn(noResults && 'text-foreground-muted')}>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{noResultsFromStatusFilter ? (
<TableRow>
<TableCell colSpan={5} className="p-0">
<NoFilterResults
filterStatus={filterStatus}
resetFilterStatus={() => setFilterStatus([])}
className="border-0"
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={5}>
<NoSearchResults
withinTableCell
label={
filterStatus.length === 0
? `No projects found`
: `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`
}
description="Your search for projects with the specified status did not return any results"
onResetFilter={() => setFilterStatus([])}
/>
</TableCell>
</TableRow>
) : noResultsFromSearch ? (
<TableRow>
<TableCell colSpan={5} className="p-0">
<NoSearchResults searchString={search} className="border-0" />
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={5}>
<NoSearchResults searchString={search} withinTableCell />
</TableCell>
</TableRow>
) : (
@@ -212,7 +191,13 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec
)}
/>
))}
{hasNextPage && <LoadingTableRow />}
{hasNextPage && (
<LoadMoreRows
type="table"
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
)}
</>
)}
</TableBody>
@@ -224,40 +209,52 @@ export const ProjectList = ({ organization: organization_, rewriteHref }: Projec
return (
<>
{noResultsFromStatusFilter ? (
<NoFilterResults
filterStatus={filterStatus}
resetFilterStatus={() => setFilterStatus([])}
<NoSearchResults
label={
filterStatus.length === 0
? `No projects found`
: `No ${filterStatus[0] === 'INACTIVE' ? 'paused' : 'active'} projects found`
}
description="Your search for projects with the specified status did not return any results"
onResetFilter={() => setFilterStatus([])}
/>
) : noResultsFromSearch ? (
<NoSearchResults searchString={search} />
) : (
<ul
onScroll={handleScroll}
className={cn(
'min-h-0 w-full mx-auto overflow-y-auto',
'grid grid-cols-1 gap-2 md:gap-4',
'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 pb-6'
)}
>
{sortedProjects?.map((project) => (
<ProjectCard
key={project.ref}
slug={slug}
project={project}
rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
resourceWarnings={resourceWarnings?.find(
(resourceWarning) => resourceWarning.project === project.ref
)}
githubIntegration={githubConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
vercelIntegration={vercelConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
<div className="flex flex-col gap-y-2 md:gap-y-4 pb-6">
<ul
className={cn(
'min-h-0 w-full mx-auto',
'grid grid-cols-1 gap-2 md:gap-4',
'sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3'
)}
>
{sortedProjects?.map((project) => (
<ProjectCard
key={project.ref}
slug={slug}
project={project}
rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
resourceWarnings={resourceWarnings?.find(
(resourceWarning) => resourceWarning.project === project.ref
)}
githubIntegration={githubConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
vercelIntegration={vercelConnections?.find(
(connection) => connection.supabase_project_ref === project.ref
)}
/>
))}
</ul>
{hasNextPage && (
<LoadMoreRows
type="card"
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
/>
))}
{hasNextPage && [...Array(2)].map((_, i) => <ShimmeringCard key={i} />)}
</ul>
)}
</div>
)}
</>
)
@@ -53,15 +53,17 @@ export const ProjectTableRow = ({
}}
>
<TableCell>
<div className="flex flex-col gap-y-1">
<div className="flex flex-col gap-y-2">
{/* Text */}
<div>
<p className="font-medium">{name}</p>
<p className="text-xs text-foreground-lighter">ID: {projectRef}</p>
<h5 className="text-sm">{name}</h5>
<p className="text-sm text-foreground-lighter">ID: {projectRef}</p>
</div>
{/* Integrations */}
{(isGithubIntegrated || isVercelIntegrated) && (
<div className="flex items-center gap-x-2">
<div className="flex items-center gap-x-1.5">
{isVercelIntegrated && (
<div className="w-fit p-1 border rounded-md flex items-center text-black dark:text-white">
<div className="bg-surface-100 w-5 h-5 p-1 border border-strong rounded-md flex items-center text-black dark:text-white">
<InlineSVG
src={`${BASE_PATH}/img/icons/vercel-icon.svg`}
title="Vercel Icon"
@@ -70,16 +72,14 @@ export const ProjectTableRow = ({
</div>
)}
{isGithubIntegrated && (
<>
<div className="w-fit p-1 border rounded-md flex items-center">
<div className="bg-surface-100 flex items-center gap-x-0.5 h-5 pr-1 border border-strong rounded-md">
<div className="w-5 h-5 p-1 flex items-center">
<Github size={12} strokeWidth={1.5} />
</div>
{githubRepository && (
<span className="text-xs text-foreground-light truncate max-w-64">
{githubRepository}
</span>
<p className="text-xs text-foreground-light truncate">{githubRepository}</p>
)}
</>
</div>
)}
</div>
)}
@@ -102,7 +102,7 @@ export const ProjectTableRow = ({
computeSize={getComputeSize(project)}
/>
) : (
<span className="text-xs text-foreground-light">-</span>
<span className="text-xs text-foreground-muted">–</span>
)}
</div>
</TableCell>
@@ -1,3 +1,4 @@
import { keepPreviousData } from '@tanstack/react-query'
import { useDebounce } from '@uidotdev/usehooks'
import { Filter, Grid, List, Loader2, Plus, Search, X } from 'lucide-react'
import Link from 'next/link'
@@ -19,7 +20,6 @@ import {
ToggleGroupItem,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { keepPreviousData } from '@tanstack/react-query'
interface HomePageActionsProps {
slug?: string
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff. Show more