Merge branch 'master' into chore/ai-persistence
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||