mirror of
https://github.com/supabase/supabase.git
synced 2026-10-14 05:39:11 +03:00
Merge remote-tracking branch 'origin/master' into alaister/tanstack-start
Resolve conflicts: - apps/studio/package.json: drop @supabase/postgres-meta (replaced by @supabase/pg-meta on master) while keeping tanstack/tailwind v4 dev deps - pnpm-lock.yaml: take master's and reconcile via pnpm install Bump tanstack deps to latest available (post supply-chain unpublish): - @tanstack/react-devtools ^0.10.2 -> ^0.10.3 - @tanstack/react-hotkeys ^0.9.1 -> ^0.10.0 - @tanstack/react-query / -devtools ^5.0.0 -> ^5.100.10 (studio/docs/ui-library)
This commit is contained in:
commit
c7de8411cf
426 files changed
+15792
-7311
No files matched your search
@@ -192,13 +192,13 @@ export const Index: Record<string, any> = {
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
},
|
||||
"alert-collapsible-demo": {
|
||||
name: "alert-collapsible-demo",
|
||||
"collapsible-alert-demo": {
|
||||
name: "collapsible-alert-demo",
|
||||
type: "components:example",
|
||||
registryDependencies: ["alert","collapsible","button"],
|
||||
component: React.lazy(() => import("@/registry/default/example/alert-collapsible-demo")),
|
||||
registryDependencies: ["collapsible-alert"],
|
||||
component: React.lazy(() => import("@/registry/default/example/collapsible-alert-demo")),
|
||||
source: "",
|
||||
files: ["registry/default/example/alert-collapsible-demo.tsx"],
|
||||
files: ["registry/default/example/collapsible-alert-demo.tsx"],
|
||||
category: "undefined",
|
||||
subcategory: "undefined",
|
||||
chunks: []
|
||||
|
||||
@@ -56,11 +56,11 @@ const ColorPalette = () => {
|
||||
<div key={slug} className={`grid items-center gap-1 ${GRID_COLS}`}>
|
||||
<div className="pr-2 text-sm font-medium text-foreground">{name}</div>
|
||||
{SCALE_STEPS.map((step) => {
|
||||
const reference = `var(--colors-${slug}${step})`
|
||||
const reference = `var(--color-${slug}-${step * 100})`
|
||||
const isCopied = copied === reference
|
||||
return (
|
||||
<button
|
||||
key={step}
|
||||
key={step * 100}
|
||||
type="button"
|
||||
onClick={() => handleCopy(reference)}
|
||||
className="group relative flex aspect-square w-full items-center justify-center rounded-sm border border-overlay/40 transition hover:scale-[1.05] focus:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
|
||||
@@ -68,7 +68,7 @@ const ColorPalette = () => {
|
||||
title={reference}
|
||||
>
|
||||
<span className="rounded-xs bg-surface-100/90 px-1 font-mono text-[10px] text-foreground-light opacity-0 transition group-hover:opacity-100">
|
||||
{isCopied ? 'Copied!' : step}
|
||||
{isCopied ? 'Copied!' : step * 100}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -117,6 +117,11 @@ export const docsConfig: DocsConfig = {
|
||||
href: '/docs/fragments/admonition',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Collapsible Alert',
|
||||
href: '/docs/fragments/collapsible-alert',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Assistant Chat',
|
||||
href: '/docs/fragments/assistant-chat',
|
||||
|
||||
@@ -75,4 +75,6 @@ These can also be accessed with `foreground`. Like `text-foreground-light`.
|
||||
Every Radix scale exposed via `--colors-{name}{1..12}`. Click a swatch to copy the CSS variable reference. The colors are taken
|
||||
from `@radix-ui/colors` v0.1.9 except the `Brand` and `Scale` colors.
|
||||
|
||||
The `Brand` colors issue of not being uniform across the whole scale is known, they need to be refactored. The `brand-200` variable in the dark themes is also bad.
|
||||
|
||||
<ColorPalette />
|
||||
@@ -1,29 +1,24 @@
|
||||
---
|
||||
title: Alert
|
||||
description: Displays a callout for user attention.
|
||||
description: Displays a low-level callout primitive for user attention.
|
||||
component: true
|
||||
source:
|
||||
radix: true
|
||||
shadcn: true
|
||||
---
|
||||
|
||||
Alert forms the core primitives for [Admonition](../fragments/admonition). Use the Admonition component unless you must deviate from our consistent patterns. Examples where Alert might make sense include:
|
||||
Alert is the low-level shadcn primitive that forms the foundation for [Admonition](../fragments/admonition). Use Admonition for product callouts unless you need custom composition that the Admonition API does not support.
|
||||
|
||||
- Bespoke icon requirement
|
||||
- Highly custom internal contents
|
||||
Examples where Alert may be appropriate include bespoke icons, custom internal layouts, or wrapper components that need direct access to `alertVariants`.
|
||||
|
||||
Use [Collapsible Alert](../fragments/collapsible-alert) when a callout also needs expandable detail. Use [Collapsible](./collapsible) only for generic disclosure behaviour that is not itself an alert.
|
||||
|
||||
<ComponentPreview name="alert-demo" peekCode wide />
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npx shadcn-ui@latest add alert
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Alert, AlertDescription, AlertTitle } from 'ui/src/components/shadcn/ui/alert'
|
||||
```
|
||||
|
||||
```tsx
|
||||
@@ -35,3 +30,11 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
```
|
||||
|
||||
### Variants
|
||||
|
||||
Use `alertVariants` when building shared wrappers around the primitive.
|
||||
|
||||
```tsx
|
||||
import { alertVariants } from 'ui/src/components/shadcn/ui/alert'
|
||||
```
|
||||
@@ -1,37 +1,51 @@
|
||||
---
|
||||
title: Alert Collapsible
|
||||
description: An alert-styled block with a collapsible content section, toggled by an icon button.
|
||||
title: Collapsible
|
||||
description: An expandable content region using the shadcn Collapsible primitive.
|
||||
component: true
|
||||
---
|
||||
|
||||
<ComponentPreview name="alert-collapsible-demo" peekCode wide />
|
||||
Collapsible is a low-level primitive for revealing and hiding related content. Use it when a component needs custom trigger or content composition.
|
||||
|
||||
Use Collapsible for generic disclosure behaviour: rows, sections, menus, tool details, and custom layouts. Use [Collapsible Alert](../fragments/collapsible-alert) instead when the disclosure is an alert-styled callout that should keep standard alert padding, variants, trigger styling, and toggle treatment.
|
||||
|
||||
<ComponentPreview name="collapsible-demo" peekCode wide />
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { AlertCollapsible } from 'ui'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from 'ui/src/components/shadcn/ui/collapsible'
|
||||
```
|
||||
|
||||
```tsx
|
||||
<AlertCollapsible trigger="Need help?">
|
||||
<p>Content revealed when expanded.</p>
|
||||
</AlertCollapsible>
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger>Toggle</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p>Content revealed when expanded.</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
```
|
||||
|
||||
### Default open
|
||||
|
||||
```tsx
|
||||
<AlertCollapsible trigger="Details" defaultOpen>
|
||||
<p>This section is expanded by default.</p>
|
||||
</AlertCollapsible>
|
||||
<Collapsible defaultOpen>
|
||||
<CollapsibleTrigger>Details</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<p>This section is expanded by default.</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
```
|
||||
|
||||
### Variants
|
||||
### Animation
|
||||
|
||||
Supports the same `variant` prop as `Alert`: `default`, `destructive`, and `warning`.
|
||||
Add animation classes to `CollapsibleContent` when animated open and close transitions are needed.
|
||||
|
||||
```tsx
|
||||
<AlertCollapsible trigger="Warning" variant="warning">
|
||||
<p>Something needs attention.</p>
|
||||
</AlertCollapsible>
|
||||
<CollapsibleContent className="data-closed:animate-collapsible-up data-open:animate-collapsible-down">
|
||||
<p>Content revealed when expanded.</p>
|
||||
</CollapsibleContent>
|
||||
```
|
||||
@@ -4,7 +4,7 @@ description: Displays a callout for user attention.
|
||||
fragment: true
|
||||
---
|
||||
|
||||
Admonition provides focus for situations that require a callout.
|
||||
Admonition provides the standard product callout pattern for situations that require user attention.
|
||||
|
||||
<ComponentPreview
|
||||
name="admonition-demo"
|
||||
@@ -13,7 +13,9 @@ Admonition provides focus for situations that require a callout.
|
||||
wide
|
||||
/>
|
||||
|
||||
Use Admonition instead of [Alert](../components/alert) unless you specifically need the primitives. If in doubt, stick with Admonition.
|
||||
Use Admonition instead of the low-level [Alert](../components/alert) primitive unless you specifically need custom composition. If in doubt, stick with Admonition.
|
||||
|
||||
Use [Collapsible Alert](./collapsible-alert) when the callout needs to remain visible while optional detail can be expanded. Use [Collapsible](../components/collapsible) for generic disclosure behaviour that is not itself a callout.
|
||||
|
||||
Use `title` for the heading slot when the callout needs its own heading. `title` is optional: short callouts may use `description` without a title when nearby content already provides enough context.
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: Collapsible Alert
|
||||
description: An alert-styled block with a collapsible content section, toggled by an icon button.
|
||||
fragment: true
|
||||
---
|
||||
|
||||
Collapsible Alert combines an Alert-style container with a collapsible content section. Use it when the callout itself needs to stay visible while optional detail can be expanded.
|
||||
|
||||
Use Collapsible Alert when the disclosure is itself a callout. It owns the alert styling, padding, trigger treatment, and toggle button. Use [Collapsible](../components/collapsible) instead when you only need generic reveal and hide behaviour and the surrounding layout is not an alert.
|
||||
|
||||
For non-expandable product callouts, use [Admonition](./admonition). Use the low-level [Alert](../components/alert) primitive only when a wrapper or bespoke composition needs direct control over the alert structure.
|
||||
|
||||
<ComponentPreview name="collapsible-alert-demo" peekCode wide />
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { CollapsibleAlert } from 'ui-patterns/collapsible-alert'
|
||||
```
|
||||
|
||||
```tsx
|
||||
<CollapsibleAlert trigger="Need help?">
|
||||
<p>Content revealed when expanded.</p>
|
||||
</CollapsibleAlert>
|
||||
```
|
||||
|
||||
### Default open
|
||||
|
||||
```tsx
|
||||
<CollapsibleAlert trigger="Details" defaultOpen>
|
||||
<p>This section is expanded by default.</p>
|
||||
</CollapsibleAlert>
|
||||
```
|
||||
|
||||
### Variants
|
||||
|
||||
Supports the same `variant` prop as Alert: `default`, `destructive`, and `warning`.
|
||||
|
||||
```tsx
|
||||
<CollapsibleAlert trigger="Warning" variant="warning">
|
||||
<p>Something needs attention.</p>
|
||||
</CollapsibleAlert>
|
||||
```
|
||||
@@ -1,11 +0,0 @@
|
||||
import { AlertCollapsible } from 'ui'
|
||||
|
||||
export default function AlertCollapsibleDemo() {
|
||||
return (
|
||||
<AlertCollapsible trigger="Need help?">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Try a different browser or disable extensions that block network requests.
|
||||
</p>
|
||||
</AlertCollapsible>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Terminal } from 'lucide-react'
|
||||
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
|
||||
import { Alert, AlertDescription, AlertTitle } from 'ui/src/components/shadcn/ui/alert'
|
||||
|
||||
export default function AlertDemo() {
|
||||
return (
|
||||
<Alert_Shadcn_>
|
||||
<Alert>
|
||||
<Terminal size={16} />
|
||||
<AlertTitle_Shadcn_>Heads up!</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
You can also add components to your app using the CLI.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
<AlertTitle>Heads up!</AlertTitle>
|
||||
<AlertDescription>You can also add components to your app using the CLI.</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CollapsibleAlert } from 'ui-patterns/collapsible-alert'
|
||||
|
||||
export default function CollapsibleAlertDemo() {
|
||||
return (
|
||||
<CollapsibleAlert trigger="Need help?">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Try a different browser or disable extensions that block network requests.
|
||||
</p>
|
||||
</CollapsibleAlert>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronsUpDown, Plus, X } from 'lucide-react'
|
||||
import { ChevronsUpDown } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { Button } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
} from 'ui'
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from 'ui/src/components/shadcn/ui/collapsible'
|
||||
|
||||
export default function CollapsibleDemo() {
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Collapsible_Shadcn_ open={isOpen} onOpenChange={setIsOpen} className="w-[350px] space-y-2">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="w-[350px] space-y-2">
|
||||
<div className="flex items-center justify-between space-x-4 px-4">
|
||||
<h4 className="text-sm font-semibold">@peduarte starred 3 repositories</h4>
|
||||
<CollapsibleTrigger_Shadcn_ asChild>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="outline" size="medium" className="w-9 p-0">
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
<div className="rounded-md border px-4 py-3 font-mono text-sm">@radix-ui/primitives</div>
|
||||
<CollapsibleContent_Shadcn_ className="space-y-2">
|
||||
<CollapsibleContent className="space-y-2">
|
||||
<div className="rounded-md border px-4 py-3 font-mono text-sm">@radix-ui/colors</div>
|
||||
<div className="rounded-md border px-4 py-3 font-mono text-sm">@stitches/react</div>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -62,10 +62,10 @@ export const examples: Registry = [
|
||||
files: ['example/alert-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'alert-collapsible-demo',
|
||||
name: 'collapsible-alert-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['alert', 'collapsible', 'button'],
|
||||
files: ['example/alert-collapsible-demo.tsx'],
|
||||
registryDependencies: ['collapsible-alert'],
|
||||
files: ['example/collapsible-alert-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'alert-dialog-demo',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
|
||||
import {
|
||||
genGuideMeta,
|
||||
genGuidesStaticParams,
|
||||
getGuidesMarkdown,
|
||||
} from '~/features/docs/GuidesMdx.utils'
|
||||
import { getEmptyArray } from '~/features/helpers.fn'
|
||||
import { IS_DEV } from '~/lib/constants'
|
||||
|
||||
type Params = { slug?: string[] }
|
||||
|
||||
const AiToolsGuidePage = async (props: { params: Promise<Params> }) => {
|
||||
const params = await props.params
|
||||
const slug = ['ai-tools', ...(params.slug ?? [])]
|
||||
const data = await getGuidesMarkdown(slug)
|
||||
|
||||
return <GuideTemplate {...data!} />
|
||||
}
|
||||
|
||||
const generateStaticParams = !IS_DEV ? genGuidesStaticParams('ai-tools') : getEmptyArray
|
||||
const generateMetadata = genGuideMeta((params: { slug?: string[] }) =>
|
||||
getGuidesMarkdown(['ai-tools', ...(params.slug ?? [])])
|
||||
)
|
||||
|
||||
export default AiToolsGuidePage
|
||||
export { generateMetadata, generateStaticParams }
|
||||
+5
-6
@@ -1,14 +1,13 @@
|
||||
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
|
||||
import { source } from 'common-tags'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import {
|
||||
generateAiPromptMetadata,
|
||||
generateAiPromptsStaticParams,
|
||||
generateCursorPromptDeepLink,
|
||||
getAiPrompt,
|
||||
wrapInMarkdownCodeBlock,
|
||||
} from './AiPrompts.utils'
|
||||
} from '~/app/guides/getting-started/ai-prompts/[slug]/AiPrompts.utils'
|
||||
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
|
||||
import { source } from 'common-tags'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const dynamicParams = false
|
||||
|
||||
@@ -51,7 +50,7 @@ export default async function AiPromptsPage(props: { params: Promise<{ slug: str
|
||||
meta={{ title: `AI Prompt: ${heading}` }}
|
||||
content={content}
|
||||
editLink={newEditLink(`supabase/supabase/blob/master/examples/prompts/${slug}.md`)}
|
||||
pathname={`/guides/getting-started/ai-prompts/${slug}`}
|
||||
pathname={`/guides/ai-tools/ai-prompts/${slug}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Layout from '~/layouts/guides'
|
||||
|
||||
export default async function AiToolsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SectionTroubleshootingPage, {
|
||||
generateSectionTroubleshootingMetadata,
|
||||
} from '~/features/docs/TroubleshootingSection.page'
|
||||
|
||||
export default async function AiToolsTroubleshootingPage() {
|
||||
return (
|
||||
<SectionTroubleshootingPage
|
||||
topic="ai-tools"
|
||||
sectionName="AI Tools"
|
||||
description="Search or browse troubleshooting guides for common AI tools issues, including configuration, usage, and integration problems."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata = generateSectionTroubleshootingMetadata('AI', 'AI Tools')
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { GlassPanel } from 'ui-patterns'
|
||||
|
||||
import { getAiPrompts } from './AiPrompts.utils'
|
||||
@@ -12,7 +11,7 @@ export async function AiPromptsIndex() {
|
||||
{prompts.map((prompt) => (
|
||||
<Link
|
||||
key={prompt.filename}
|
||||
href={`/guides/getting-started/ai-prompts/${prompt.filename}`}
|
||||
href={`/guides/ai-tools/ai-prompts/${prompt.filename}`}
|
||||
passHref
|
||||
>
|
||||
<GlassPanel
|
||||
|
||||
+14
-8
@@ -180,10 +180,16 @@ const clientLibraries = [
|
||||
|
||||
const additionalResources = [
|
||||
{
|
||||
title: 'Management API',
|
||||
description: 'Manage your Supabase projects and organizations.',
|
||||
icon: 'reference-api',
|
||||
href: '/reference/api/introduction',
|
||||
title: 'AI tools',
|
||||
description: 'Develop with Supabase AI-first using plugins, MCP, and skills.',
|
||||
icon: 'ai-tools',
|
||||
href: '/guides/ai',
|
||||
},
|
||||
{
|
||||
title: 'Platform guides',
|
||||
description: 'Learn more about the tools and services powering Supabase.',
|
||||
icon: 'platform',
|
||||
href: '/guides/platform',
|
||||
},
|
||||
{
|
||||
title: 'Supabase CLI',
|
||||
@@ -192,10 +198,10 @@ const additionalResources = [
|
||||
href: '/reference/cli/introduction',
|
||||
},
|
||||
{
|
||||
title: 'Platform Guides',
|
||||
description: 'Learn more about the tools and services powering Supabase.',
|
||||
icon: 'platform',
|
||||
href: '/guides/platform',
|
||||
title: 'Management API',
|
||||
description: 'Manage your Supabase projects and organizations.',
|
||||
icon: 'reference-api',
|
||||
href: '/reference/api/introduction',
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
|
||||
@@ -27,7 +27,7 @@ function AiPrompt({ className }: { className?: string }) {
|
||||
'transition-colors',
|
||||
className
|
||||
)}
|
||||
href="/guides/getting-started/ai-prompts"
|
||||
href="/guides/ai-tools/ai-prompts"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Start with Supabase AI prompts
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderMermaidSVG } from 'beautiful-mermaid'
|
||||
|
||||
interface MermaidProps {
|
||||
chart: string
|
||||
}
|
||||
|
||||
export function Mermaid({ chart }: MermaidProps) {
|
||||
const svg = renderMermaidSVG(chart, {
|
||||
bg: 'hsl(var(--background-default))',
|
||||
fg: 'hsl(var(--foreground-default))',
|
||||
accent: 'hsl(var(--brand-default))',
|
||||
muted: 'hsl(var(--foreground-light))',
|
||||
line: 'hsl(var(--border-strong))',
|
||||
border: 'hsl(var(--border-strong))',
|
||||
surface: 'hsl(var(--background-surface-200))',
|
||||
transparent: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className="my-6 overflow-x-auto rounded-md border bg-surface-100 p-4 [&>svg]:mx-auto [&>svg]:h-auto [&>svg]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -58,6 +58,8 @@ const {
|
||||
'sdk:swift',
|
||||
])
|
||||
|
||||
const aiToolsEnabled = true
|
||||
|
||||
const jsOnly =
|
||||
!sdkCsharpEnabled && !sdkDartEnabled && !sdkKotlinEnabled && !sdkPythonEnabled && !sdkSwiftEnabled
|
||||
|
||||
@@ -148,6 +150,13 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
|
||||
label: 'Build',
|
||||
menuItems: [
|
||||
[
|
||||
{
|
||||
label: 'AI Tools',
|
||||
icon: 'ai-tools',
|
||||
href: '/guides/ai-tools' as `/${string}`,
|
||||
level: 'ai_tools',
|
||||
enabled: aiToolsEnabled,
|
||||
},
|
||||
{
|
||||
label: 'Local Development & CLI',
|
||||
icon: 'dev-cli',
|
||||
@@ -327,8 +336,9 @@ export const gettingstarted: NavMenuConstant = {
|
||||
title: 'Start with Supabase',
|
||||
url: '/guides/getting-started',
|
||||
items: [
|
||||
{ name: 'Features', url: '/guides/getting-started/features' },
|
||||
{ name: 'Build with AI tools', url: '/guides/ai-tools' },
|
||||
{ name: 'API Keys', url: '/guides/getting-started/api-keys' },
|
||||
{ name: 'Local Development', url: '/guides/cli/getting-started' },
|
||||
{ name: 'Architecture', url: '/guides/getting-started/architecture' },
|
||||
{
|
||||
name: 'Framework Quickstarts',
|
||||
@@ -496,32 +506,6 @@ export const gettingstarted: NavMenuConstant = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AI Tools',
|
||||
url: undefined,
|
||||
items: [
|
||||
{
|
||||
name: 'Supabase Plugin for AI Coding Agents',
|
||||
url: '/guides/getting-started/plugins' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Prompts',
|
||||
url: '/guides/getting-started/ai-prompts' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Agent Skills',
|
||||
url: '/guides/getting-started/ai-skills' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Supabase MCP server',
|
||||
url: '/guides/getting-started/mcp' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Deploy MCP servers',
|
||||
url: '/guides/getting-started/byo-mcp' as `/${string}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -2307,6 +2291,47 @@ export const ai: NavMenuConstant = {
|
||||
],
|
||||
}
|
||||
|
||||
export const ai_tools: NavMenuConstant = {
|
||||
icon: 'ai-tools',
|
||||
title: 'AI Tools',
|
||||
url: '/guides/ai-tools',
|
||||
enabled: aiToolsEnabled,
|
||||
items: [
|
||||
{ name: 'Overview', url: '/guides/ai-tools' },
|
||||
{
|
||||
name: 'Agent Plugin',
|
||||
url: '/guides/ai-tools/plugins' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'MCP server',
|
||||
url: '/guides/ai-tools/mcp' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Agent Skills',
|
||||
url: '/guides/ai-tools/ai-skills' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Prompts',
|
||||
url: '/guides/ai-tools/ai-prompts' as `/${string}`,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Build AI features',
|
||||
url: undefined,
|
||||
items: [
|
||||
{
|
||||
name: 'Deploy MCP servers',
|
||||
url: '/guides/ai-tools/byo-mcp' as `/${string}`,
|
||||
},
|
||||
{
|
||||
name: 'Vectors / Embeddings ',
|
||||
url: '/guides/ai' as `/${string}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const local_development: NavMenuConstant = {
|
||||
icon: 'dev-cli',
|
||||
title: 'Local Dev / CLI',
|
||||
@@ -3076,6 +3101,10 @@ export const integrations: NavMenuConstant = {
|
||||
name: 'Supabase for Platforms',
|
||||
url: '/guides/integrations/supabase-for-platforms',
|
||||
},
|
||||
{
|
||||
name: 'Supabase partner integration guide',
|
||||
url: '/guides/integrations/partner-integration-guide',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ name: 'Integrations', url: undefined, items: [] },
|
||||
|
||||
@@ -24,6 +24,7 @@ enum MenuId {
|
||||
Security = 'security',
|
||||
SelfHosting = 'self_hosting',
|
||||
Integrations = 'integrations',
|
||||
AiTools = 'ai_tools',
|
||||
LocalDevelopment = 'local_development',
|
||||
Contributing = 'contributing',
|
||||
RefJavaScriptV1 = 'reference_javascript_v1',
|
||||
@@ -134,6 +135,10 @@ const menus: Menu[] = [
|
||||
id: MenuId.LocalDevelopment,
|
||||
type: 'guide',
|
||||
},
|
||||
{
|
||||
id: MenuId.AiTools,
|
||||
type: 'guide',
|
||||
},
|
||||
{
|
||||
id: MenuId.Contributing,
|
||||
type: 'guide',
|
||||
|
||||
@@ -110,7 +110,7 @@ export const getMenuId = (pathname: string | null) => {
|
||||
pathname = (pathname ??= '').replace(/^\/guides\//, '')
|
||||
|
||||
switch (true) {
|
||||
case pathname.startsWith('ai'):
|
||||
case pathname.startsWith('ai') && !pathname.startsWith('ai-tools'):
|
||||
return MenuId.Ai
|
||||
case pathname.startsWith('api'):
|
||||
return MenuId.Api
|
||||
@@ -132,6 +132,8 @@ export const getMenuId = (pathname: string | null) => {
|
||||
return MenuId.Integrations
|
||||
case pathname.startsWith('local-development'):
|
||||
return MenuId.LocalDevelopment
|
||||
case pathname.startsWith('ai-tools'):
|
||||
return MenuId.AiTools
|
||||
case pathname.startsWith('telemetry'):
|
||||
return MenuId.Telemetry
|
||||
case pathname.startsWith('platform'):
|
||||
|
||||
@@ -3,7 +3,9 @@ import { ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Collapsible,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
Label_Shadcn_,
|
||||
Select_Shadcn_,
|
||||
SelectContent_Shadcn_,
|
||||
@@ -151,8 +153,8 @@ export default function RealtimeLimitsEstimater({}) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Collapsible open={expandPreview} onOpenChange={setExpandPreview}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Collapsible_Shadcn_ open={expandPreview} onOpenChange={setExpandPreview}>
|
||||
<CollapsibleTrigger_Shadcn_ asChild>
|
||||
<div className="py-1 flex items-center">
|
||||
<p className="text-sm">View raw throughput table</p>
|
||||
<Button
|
||||
@@ -168,8 +170,8 @@ export default function RealtimeLimitsEstimater({}) {
|
||||
onClick={() => setExpandPreview(!expandPreview)}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_>
|
||||
<div>
|
||||
{throughputTable
|
||||
.map((l) => l.computeAddOn)
|
||||
@@ -218,8 +220,8 @@ export default function RealtimeLimitsEstimater({}) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
id: 'ai-tools'
|
||||
title: 'AI Tools'
|
||||
subtitle: 'Plugins, MCP, and skills for AI-first Supabase development.'
|
||||
---
|
||||
|
||||
Supabase provides integrations and resources that your AI coding agents can use to build on Supabase:
|
||||
|
||||
- [Agent Plugin](/docs/guides/ai-tools/plugins): One-click setup that bundles the MCP server and agent skills into a single install for your AI coding agent.
|
||||
- [MCP Server](/docs/guides/ai-tools/mcp): Connect your AI agent to your Supabase project using the Model Context Protocol (MCP). Gives your agent tools to read your schema, run queries, manage migrations, and more - directly from your IDE.
|
||||
- [Agent Skills](/docs/guides/ai-tools/ai-skills): Curated instruction sets that give your AI agent procedural knowledge about working with Supabase.
|
||||
- [Prompts](/docs/guides/ai-tools/ai-prompts): Ready-to-use prompts for common Supabase tasks. Copy them into your project and reference them from your AI tool for consistent, accurate output.
|
||||
|
||||
## Building AI into your app?
|
||||
|
||||
The tools above are for your development workflow. If you're building AI capabilities into your own product:
|
||||
|
||||
- [Deploy MCP servers](/docs/guides/ai-tools/byo-mcp) - Host your own MCP server on Supabase Edge Functions so your users can connect their AI agents to your product
|
||||
- [Vectors / Embeddings](/docs/guides/ai) - Build semantic search, RAG pipelines, and other AI-powered features using pgvector
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+2
-2
@@ -1,9 +1,9 @@
|
||||
---
|
||||
id: 'ai-tools-mcp'
|
||||
title: 'Model context protocol (MCP)'
|
||||
title: 'Supabase MCP Server'
|
||||
subtitle: 'Connect your AI tools to Supabase using MCP'
|
||||
description: 'Connect your AI tools to Supabase using MCP'
|
||||
sidebar_label: 'Model context protocol (MCP)'
|
||||
sidebar_label: 'MCP Server'
|
||||
---
|
||||
|
||||
The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is a standard for connecting Large Language Models (LLMs) to platforms like Supabase. Once connected, your AI assistants can interact with and query your Supabase projects on your behalf.
|
||||
File renamed without changes.
@@ -39,9 +39,9 @@ supabase gen types typescript --project-id=your-project-ref --schema=storage,pub
|
||||
Find the complete code on [GitHub](https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions/huggingface-image-captioning).
|
||||
|
||||
```ts
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { HfInference } from 'https://esm.sh/@huggingface/inference@2.3.2'
|
||||
import { createClient } from 'npm:@supabase/supabase-js@2'
|
||||
|
||||
import { Database } from './types.ts'
|
||||
|
||||
console.log('Hello from `huggingface-image-captioning` function!')
|
||||
@@ -57,7 +57,7 @@ interface WebhookPayload {
|
||||
old_record: null | SoRecord
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
Deno.serve(async (req) => {
|
||||
const payload: WebhookPayload = await req.json()
|
||||
const soRecord = payload.record
|
||||
const SUPABASE_SECRET_KEYS = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS')!)
|
||||
|
||||
@@ -94,12 +94,11 @@ HUGGING_FACE_ACCESS_TOKEN=<your-token-here>
|
||||
Let's modify the Edge Function to import Hugging Face's inference client and perform a `text-to-image` request:
|
||||
|
||||
```ts
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { HfInference } from 'https://esm.sh/@huggingface/inference@2.3.2'
|
||||
|
||||
const hf = new HfInference(Deno.env.get('HUGGING_FACE_ACCESS_TOKEN'))
|
||||
|
||||
serve(async (req) => {
|
||||
Deno.serve(async (req) => {
|
||||
const { prompt } = await req.json()
|
||||
|
||||
const image = await hf.textToImage(
|
||||
|
||||
@@ -182,9 +182,7 @@ Custom Access Token Hooks are triggered for **all** token issuance. Use `client_
|
||||
A common use case is customizing the `audience` claim for different OAuth clients. This allows third-party services to validate that tokens were issued specifically for them:
|
||||
|
||||
```typescript
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
|
||||
serve(async (req) => {
|
||||
Deno.serve(async (req) => {
|
||||
const { user, claims, client_id } = await req.json()
|
||||
|
||||
// Customize audience based on OAuth client
|
||||
@@ -230,10 +228,9 @@ The `audience` claim is especially important for:
|
||||
You can also add custom claims and metadata based on the OAuth client:
|
||||
|
||||
```typescript
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
serve(async (req) => {
|
||||
Deno.serve(async (req) => {
|
||||
const { user, claims, client_id } = await req.json()
|
||||
|
||||
const supabase = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SECRET_KEY')!)
|
||||
|
||||
@@ -723,7 +723,6 @@ curl -X PATCH "https://api.supabase.com/v1/projects/$PROJECT_REF/config/auth" \
|
||||
dependencies {
|
||||
implementation("io.github.jan-tennert.supabase:gotrue-kt:VERSION")
|
||||
implementation("io.github.jan-tennert.supabase:compose-auth:VERSION")
|
||||
implementation("io.github.jan-tennert.supabase:compose-auth-ui:VERSION") // Optional, for UI components
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ GET YOUR CERT FROM YOUR PROJECT DASHBOARD
|
||||
Create a `DenoPostgresDriver.ts` file to manage the connection to Postgres via [deno-postgres](https://deno-postgres.com/):
|
||||
|
||||
```ts DenoPostgresDriver.ts
|
||||
import { Pool, PoolClient } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
|
||||
import {
|
||||
CompiledQuery,
|
||||
DatabaseConnection,
|
||||
@@ -44,7 +45,6 @@ import {
|
||||
} from 'https://esm.sh/kysely@0.23.4'
|
||||
import { freeze, isFunction } from 'https://esm.sh/kysely@0.23.4/dist/esm/util/object-utils.js'
|
||||
import { extendStackTrace } from 'https://esm.sh/kysely@0.23.4/dist/esm/util/stack-trace-utils.js'
|
||||
import { Pool, PoolClient } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
|
||||
|
||||
export interface PostgresDialectConfig {
|
||||
pool: Pool | (() => Promise<Pool>)
|
||||
@@ -190,15 +190,15 @@ class PostgresConnection implements DatabaseConnection {
|
||||
Create an `index.ts` file to execute a query on incoming requests:
|
||||
|
||||
```ts index.ts
|
||||
import { serve } from 'https://deno.land/std@0.175.0/http/server.ts'
|
||||
import { Pool } from 'https://deno.land/x/postgres@v0.17.0/mod.ts'
|
||||
import {
|
||||
Kysely,
|
||||
Generated,
|
||||
Kysely,
|
||||
PostgresAdapter,
|
||||
PostgresIntrospector,
|
||||
PostgresQueryCompiler,
|
||||
} from 'https://esm.sh/kysely@0.23.4'
|
||||
|
||||
import { PostgresDriver } from './DenoPostgresDriver.ts'
|
||||
|
||||
console.log(`Function "kysely-postgres" up and running!`)
|
||||
@@ -245,7 +245,7 @@ const db = new Kysely<Database>({
|
||||
},
|
||||
})
|
||||
|
||||
serve(async (_req) => {
|
||||
Deno.serve(async (_req) => {
|
||||
try {
|
||||
// Run a query
|
||||
const animals = await db.selectFrom('animals').select(['id', 'animal', 'created_at']).execute()
|
||||
|
||||
@@ -11,17 +11,17 @@ hideToc: true
|
||||
|
||||
<div className="grid grid-cols-12 gap-6 not-prose">
|
||||
{[
|
||||
{
|
||||
title: 'Features',
|
||||
{
|
||||
title: 'Build with AI tools',
|
||||
description: 'Develop with Supabase AI-first using plugins, MCP, and skills.',
|
||||
hasLightIcon: true,
|
||||
href: '/guides/getting-started/features',
|
||||
description: 'A non-exhaustive list of features that Supabase provides for every project.'
|
||||
},
|
||||
{
|
||||
title: 'Architecture',
|
||||
href: '/guides/ai',
|
||||
},
|
||||
{
|
||||
title: 'API Keys',
|
||||
hasLightIcon: true,
|
||||
href: '/guides/getting-started/architecture',
|
||||
description: "An overview of Supabase's architecture and product principles.",
|
||||
href: '/guides/getting-started/api-keys',
|
||||
description: 'Learn about the different API keys in Supabase and how to use them.',
|
||||
},
|
||||
{
|
||||
title: 'Local Development',
|
||||
|
||||
@@ -141,7 +141,7 @@ These variables are safe to expose in your Expo app since Supabase has
|
||||
const supabaseUrl = YOUR_REACT_NATIVE_SUPABASE_URL
|
||||
const supabasePublishableKey = YOUR_REACT_NATIVE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabasePublishableKey, {
|
||||
export const supabase = createClient(supabaseUrl, supabasePublishableKey, {
|
||||
auth: {
|
||||
storage: new LargeSecureStore(),
|
||||
autoRefreshToken: true,
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
id: 'partner-integration-guide'
|
||||
title: 'Supabase Partner Integration Guide'
|
||||
subtitle: 'Integrate Supabase with your platform or product.'
|
||||
description: 'This guide explains how a Supabase partner can integrate with the Supabase platform via OAuth.'
|
||||
---
|
||||
|
||||
This guide assumes you have already followed [the Build a Supabase Integration guide](/docs/guides/integrations/build-a-supabase-oauth-integration) and have a working OAuth client.
|
||||
|
||||
When a Supabase user clicks the **Install Integration** button in the Dashboard, they are redirected to your system to begin the OAuth flow. You can implement this redirect in one of two ways:
|
||||
|
||||
- **[Simple redirect](#method-1-simple-redirect)**: Easier to build, but your system cannot verify that the incoming user was sent by Supabase.
|
||||
- **[Signed redirect](#method-2-signed-redirect)**: More work to build, but cryptographically verifies that the redirect originated from Supabase. **Recommended for production integrations.**
|
||||
|
||||
Pick the method that fits your security requirements, then follow the matching section below.
|
||||
|
||||
## Method 1: Simple redirect
|
||||
|
||||
In this method, you implement a single `GET` endpoint. Supabase redirects the user to this endpoint when they click **Install Integration**, and your endpoint kicks off the OAuth flow.
|
||||
|
||||
<Mermaid
|
||||
chart={`sequenceDiagram
|
||||
autonumber
|
||||
participant User
|
||||
participant Browser
|
||||
participant Partner as Partner (OAuth Client)
|
||||
participant Supabase as Supabase (OAuth Authorization Server/Protected Resource)
|
||||
|
||||
Note over Browser: Supabase Dashboard Open
|
||||
|
||||
User->>Browser: Clicks "Install Integration"
|
||||
|
||||
Browser->>Partner: "Connect to Partner" Click Handler Page
|
||||
Partner->>Browser: Redirect to Supabase Authorization Page
|
||||
|
||||
Browser->>Supabase: Request Authorization
|
||||
Supabase->>Browser: Redirect to Consent Screen
|
||||
|
||||
Browser->>User: Display Consent Screen
|
||||
User->>Browser: Gives Consent
|
||||
|
||||
Browser->>Supabase: Submit User Consent
|
||||
Supabase->>Browser: Redirect to Partner With Authorization Code
|
||||
|
||||
Browser->>Partner: Submit Authorization Code
|
||||
|
||||
Partner->>Supabase: Exchange Authorization Code for Token
|
||||
Supabase->>Partner: Return Token
|
||||
|
||||
Partner->>Supabase: Request Management API Resources
|
||||
Supabase->>Partner: Return Resources
|
||||
|
||||
Partner->>Browser: Ok 200, OAuth Flow Complete
|
||||
|
||||
Browser->>User: Display "Integration Installed" Message`}
|
||||
|
||||
/>
|
||||
|
||||
### Step 1: Implement the redirect endpoint
|
||||
|
||||
Expose a `GET` endpoint at any URL you control:
|
||||
|
||||
```
|
||||
GET https://<your-host>/<optional-path>?project_id=<supabase-project_ref>&organization_slug=<supabase-org-slug>
|
||||
```
|
||||
|
||||
Supabase will append the following query parameters to the URL when redirecting:
|
||||
|
||||
| Param | Description |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `project_id` | Supabase project ref of the project where the user clicked the **Install Integration** button. |
|
||||
| `organization_slug` | Supabase organization slug where the user clicked the **Install Integration** button. |
|
||||
|
||||
Save these parameters in your system. You can use them to fetch project or organization details, or to pre-select a project or organization in your UI once the OAuth flow is complete.
|
||||
|
||||
Your endpoint may ask the user to sign up, sign in, or perform other setup tasks on your website. Once those are complete, immediately [redirect to the Supabase authorization URL](/docs/guides/integrations/build-a-supabase-oauth-integration#redirecting-to-the-authorize-url) without further user interaction. This starts the OAuth flow.
|
||||
|
||||
### Step 2: Share your endpoint with Supabase
|
||||
|
||||
Send Supabase the URL of your endpoint so we can configure the **Install Integration** button to redirect there.
|
||||
|
||||
## Method 2: Signed redirect
|
||||
|
||||
In this method, Supabase signs a JWT before redirecting the user. You verify the signature, generate a one-time redirect record, and return its URL to Supabase. Supabase then redirects the user to that URL. This guarantees that any redirect your system handles originated from Supabase.
|
||||
|
||||
You will implement two endpoints:
|
||||
|
||||
1. A `POST` endpoint that validates the signed JWT and returns a unique redirect URL.
|
||||
2. A `GET` endpoint that handles the user once they are redirected to that URL.
|
||||
|
||||
<Mermaid
|
||||
chart={`sequenceDiagram
|
||||
autonumber
|
||||
participant User
|
||||
participant Browser
|
||||
participant Partner as Partner (OAuth Client)
|
||||
participant Supabase as Supabase (OAuth Authorization Server/Protected Resource)
|
||||
|
||||
Note over Browser: Supabase Dashboard Open
|
||||
|
||||
User->>Browser: Clicks "Install Integration"
|
||||
|
||||
Browser->>Supabase: Request Redirect URL Generation
|
||||
Supabase->>Partner: Send Signed JWT
|
||||
Partner->>Partner: Validate JWT
|
||||
Partner->>Partner: Generate Unique Redirect URL
|
||||
Partner->>Supabase: Return Redirect URL
|
||||
Supabase->>Browser: Redirect to the Redirect URL
|
||||
|
||||
Browser->>Partner: Visit Redirect URL
|
||||
Partner->>Partner: Validate Redirect Record
|
||||
Partner->>Browser: Optional User Actions
|
||||
Browser->>User: User Performs Optional Actions
|
||||
User->>Browser: User Optional Actions Complete
|
||||
Browser->>Partner: User Optional Actions Complete
|
||||
Partner->>Browser: Redirect to Supabase Authorization Page
|
||||
|
||||
Browser->>Supabase: Request Authorization
|
||||
Supabase->>Browser: Redirect to Consent Screen
|
||||
|
||||
Browser->>User: Display Consent Screen
|
||||
User->>Browser: Gives Consent
|
||||
|
||||
Browser->>Supabase: Submit User Consent
|
||||
Supabase->>Browser: Redirect to Partner With Authorization Code
|
||||
|
||||
Browser->>Partner: Submit Authorization Code
|
||||
|
||||
Partner->>Supabase: Exchange Authorization Code for Token
|
||||
Supabase->>Partner: Return Token
|
||||
|
||||
Partner->>Supabase: Request Management API Resources
|
||||
Supabase->>Partner: Return Resources
|
||||
|
||||
Partner->>Browser: Ok 200, OAuth Flow Complete
|
||||
|
||||
Browser->>User: Display "Integration Installed" Message`}
|
||||
|
||||
/>
|
||||
|
||||
### Step 1: Exchange public keys with Supabase
|
||||
|
||||
Supabase generates two key-pairs, one for staging, one for production, and shares the public keys with you. Save both public keys and their key IDs in your system. The keys are PEM-encoded EC P-256.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
===============Staging==================
|
||||
|
||||
Key ID: pik_3038669348a3ea75dbaf0655
|
||||
Public Key:
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnj3NmwrLPPH/3isvpS601ndQP9Mk
|
||||
zqppdLDV9YfmoF4wavTyb9UTVE5pJ0fukpo5aOoNb4fBZgESsedIUoEn8Q==
|
||||
-----END PUBLIC KEY-----
|
||||
|
||||
==============Production================
|
||||
|
||||
Key ID: pik_89e80ddbca9df41b97e28986
|
||||
Public Key:
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWCGhwtFWn4jpWZNeyZpTlaAdq/tD
|
||||
/yBaN0gFPpS8LTFiCPFgnWKbVe3RfExXh7bEhcrEUdWycmYvwrNklWWHRA==
|
||||
-----END PUBLIC KEY-----
|
||||
```
|
||||
|
||||
Look up the correct public key by the `kid` field in the JWT header.
|
||||
|
||||
Storing keys by ID enables zero-downtime key rotation. When Supabase rotates keys, we share new key-pairs and start signing JWTs with the new key ID. Your system picks the correct key based on `kid` without any code changes.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
In future, we plan to publish keys at a `.../.well-known/jwks.json` URL and fully automate key rotation. For now this is done manually.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Step 2: Implement the redirect record endpoint
|
||||
|
||||
This endpoint receives the signed JWT from Supabase and returns a one-time redirect URL.
|
||||
|
||||
Host the endpoint at any URL and path you control. Do not require authentication on this endpoint, but apply rate limiting to prevent abuse.
|
||||
|
||||
```
|
||||
POST https://<your-api-host>/<your-api-path>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
#### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "<signed-jwt>"
|
||||
}
|
||||
```
|
||||
|
||||
#### JWT fields
|
||||
|
||||
The token is a JWT signed with the ES256 private key.
|
||||
|
||||
**Protected header (JOSE header)**
|
||||
|
||||
| Field | Required | Description |
|
||||
| ----- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `alg` | Yes | Always `ES256` — the only signing algorithm currently supported. |
|
||||
| `kid` | Yes | The key ID identifying the key pair. Use this to pick the correct public key when verifying the signature. |
|
||||
|
||||
**Payload (claims)**
|
||||
|
||||
| Claim | Required | Description |
|
||||
| ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `iss` | Yes | Always `supabase`. |
|
||||
| `aud` | Yes | A unique string identifying the audience of this JWT. Usually a URL. |
|
||||
| `iat` | Yes | Issued-at timestamp (seconds since epoch). |
|
||||
| `exp` | Yes | Expiry timestamp — at most 5 minutes after `iat`. |
|
||||
| `organization_slug` | No | The Supabase organization the user is connecting from. Use to pre-select the org during the OAuth consent screen. |
|
||||
| `project_id` | No | The Supabase project ref the user wants to connect. Use to pre-select a Supabase project in your UI. |
|
||||
|
||||
**Signature**
|
||||
|
||||
The header and claims are signed with the EC P-256 private key.
|
||||
|
||||
#### Validation
|
||||
|
||||
When you receive a request:
|
||||
|
||||
1. Read the `kid` field from the JWT header and look up the matching public key.
|
||||
2. Verify the JWT signature with that public key.
|
||||
3. Verify that `alg` is `ES256`.
|
||||
4. Verify that `iss` is `supabase`.
|
||||
5. Verify that `aud` matches the value you agreed with Supabase.
|
||||
6. Verify that the current time is between `iat` and `exp`.
|
||||
|
||||
If any check fails, return `401 Unauthorized`.
|
||||
|
||||
If validation succeeds, generate a UUID to identify this redirect record (also called an integration record), save it in your system with an expiry (typically 1 hour), and return it in the response. The expiry prevents records from accumulating and limits the window in which a leaked record can be used.
|
||||
|
||||
#### Response body
|
||||
|
||||
```json
|
||||
{
|
||||
"integrationId": "<a unique uuid>",
|
||||
"redirectUrl": "https://<your-api-host>/<your-api-path>/<integration-id>",
|
||||
"expiresAt": "<timestamp>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `integrationId` | A UUID uniquely identifying the redirect record. |
|
||||
| `redirectUrl` | The URL the user will be redirected to. Must contain the `integrationId` somewhere in its path so you can retrieve the record on redirect. |
|
||||
| `expiresAt` | The time when the redirect record expires (typically 1 hour from creation). The user must begin the flow before this time. |
|
||||
|
||||
### Step 3: Implement the redirect handler endpoint
|
||||
|
||||
This is the `GET` endpoint at the `redirectUrl` you returned in the previous step. The redirect record's UUID must be in its path.
|
||||
|
||||
When a user arrives at this endpoint:
|
||||
|
||||
1. Extract the redirect record UUID from the URL path.
|
||||
2. Look it up in your system. If it doesn't exist or has expired, return `401 Unauthorized`.
|
||||
3. Optionally, walk the user through any setup required on your side — for example, signing up, signing in, or configuring your system so the integration will work.
|
||||
4. Redirect the user to the [Supabase authorization URL](/docs/guides/integrations/build-a-supabase-oauth-integration#redirecting-to-the-authorize-url) to start the OAuth flow.
|
||||
|
||||
<Admonition type="tip">
|
||||
|
||||
If you need the user to perform setup steps on your site, design the experience as a wizard that ends by redirecting to the Supabase authorization URL. This minimizes the chance of users getting distracted, navigating elsewhere on your site, and abandoning the OAuth flow.
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Step 4: Share your details with Supabase
|
||||
|
||||
Send Supabase the following so we can configure the **Install Integration** button:
|
||||
|
||||
- The URL of your redirect record endpoint ([Step 2](#step-2-implement-the-redirect-record-endpoint)).
|
||||
- The URL pattern of your redirect handler endpoint ([Step 3](#step-3-implement-the-redirect-handler-endpoint)).
|
||||
- The `aud` claim value you want Supabase to send in the signed JWT.
|
||||
|
||||
Ask Supabase for the public keys and key IDs for the staging and production environments.
|
||||
@@ -207,6 +207,28 @@ Contains your application's Site URL. This can be configured in your project's [
|
||||
<p>Visit <a href="{{ .SiteURL }}">here</a> to log in.</p>
|
||||
```
|
||||
|
||||
### `RedirectTo`
|
||||
|
||||
Contains the redirect URL passed as the `redirectTo` option in the auth method call.
|
||||
|
||||
**Usage**
|
||||
|
||||
```html
|
||||
<a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next={{ .RedirectTo }}">
|
||||
Confirm your email
|
||||
</a>
|
||||
```
|
||||
|
||||
### `Data`
|
||||
|
||||
Contains metadata from `auth.users.user_metadata`. Use this to personalize the email message.
|
||||
|
||||
**Usage**
|
||||
|
||||
```html
|
||||
<p>Hello {{ .Data.first_name }}, please confirm your signup.</p>
|
||||
```
|
||||
|
||||
### `Email`
|
||||
|
||||
Contains the user's email address.
|
||||
|
||||
@@ -111,6 +111,21 @@ You can access them only by:
|
||||
|
||||
### Signing URLs
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
Storage signed URLs are signed with a dedicated internal key that is separate from your project's Auth JWT signing key. Each project has its own signing key.
|
||||
|
||||
Because signed URLs use this separate key, they are not affected by:
|
||||
|
||||
- Rotating or revoking Auth JWT legacy secret or signing key
|
||||
- Disabling legacy keys in the dashboard
|
||||
- Switching from HS256 to asymmetric (ES256) signing key
|
||||
|
||||
Signed URLs remain valid until their expiry time regardless of any Auth key changes.
|
||||
|
||||
If you need to revoke signed URLs, [contact Supabase support](/dashboard/support/new?projectRef=).
|
||||
|
||||
</Admonition>
|
||||
You can sign a time-limited URL that you can share to your users by invoking the `createSignedUrl` method on the SDK.
|
||||
|
||||
```js
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ The self-hosted version is pretty similar to the hosted one. It might not always
|
||||
### Feature availability
|
||||
|
||||
To know what features are available in the self-hosted version, refer to the comprehensive list here:
|
||||
[Supabase Features](/docs/guides/getting-started/features#generally-available)
|
||||
[Supabase Features](/features)
|
||||
|
||||
### Self-hosting documentation
|
||||
|
||||
|
||||
+2
@@ -19,3 +19,5 @@ If you are observing a `RateLimitError: "Rate limit exceeded for trace ..."` err
|
||||
- **Consolidate Logic:** Refactor the logic into a shared library or move it directly into the calling function to eliminate the need for cross-function network calls.
|
||||
|
||||
You can monitor these errors by visiting the [logs explorer](/dashboard/project/_/logs/explorer) to identify which executions are exceeding their trace budget.
|
||||
|
||||
For more details on how recursive invocations and per-trace rate limiting work, see the recursive functions guide: [Recursive Functions — What gets rate limited](/docs/guides/functions/recursive-functions#what-gets-rate-limited).
|
||||
+1
-1
@@ -7,4 +7,4 @@ keywords = [ "password", "reset" ]
|
||||
database_id = "6e4a515c-5e70-4b92-808c-f076d47d4e35"
|
||||
---
|
||||
|
||||
You can reset your database password from the [Database Settings page](/dashboard/project/_/database/settings) on the project dashboard.
|
||||
You can reset your database password from the [**Database > Settings**](/dashboard/project/_/database/settings) section of the Dashboard.
|
||||
+4
-4
@@ -172,7 +172,7 @@ export const config = {
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function login(formData: FormData) {
|
||||
const supabase = createClient();
|
||||
@@ -245,7 +245,7 @@ export default function LoginPage() {
|
||||
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
|
||||
// with
|
||||
import { createClient } from '@/utils/supabase/client';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
export default async function Page() {
|
||||
// replace this line
|
||||
@@ -266,7 +266,7 @@ import { cookies } from 'next/headers';
|
||||
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs';
|
||||
|
||||
// with
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export default async function Page() {
|
||||
// replace
|
||||
@@ -290,7 +290,7 @@ import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
// with
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// replace
|
||||
|
||||
@@ -26,12 +26,8 @@ If you haven’t migrated to asymmetric JWT signing keys:
|
||||
|
||||
{/* supa-mdx-lint-disable Rule004ExcludeWords */}
|
||||
|
||||
1. Go to [**Project Settings** → **JWT Keys**](/dashboard/project/_/settings/jwt) in the Supabase Dashboard
|
||||
2. Navigate to the **Legacy JWT Secret** tab
|
||||
3. Click on **Change Legacy Secret**
|
||||
- Click on **Generate a random secret** to let Supabase decide the JWT secret.
|
||||
- Click on **Create my own secret** to enter a custom JWT secret
|
||||
4. You will see a Confirmation dialog. Read it through and confirm to proceed.
|
||||
We recommend that you migrate to asymmetric JWT signing keys and publishable/secret API keys as it is no longer possible to rotate the legacy anon, service and JWT secrets.
|
||||
You can view this [**Get Started guide**](/docs/guides/auth/signing-keys#getting-started) for steps to migrate to asymmetric JWT signing keys.
|
||||
|
||||
If you have migrated to new symmetric JWT signing keys:
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ComputeDiskLimitsTable } from '~/components/ComputeDiskLimitsTable'
|
||||
import { Extensions } from '~/components/Extensions'
|
||||
import Image, { type ImageProps } from '~/components/Image'
|
||||
import { JwtGeneratorSimple } from '~/components/JwtGenerator'
|
||||
import { Mermaid } from '~/components/Mermaid'
|
||||
import { MetricsStackCards } from '~/components/MetricsStackCards'
|
||||
import { NavData } from '~/components/NavData'
|
||||
import { Price } from '~/components/Price'
|
||||
@@ -70,6 +71,7 @@ const components = {
|
||||
JwtGeneratorSimple,
|
||||
Link,
|
||||
McpConfigPanel,
|
||||
Mermaid,
|
||||
MetricsStackCards,
|
||||
NamedCodeBlock,
|
||||
NavData,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NavMenuConstant } from '~/components/Navigation/Navigation.types'
|
||||
import {
|
||||
GLOBAL_MENU_ITEMS,
|
||||
ai,
|
||||
ai_tools,
|
||||
api,
|
||||
auth,
|
||||
cron,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
deployment,
|
||||
functions,
|
||||
gettingstarted,
|
||||
GLOBAL_MENU_ITEMS,
|
||||
graphql,
|
||||
integrations,
|
||||
local_development,
|
||||
@@ -33,6 +34,7 @@ const SECTION_NAV_MAPS: Record<string, NavMenuConstant> = {
|
||||
'getting-started': gettingstarted,
|
||||
graphql,
|
||||
integrations,
|
||||
'ai-tools': ai_tools,
|
||||
'local-development': local_development,
|
||||
platform,
|
||||
queues,
|
||||
|
||||
@@ -60,6 +60,7 @@ export const TroubleshootingSchema = z
|
||||
topics: z.array(
|
||||
z.enum([
|
||||
'ai',
|
||||
'ai-tools',
|
||||
'auth',
|
||||
'branching',
|
||||
'cli',
|
||||
|
||||
@@ -56,10 +56,11 @@
|
||||
"@octokit/plugin-retry": "^8.1.0",
|
||||
"@sentry/nextjs": "catalog:",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@tanstack/react-query": "^5.13.4",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"acorn": "^8.11.3",
|
||||
"acorn-typescript": "^1.4.13",
|
||||
"ai-commands": "workspace:*",
|
||||
"beautiful-mermaid": "^1.1.3",
|
||||
"common": "workspace:*",
|
||||
"common-tags": "^1.8.2",
|
||||
"config": "workspace:*",
|
||||
|
||||
@@ -38,6 +38,8 @@ Bobbie Soedirgo
|
||||
Brad Gyger
|
||||
Brendan Stephens
|
||||
Brent Newson
|
||||
Brian Cullen
|
||||
Brent Schroeter
|
||||
Carel de Waal
|
||||
Cameron Blackwood
|
||||
Cameron Michie
|
||||
@@ -53,6 +55,7 @@ Chris Gwilliams
|
||||
Chris Martin
|
||||
Chris Stockton
|
||||
Chris Ward
|
||||
Christian Funkhouser
|
||||
Clayton Kast
|
||||
Colin Goodheart-Smithe
|
||||
Colin Murray
|
||||
@@ -123,6 +126,7 @@ Jean-Paul Argudo
|
||||
Jeff Smick
|
||||
Jenny Kibiri
|
||||
Jeremias Menichelli
|
||||
Jess Fink
|
||||
Jess Shears
|
||||
Jim Brodeur
|
||||
Jim Chanco Jr
|
||||
@@ -142,6 +146,7 @@ Jordan McQueen
|
||||
Jordi Enric
|
||||
José Luis Ledesma
|
||||
Joshen Lim
|
||||
Julian Domke
|
||||
Julien Goux
|
||||
Kai M
|
||||
Kalleby Santos
|
||||
@@ -174,6 +179,7 @@ Manuel Mendez
|
||||
Manan Gupta
|
||||
Marcus Weiner
|
||||
Margarita Sandomirskaia
|
||||
Marija Milicevic
|
||||
Mark Burggraf
|
||||
Matthew Hambright
|
||||
Matt Hudson
|
||||
@@ -206,11 +212,13 @@ Paweł Gulbinowicz
|
||||
Pedro Rodrigues
|
||||
Peter Lyn
|
||||
Peter Soderberg
|
||||
Pierre Ducroquet
|
||||
Qiao Han
|
||||
Rafael Chacón
|
||||
Raminder Singh
|
||||
Raúl Barroso
|
||||
Riccardo Busetti
|
||||
Richard Kasprzak
|
||||
Rob Shields
|
||||
Rodrigo Esteves
|
||||
Rodrigo Mansueli
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
import type { PGTable } from '@supabase/pg-meta'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { RenderCellProps } from 'react-data-grid'
|
||||
@@ -38,7 +38,7 @@ export const ForeignKeyFormatter = (props: Props) => {
|
||||
r.source_column_name === column.name
|
||||
)
|
||||
|
||||
const { data: targetTable, isPending: isLoadingTargetTable } = useTableQuery<PostgresTable>(
|
||||
const { data: targetTable, isPending: isLoadingTargetTable } = useTableQuery<PGTable>(
|
||||
{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PostgresTable } from '@supabase/postgres-meta'
|
||||
import type { PGTable } from '@supabase/pg-meta'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { Key } from 'lucide-react'
|
||||
@@ -29,7 +29,7 @@ import { useTableRowsQuery } from '@/data/table-rows/table-rows-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
interface ReferenceRecordPeekProps {
|
||||
table: PostgresTable
|
||||
table: PGTable
|
||||
column: string
|
||||
value: string | number | Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'
|
||||
import type { PostgresColumn } from '@supabase/postgres-meta'
|
||||
import type { PGColumn } from '@supabase/pg-meta'
|
||||
import { forwardRef, memo, Ref, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import DataGrid, {
|
||||
CalculatedColumn,
|
||||
@@ -137,7 +137,7 @@ export const Grid = memo(
|
||||
tableEditorSnap.onEditForeignKeyColumnValue({
|
||||
foreignKey,
|
||||
row,
|
||||
column: column as unknown as PostgresColumn,
|
||||
column: column as unknown as PGColumn,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { InputVariants } from '@ui/components/shadcn/ui/input'
|
||||
import { useParams } from 'common'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
@@ -7,10 +6,9 @@ import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { useRevealedSecret } from './useRevealedSecret'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
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'
|
||||
|
||||
export function ApiKeyPill({
|
||||
@@ -18,94 +16,70 @@ export function ApiKeyPill({
|
||||
}: {
|
||||
apiKey: Extract<APIKeysData[number], { type: 'secret' | 'publishable' }>
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
// State that controls whether to show the full API key
|
||||
const [show, setShowState] = useState(false)
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const isSecret = apiKey.type === 'secret'
|
||||
|
||||
// Permission check for revealing/copying secret API keys
|
||||
const { can: canManageSecretKeys, isLoading: isLoadingPermission } = useAsyncCheckPermissions(
|
||||
PermissionAction.READ,
|
||||
'service_api_keys'
|
||||
)
|
||||
|
||||
// This query only runs when show=true (enabled: show)
|
||||
// It fetches the fully revealed API key when needed
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isPending: isLoading,
|
||||
refetch: refetchApiKey,
|
||||
} = useAPIKeyIdQuery(
|
||||
{
|
||||
projectRef,
|
||||
id: apiKey.id as string,
|
||||
reveal: true, // Request the unmasked key
|
||||
},
|
||||
{
|
||||
enabled: show, // Only run query when show is true
|
||||
staleTime: 0, // Always consider data stale
|
||||
gcTime: 0, // Don't cache the key data
|
||||
}
|
||||
)
|
||||
data: revealedKey,
|
||||
isLoading,
|
||||
reveal,
|
||||
clear,
|
||||
} = useRevealedSecret({
|
||||
projectRef,
|
||||
id: apiKey.id as string,
|
||||
})
|
||||
|
||||
// Auto-hide timer for the API key (security feature)
|
||||
useEffect(() => {
|
||||
if (show && data?.api_key) {
|
||||
// Auto-hide the key after 10 seconds
|
||||
if (show && revealedKey) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowState(false)
|
||||
// Clear the cached key from memory
|
||||
queryClient.removeQueries({
|
||||
queryKey: apiKeysKeys.single(projectRef, apiKey.id as string),
|
||||
exact: true,
|
||||
})
|
||||
}, 10000) // Hide after 10 seconds
|
||||
setShow(false)
|
||||
clear()
|
||||
}, 10000)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [show, data?.api_key, projectRef, queryClient, apiKey.id])
|
||||
}, [show, revealedKey, clear])
|
||||
|
||||
async function onSubmitToggle() {
|
||||
// Don't reveal key if not allowed or loading
|
||||
async function onToggleShow() {
|
||||
if (isSecret && !canManageSecretKeys) return
|
||||
if (isLoadingPermission) return
|
||||
|
||||
// Toggle the show state
|
||||
setShowState(!show)
|
||||
if (show) {
|
||||
setShow(false)
|
||||
clear()
|
||||
} else {
|
||||
setShow(true)
|
||||
try {
|
||||
await reveal()
|
||||
} catch {
|
||||
toast.error('Failed to reveal secret API key')
|
||||
setShow(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopy() {
|
||||
// If key is already revealed, use that value
|
||||
if (data?.api_key) return data?.api_key ?? ''
|
||||
if (!isSecret) return apiKey.api_key
|
||||
if (revealedKey) return revealedKey
|
||||
|
||||
try {
|
||||
// Fetch full key and immediately clear from cache after copying
|
||||
const result = await refetchApiKey()
|
||||
queryClient.removeQueries({
|
||||
queryKey: apiKeysKeys.single(projectRef, apiKey.id as string),
|
||||
exact: true,
|
||||
})
|
||||
|
||||
if (result.isSuccess) return result.data.api_key ?? ''
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to copy secret API key')
|
||||
return ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch API key:', error)
|
||||
return ''
|
||||
const key = await reveal()
|
||||
clear()
|
||||
return key ?? ''
|
||||
} catch {
|
||||
toast.error('Failed to copy secret API key')
|
||||
return apiKey.api_key
|
||||
}
|
||||
|
||||
// Fallback to the masked version if fetch fails
|
||||
return apiKey.api_key
|
||||
}
|
||||
|
||||
// States for disabling buttons/showing tooltips
|
||||
const isRestricted = isSecret && !canManageSecretKeys
|
||||
|
||||
return (
|
||||
@@ -123,7 +97,7 @@ export function ApiKeyPill({
|
||||
{isSecret ? (
|
||||
<>
|
||||
<span>{apiKey?.api_key.slice(0, 15)}</span>
|
||||
<span>{show && data?.api_key ? data?.api_key.slice(15) : '••••••••••••••••'}</span>
|
||||
<span>{show && revealedKey ? revealedKey.slice(15) : '••••••••••••••••'}</span>
|
||||
</>
|
||||
) : (
|
||||
<span title={apiKey.api_key} className="truncate">
|
||||
@@ -141,7 +115,7 @@ export function ApiKeyPill({
|
||||
className="rounded-full px-2 pointer-events-auto"
|
||||
loading={show && isLoading}
|
||||
icon={show ? <EyeOff strokeWidth={2} /> : <Eye strokeWidth={2} />}
|
||||
onClick={onSubmitToggle}
|
||||
onClick={onToggleShow}
|
||||
disabled={isRestricted}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { getAPIKeysById } from '@/data/api-keys/api-key-id-query'
|
||||
|
||||
interface UseRevealedSecretOptions {
|
||||
projectRef?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function useRevealedSecret({ projectRef, id }: UseRevealedSecretOptions) {
|
||||
const [data, setData] = useState<string | undefined | null>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const reveal = useCallback(async () => {
|
||||
if (!projectRef || !id) return
|
||||
|
||||
const requestId = ++requestIdRef.current
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const result = await getAPIKeysById({ projectRef, id, reveal: true })
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setData(result.api_key)
|
||||
return result.api_key
|
||||
} catch (error) {
|
||||
if (requestId !== requestIdRef.current) return
|
||||
console.error('Failed to reveal secret key:', error)
|
||||
throw error
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [projectRef, id])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
requestIdRef.current++
|
||||
setData(undefined)
|
||||
}, [])
|
||||
|
||||
return { data, isLoading, reveal, clear }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { PropsWithChildren } from 'react'
|
||||
|
||||
import { OrganizationResourceBanner } from '../Organization/HeaderBanner'
|
||||
import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner'
|
||||
import { FlyDeprecationBanner } from '@/components/layouts/AppLayout/FlyDeprecationBanner'
|
||||
import { NoticeBanner, NoticeBanner2 } from '@/components/layouts/AppLayout/NoticeBanner'
|
||||
import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner'
|
||||
|
||||
@@ -17,6 +18,7 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => {
|
||||
<StatusPageBanner />
|
||||
{showNoticeBanner && <NoticeBanner />}
|
||||
{showNoticeBanner2 && <NoticeBanner2 />}
|
||||
<FlyDeprecationBanner />
|
||||
<OrganizationResourceBanner />
|
||||
{/* Disabled until reintroduced or removed altogether. */}
|
||||
{/* <TaxIdBanner /> */}
|
||||
|
||||
@@ -28,7 +28,7 @@ const FeaturePreviewContext = createContext<FeaturePreviewContextType>({
|
||||
|
||||
export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext)
|
||||
|
||||
export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) => {
|
||||
const { hasLoaded } = useContext(FeatureFlagContext)
|
||||
const featurePreviews = useFeaturePreviews()
|
||||
|
||||
@@ -93,8 +93,8 @@ export const useUnifiedLogsPreview = () => {
|
||||
useIsEnterpriseOrSupabaseOrg()
|
||||
|
||||
const isLoading = !flagsHaveLoaded || isOrgLoading
|
||||
const isEligible = unifiedLogsEnabled && isEnterpriseOrSupabaseOrg
|
||||
const isEnabled = unifiedLogsEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]
|
||||
const isEligible = unifiedLogsEnabled || isEnterpriseOrSupabaseOrg
|
||||
const isEnabled = isEligible && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]
|
||||
|
||||
const enable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, true)
|
||||
const disable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, false)
|
||||
|
||||
@@ -37,7 +37,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
|
||||
name: 'New Logs interface',
|
||||
discussionsUrl: 'https://github.com/orgs/supabase/discussions/37234',
|
||||
enabled: isUnifiedLogsPreviewAvailable && isEnterpriseOrSupabaseOrg,
|
||||
enabled: isUnifiedLogsPreviewAvailable || isEnterpriseOrSupabaseOrg,
|
||||
isNew: false,
|
||||
isPlatformOnly: true,
|
||||
isDefaultOptIn: false,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DisplayableSqlFragment, SafeSqlFragment } from '@supabase/pg-meta'
|
||||
|
||||
export interface PolicyFormField {
|
||||
id?: number
|
||||
name: string
|
||||
@@ -19,8 +21,8 @@ export interface PostgresPolicyCreatePayload {
|
||||
name: string
|
||||
table: string
|
||||
schema?: string
|
||||
definition?: string
|
||||
check?: string
|
||||
definition?: SafeSqlFragment
|
||||
check?: SafeSqlFragment
|
||||
action?: 'PERMISSIVE' | 'RESTRICTIVE'
|
||||
command?: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL'
|
||||
roles?: string[]
|
||||
@@ -29,7 +31,30 @@ export interface PostgresPolicyCreatePayload {
|
||||
export interface PostgresPolicyUpdatePayload {
|
||||
id: number
|
||||
name?: string
|
||||
definition?: string
|
||||
check?: string
|
||||
definition?: SafeSqlFragment
|
||||
check?: SafeSqlFragment
|
||||
roles?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-acceptance shape of {@link PostgresPolicyCreatePayload}. `definition`/`check` are
|
||||
* `DisplayableSqlFragment` because they originate from form input and have not yet been
|
||||
* promoted via `acceptUntrustedSql`. Promotion must happen at the user gesture
|
||||
* (the Save click in `PolicyEditorModal`), not inside a utility.
|
||||
*/
|
||||
export interface DraftPostgresPolicyCreatePayload extends Omit<
|
||||
PostgresPolicyCreatePayload,
|
||||
'definition' | 'check'
|
||||
> {
|
||||
definition?: DisplayableSqlFragment
|
||||
check?: DisplayableSqlFragment
|
||||
}
|
||||
|
||||
/** Pre-acceptance shape of {@link PostgresPolicyUpdatePayload}. See {@link DraftPostgresPolicyCreatePayload}. */
|
||||
export interface DraftPostgresPolicyUpdatePayload extends Omit<
|
||||
PostgresPolicyUpdatePayload,
|
||||
'definition' | 'check'
|
||||
> {
|
||||
definition?: DisplayableSqlFragment
|
||||
check?: DisplayableSqlFragment
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { safeSql } from '@supabase/pg-meta'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -249,7 +250,7 @@ describe('Policies.utils - Policy Generation', () => {
|
||||
command: 'SELECT',
|
||||
table: 'posts',
|
||||
schema: 'public',
|
||||
definition: 'true',
|
||||
definition: safeSql`true`,
|
||||
action: 'PERMISSIVE',
|
||||
roles: ['public'],
|
||||
},
|
||||
@@ -342,7 +343,7 @@ describe('Policies.utils - Policy Generation', () => {
|
||||
command: 'SELECT',
|
||||
table: 'posts',
|
||||
schema: 'public',
|
||||
definition: 'true',
|
||||
definition: safeSql`true`,
|
||||
action: 'PERMISSIVE',
|
||||
roles: ['public'],
|
||||
},
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { ident } from '@supabase/pg-meta/src/pg-format'
|
||||
import type { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import {
|
||||
acceptUntrustedSql,
|
||||
ident,
|
||||
safeSql,
|
||||
untrustedSql,
|
||||
type DisplayableSqlFragment,
|
||||
type SafeSqlFragment,
|
||||
} from '@supabase/pg-meta'
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { has, isEmpty, isEqual } from 'lodash'
|
||||
|
||||
import {
|
||||
DraftPostgresPolicyCreatePayload,
|
||||
DraftPostgresPolicyUpdatePayload,
|
||||
PolicyFormField,
|
||||
PolicyForReview,
|
||||
PostgresPolicyCreatePayload,
|
||||
PostgresPolicyUpdatePayload,
|
||||
} from './Policies.types'
|
||||
import { generateSqlPolicy } from '@/data/ai/sql-policy-mutation'
|
||||
import type { CreatePolicyBody } from '@/data/database-policies/database-policy-create-mutation'
|
||||
@@ -19,7 +26,7 @@ import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constrain
|
||||
|
||||
export const createSQLPolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
originalPolicyFormFields?: PostgresPolicy
|
||||
originalPolicyFormFields?: PGPolicy
|
||||
) => {
|
||||
const { definition, check } = policyFormFields
|
||||
const formattedPolicyFormFields = {
|
||||
@@ -113,45 +120,48 @@ const createSQLStatementForUpdatePolicy = (
|
||||
return { description, statement }
|
||||
}
|
||||
|
||||
// These constructors return DRAFT payloads — `definition`/`check` are still
|
||||
// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture
|
||||
// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that
|
||||
// it was reached via a deliberate user action.
|
||||
export const createPayloadForCreatePolicy = (
|
||||
policyFormFields: PolicyFormField
|
||||
): PostgresPolicyCreatePayload => {
|
||||
const { command, definition, check, roles } = policyFormFields
|
||||
): DraftPostgresPolicyCreatePayload => {
|
||||
const { name, schema, table, command, definition, check, roles } = policyFormFields
|
||||
return {
|
||||
...policyFormFields,
|
||||
name,
|
||||
schema,
|
||||
table,
|
||||
action: 'PERMISSIVE',
|
||||
command: command || undefined,
|
||||
definition: definition || undefined,
|
||||
check: check || undefined,
|
||||
definition: !definition ? undefined : untrustedSql(definition),
|
||||
check: !check ? undefined : untrustedSql(check),
|
||||
roles: roles.length > 0 ? roles : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const createPayloadForUpdatePolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
originalPolicyFormFields: PostgresPolicy
|
||||
): PostgresPolicyUpdatePayload => {
|
||||
originalPolicyFormFields: PGPolicy
|
||||
): DraftPostgresPolicyUpdatePayload => {
|
||||
const { definition, check } = policyFormFields
|
||||
const formattedPolicyFormFields = {
|
||||
...policyFormFields,
|
||||
definition: definition ? definition.replace(/\s+/g, ' ').trim() : definition,
|
||||
check: check ? check.replace(/\s+/g, ' ').trim() : check,
|
||||
}
|
||||
const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition
|
||||
const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check
|
||||
|
||||
const payload: PostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
|
||||
const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
|
||||
|
||||
if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) {
|
||||
payload.name = formattedPolicyFormFields.name
|
||||
if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) {
|
||||
payload.name = policyFormFields.name
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) {
|
||||
payload.definition = formattedPolicyFormFields.definition || undefined
|
||||
if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) {
|
||||
payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition)
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) {
|
||||
payload.check = formattedPolicyFormFields.check || undefined
|
||||
if (!isEqual(formattedCheck, originalPolicyFormFields.check)) {
|
||||
payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck)
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) {
|
||||
if (formattedPolicyFormFields.roles.length === 0) payload.roles = ['public']
|
||||
else payload.roles = formattedPolicyFormFields.roles || undefined
|
||||
if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) {
|
||||
if (policyFormFields.roles.length === 0) payload.roles = ['public']
|
||||
else payload.roles = policyFormFields.roles || undefined
|
||||
}
|
||||
|
||||
return payload
|
||||
@@ -160,17 +170,43 @@ export const createPayloadForUpdatePolicy = (
|
||||
// --- 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
|
||||
* A policy generated for display/staging in the table editor.
|
||||
* `definition`/`check` are `DisplayableSqlFragment` because generators have different provenance:
|
||||
* programmatic generation produces `SafeSqlFragment` (composed via `safeSql`), AI generation
|
||||
* produces `UntrustedSqlFragment` (third-party output). Consumers must promote via
|
||||
* `acceptUntrustedSql` at a user gesture before executing.
|
||||
*/
|
||||
export type GeneratedPolicy = Required<
|
||||
Pick<CreatePolicyBody, 'name' | 'table' | 'schema' | 'action' | 'roles'>
|
||||
> &
|
||||
Pick<CreatePolicyBody, 'command' | 'definition' | 'check'> & {
|
||||
Pick<CreatePolicyBody, 'command'> & {
|
||||
definition?: DisplayableSqlFragment
|
||||
check?: DisplayableSqlFragment
|
||||
sql: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link GeneratedPolicy} whose `definition`/`check` have already been promoted to
|
||||
* `SafeSqlFragment`. Producing one of these is the contract that says: the user gesture
|
||||
* required to execute this SQL has already happened.
|
||||
*/
|
||||
export type AcceptedGeneratedPolicy = Omit<GeneratedPolicy, 'definition' | 'check'> & {
|
||||
definition?: SafeSqlFragment
|
||||
check?: SafeSqlFragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes a {@link GeneratedPolicy} to an {@link AcceptedGeneratedPolicy}.
|
||||
* ONLY call from an event handler tied to a deliberate user action (e.g. the Save click
|
||||
* on the table editor). Never call from useEffect, render, or any path that runs without
|
||||
* a user gesture.
|
||||
*/
|
||||
export const acceptGeneratedPolicy = (policy: GeneratedPolicy): AcceptedGeneratedPolicy => ({
|
||||
...policy,
|
||||
definition: policy.definition === undefined ? undefined : acceptUntrustedSql(policy.definition),
|
||||
check: policy.check === undefined ? undefined : acceptUntrustedSql(policy.check),
|
||||
})
|
||||
|
||||
type Relationship = {
|
||||
source_schema: string
|
||||
source_table_name: string
|
||||
@@ -270,23 +306,22 @@ const findPathToAuthUsers = (
|
||||
}
|
||||
|
||||
/** Generates SQL expression for RLS policy based on FK path to auth.users */
|
||||
const buildPolicyExpression = (path: Relationship[]): string => {
|
||||
if (path.length === 0) return ''
|
||||
const buildPolicyExpression = (path: Relationship[]): SafeSqlFragment => {
|
||||
if (path.length === 0) return safeSql``
|
||||
|
||||
// Direct FK to auth.users
|
||||
if (path.length === 1) {
|
||||
return `(select auth.uid()) = ${ident(path[0].source_column_name)}`
|
||||
return safeSql`(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 firstTarget = safeSql`${ident(first.target_table_schema)}.${ident(first.target_table_name)}`
|
||||
const source = safeSql`${ident(first.source_schema)}.${ident(first.source_table_name)}`
|
||||
const last = path[path.length - 1]
|
||||
|
||||
const joins = rest
|
||||
.slice(0, -1)
|
||||
.map((r) => {
|
||||
const joins = rest.slice(0, -1).reduce<SafeSqlFragment>(
|
||||
(acc, r) => {
|
||||
const targetSchema = ident(r.target_table_schema)
|
||||
const targetTable = ident(r.target_table_name)
|
||||
const targetColumn = ident(r.target_column_name)
|
||||
@@ -294,11 +329,13 @@ const buildPolicyExpression = (path: Relationship[]): string => {
|
||||
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 ')
|
||||
const join = safeSql`join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}`
|
||||
return acc.length === 0 ? join : safeSql`${acc}\n ${join}`
|
||||
},
|
||||
safeSql``
|
||||
)
|
||||
|
||||
return `exists (
|
||||
return safeSql`exists (
|
||||
select 1 from ${firstTarget}
|
||||
${joins}
|
||||
where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)}
|
||||
@@ -383,15 +420,13 @@ export const generateAiPoliciesForTable = async ({
|
||||
if (!connectionString) return []
|
||||
|
||||
try {
|
||||
const aiPolicies = await generateSqlPolicy({
|
||||
return 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 []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import { acceptUntrustedSql, PGPolicy } from '@supabase/pg-meta'
|
||||
import { isEmpty, noop } from 'lodash'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -6,6 +6,8 @@ import { Modal } from 'ui'
|
||||
|
||||
import { POLICY_MODAL_VIEWS } from '../Policies.constants'
|
||||
import {
|
||||
DraftPostgresPolicyCreatePayload,
|
||||
DraftPostgresPolicyUpdatePayload,
|
||||
PolicyFormField,
|
||||
PolicyForReview,
|
||||
PostgresPolicyCreatePayload,
|
||||
@@ -28,11 +30,29 @@ import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialo
|
||||
import useLatest from '@/hooks/misc/useLatest'
|
||||
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
|
||||
|
||||
// Call only from a user-gesture handler (the Save click). Promotes the draft payload's
|
||||
// `definition`/`check` from `DisplayableSqlFragment` to executable `SafeSqlFragment`.
|
||||
const acceptCreatePayload = (
|
||||
draft: DraftPostgresPolicyCreatePayload
|
||||
): PostgresPolicyCreatePayload => ({
|
||||
...draft,
|
||||
definition: draft.definition === undefined ? undefined : acceptUntrustedSql(draft.definition),
|
||||
check: draft.check === undefined ? undefined : acceptUntrustedSql(draft.check),
|
||||
})
|
||||
|
||||
const acceptUpdatePayload = (
|
||||
draft: DraftPostgresPolicyUpdatePayload
|
||||
): PostgresPolicyUpdatePayload => ({
|
||||
...draft,
|
||||
definition: draft.definition === undefined ? undefined : acceptUntrustedSql(draft.definition),
|
||||
check: draft.check === undefined ? undefined : acceptUntrustedSql(draft.check),
|
||||
})
|
||||
|
||||
interface PolicyEditorModalProps {
|
||||
visible?: boolean
|
||||
schema?: string
|
||||
table?: string
|
||||
selectedPolicyToEdit?: PostgresPolicy
|
||||
selectedPolicyToEdit?: PGPolicy
|
||||
showAssistantPreview?: boolean
|
||||
onSelectCancel: () => void
|
||||
onCreatePolicy: (payload: PostgresPolicyCreatePayload) => Promise<boolean>
|
||||
@@ -163,10 +183,11 @@ export const PolicyEditorModal = ({
|
||||
onReviewPolicy()
|
||||
}
|
||||
|
||||
// The Save click is the explicit user gesture that promotes editor SQL to executable.
|
||||
const onReviewSave = () => {
|
||||
const payload = isNewPolicy
|
||||
? createPayloadForCreatePolicy(policyFormFields)
|
||||
: createPayloadForUpdatePolicy(policyFormFields, selectedPolicyToEdit)
|
||||
? acceptCreatePayload(createPayloadForCreatePolicy(policyFormFields))
|
||||
: acceptUpdatePayload(createPayloadForUpdatePolicy(policyFormFields, selectedPolicyToEdit))
|
||||
onSavePolicy(payload)
|
||||
setIsDirty(false)
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { Lock } from 'lucide-react'
|
||||
|
||||
interface LockedCreateQuerySection {
|
||||
schema: string
|
||||
selectedPolicy?: PostgresPolicy
|
||||
selectedPolicy?: PGPolicy
|
||||
isRenamingPolicy: boolean
|
||||
formFields: { name: string; table: string; behavior: string; command: string; roles: string }
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { ident, keyword, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
|
||||
import type { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import { isEqual } from 'lodash'
|
||||
|
||||
// [Joshen] Not used but keeping this for now in case we do an inline editor
|
||||
export const generatePlaceholder = (policy?: PostgresPolicy) => {
|
||||
export const generatePlaceholder = (policy?: PGPolicy) => {
|
||||
if (policy === undefined) {
|
||||
return `
|
||||
-- Press tab to use this code\n
|
||||
@@ -78,7 +78,7 @@ export const generateCreatePolicyQuery = ({
|
||||
}
|
||||
|
||||
export const checkIfPolicyHasChanged = (
|
||||
selectedPolicy: PostgresPolicy,
|
||||
selectedPolicy: PGPolicy,
|
||||
policyForm: {
|
||||
name: string
|
||||
roles: string[]
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import type { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { ChevronDown, PanelLeftClose, PanelRightClose, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ export const PolicyEditorPanelHeader = ({
|
||||
showTools,
|
||||
setShowTools,
|
||||
}: {
|
||||
selectedPolicy?: PostgresPolicy
|
||||
selectedPolicy?: PGPolicy
|
||||
showTools: boolean
|
||||
setShowTools: (v: boolean) => void
|
||||
}) => {
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ import { NoSearchResults } from '@/components/ui/NoSearchResults'
|
||||
interface PolicyTemplatesProps {
|
||||
schema: string
|
||||
table: string
|
||||
selectedPolicy?: PostgresPolicy
|
||||
selectedPolicy?: PGPolicy
|
||||
selectedTemplate?: string
|
||||
onSelectTemplate: (template: any) => void
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
import { ident, joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta'
|
||||
import { PostgresPolicy } from '@supabase/postgres-meta'
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
|
||||
import type { TableApiAccessData } from '@/data/privileges/table-api-access-query'
|
||||
|
||||
export type Policy = Omit<PostgresPolicy, 'definition' | 'check'> & {
|
||||
export type Policy = Omit<PGPolicy, 'definition' | 'check'> & {
|
||||
definition: SafeSqlFragment | null
|
||||
check: SafeSqlFragment | null
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@ui/components/shadcn/ui/select'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { Code } from 'lucide-react'
|
||||
import { Code, ExternalLink } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
@@ -42,6 +42,7 @@ import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRo
|
||||
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
|
||||
import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
||||
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
@@ -51,6 +52,7 @@ interface RLSTesterSheetProps {
|
||||
}
|
||||
|
||||
export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => {
|
||||
const track = useTrack()
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
const { openSidebar } = useSidebarManagerSnapshot()
|
||||
const { setRole } = useRoleImpersonationStateSnapshot()
|
||||
@@ -103,8 +105,10 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
|
||||
if (format === 'lib') {
|
||||
if (!inferredSQL) return
|
||||
await testQuery({ value: acceptUntrustedSql(inferredSQL), ...executionCallbacks })
|
||||
track('rls_tester_run_query_clicked', { type: 'inferred' })
|
||||
} else {
|
||||
await testQuery({ value, ...executionCallbacks })
|
||||
track('rls_tester_run_query_clicked', { type: 'raw' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,11 +276,10 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
|
||||
</div>
|
||||
|
||||
<SheetFooter className="sm:justify-between">
|
||||
<Button asChild type="text">
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground-light hover:text-foreground"
|
||||
href="https://github.com/orgs/supabase/discussions/45233"
|
||||
>
|
||||
Give feedback
|
||||
|
||||
@@ -125,7 +125,7 @@ const CreditCard = ({
|
||||
</p>
|
||||
</div>
|
||||
{isSpt && spt && (
|
||||
<div className="mt-2.5 flex items-center gap-2 border-t border-border-light pt-2.5 text-xs text-foreground-light">
|
||||
<div className="mt-2.5 flex items-center gap-2 border-t pt-2.5 text-xs text-foreground-light">
|
||||
<p className="m-0">
|
||||
Managed via Stripe Projects · Token ending in{' '}
|
||||
<code className="text-code-inline">{spt.last4}</code>
|
||||
|
||||
@@ -175,7 +175,7 @@ export const ConnectionPanel = ({
|
||||
onCopyCallback={onCopyCallback}
|
||||
/>
|
||||
{notice && (
|
||||
<div className="border px-4 py-1 w-full justify-start rounded-t-none !last:rounded-b group-data-open:rounded-b-none border-light">
|
||||
<div className="border px-4 py-1 w-full justify-start rounded-t-none !last:rounded-b group-data-open:rounded-b-none">
|
||||
{notice?.map((text: string) => (
|
||||
<p key={text} className="text-xs text-foreground-lighter">
|
||||
{text}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ConnectionParameters = ({ parameters }: ConnectionParametersProps)
|
||||
<Collapsible_Shadcn_ open={isOpen} onOpenChange={setIsOpen} className="group -space-y-px">
|
||||
<CollapsibleTrigger_Shadcn_
|
||||
asChild
|
||||
className="w-full justify-start rounded-t-none !last:rounded-b group-data-open:rounded-b-none data-[state=open]:border-light border-light px-3"
|
||||
className="w-full justify-start rounded-t-none !last:rounded-b group-data-open:rounded-b-none px-3"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
|
||||
@@ -490,7 +490,7 @@ export const DatabaseConnectionString = () => {
|
||||
<Collapsible_Shadcn_ className="group">
|
||||
<CollapsibleTrigger_Shadcn_
|
||||
asChild
|
||||
className="w-full justify-start !last:rounded-b group-data-open:rounded-b-none border-light px-3"
|
||||
className="w-full justify-start !last:rounded-b group-data-open:rounded-b-none px-3"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
|
||||
import { getDatabaseTriggersHref } from './FunctionList.utils'
|
||||
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import type { DatabaseFunction } from '@/data/database-functions/database-functions-query'
|
||||
@@ -125,7 +126,7 @@ const FunctionList = ({
|
||||
<TableCell className="table-cell">
|
||||
{x.return_type === 'trigger' ? (
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/triggers?search=${x.name}`}
|
||||
href={getDatabaseTriggersHref(projectRef, x.name)}
|
||||
className="truncate text-link"
|
||||
title={x.return_type}
|
||||
>
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDatabaseTriggersHref } from './FunctionList.utils'
|
||||
|
||||
describe('FunctionList.utils: getDatabaseTriggersHref', () => {
|
||||
it('builds the triggers href with a plain function name', () => {
|
||||
expect(getDatabaseTriggersHref('abc', 'on_insert')).toBe(
|
||||
'/project/abc/database/triggers?search=on_insert'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves special characters in the function name', () => {
|
||||
const href = getDatabaseTriggersHref('abc', 'on_insert&schema=secret')
|
||||
const parsed = new URL(href, 'http://example.com')
|
||||
expect(parsed.searchParams.get('search')).toBe('on_insert&schema=secret')
|
||||
expect(parsed.searchParams.get('schema')).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves spaces and plus signs in the function name', () => {
|
||||
const href = getDatabaseTriggersHref('abc', 'a name+x')
|
||||
const parsed = new URL(href, 'http://example.com')
|
||||
expect(parsed.searchParams.get('search')).toBe('a name+x')
|
||||
})
|
||||
|
||||
it('falls back to empty strings for undefined inputs', () => {
|
||||
expect(getDatabaseTriggersHref(undefined, undefined)).toBe(
|
||||
'/project//database/triggers?search='
|
||||
)
|
||||
})
|
||||
})
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export const getDatabaseTriggersHref = (
|
||||
projectRef: string | null | undefined,
|
||||
name: string | null | undefined
|
||||
): string => {
|
||||
return `/project/${projectRef ?? ''}/database/triggers?search=${encodeURIComponent(name ?? '')}`
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { keyword } from '@supabase/pg-meta'
|
||||
import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
|
||||
import type { PostgresTrigger } from '@supabase/postgres-meta'
|
||||
import type { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
|
||||
@@ -37,7 +36,7 @@ export const isEdgeFunction = ({
|
||||
|
||||
const FORM_ID = 'edit-hook-panel-form'
|
||||
|
||||
const parseHeaders = (selectedHook?: PostgresTrigger): HTTPArgument[] => {
|
||||
const parseHeaders = (selectedHook?: PGTrigger): HTTPArgument[] => {
|
||||
if (typeof selectedHook === 'undefined') {
|
||||
return [{ id: uuidv4(), name: 'Content-type', value: 'application/json' }]
|
||||
}
|
||||
@@ -57,7 +56,7 @@ const parseHeaders = (selectedHook?: PostgresTrigger): HTTPArgument[] => {
|
||||
}))
|
||||
}
|
||||
|
||||
const parseParameters = (selectedHook?: PostgresTrigger): HTTPArgument[] => {
|
||||
const parseParameters = (selectedHook?: PGTrigger): HTTPArgument[] => {
|
||||
if (typeof selectedHook === 'undefined') {
|
||||
return [{ id: uuidv4(), name: '', value: '' }]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PostgresTrigger } from '@supabase/postgres-meta'
|
||||
import type { PGTrigger } from '@supabase/pg-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import Image from 'next/legacy/image'
|
||||
@@ -42,7 +42,7 @@ import { uuidv4 } from '@/lib/helpers'
|
||||
|
||||
export interface FormContentsProps {
|
||||
form: UseFormReturn<WebhookFormValues>
|
||||
selectedHook?: PostgresTrigger
|
||||
selectedHook?: PGTrigger
|
||||
}
|
||||
|
||||
export const FormContents = ({ form, selectedHook }: FormContentsProps) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PostgresPublication, PostgresTable } from '@supabase/postgres-meta'
|
||||
import type { PGPublication, PGTable } from '@supabase/pg-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
@@ -10,8 +10,8 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
|
||||
|
||||
interface PublicationsTableItemProps {
|
||||
table: PostgresTable
|
||||
selectedPublication: PostgresPublication
|
||||
table: PGTable
|
||||
selectedPublication: PGPublication
|
||||
}
|
||||
|
||||
export const PublicationsTableItem = ({
|
||||
@@ -35,10 +35,7 @@ export const PublicationsTableItem = ({
|
||||
|
||||
const { mutate: updatePublications, isPending } = useDatabasePublicationUpdateMutation()
|
||||
|
||||
const toggleReplicationForTable = async (
|
||||
table: PostgresTable,
|
||||
publication: PostgresPublication
|
||||
) => {
|
||||
const toggleReplicationForTable = async (table: PGTable, publication: PGPublication) => {
|
||||
if (project === undefined) return console.error('Project is required')
|
||||
|
||||
const originalChecked = checked
|
||||
|
||||
+25
-23
@@ -33,12 +33,18 @@ interface NewPublicationPanelProps {
|
||||
export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicationPanelProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { mutateAsync: createPublication, isPending: creatingPublication } =
|
||||
useCreatePublicationMutation()
|
||||
const { data: tables } = useReplicationTablesQuery({
|
||||
projectRef,
|
||||
sourceId,
|
||||
})
|
||||
|
||||
const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId })
|
||||
|
||||
const { mutate: createPublication, isPending: creatingPublication } =
|
||||
useCreatePublicationMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully created publication')
|
||||
form.reset(defaultValues)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const formId = 'publication-editor'
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
@@ -59,23 +65,19 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!project) return console.error('Project is required')
|
||||
if (!sourceId) return console.error('Source id is required')
|
||||
try {
|
||||
await createPublication({
|
||||
projectRef,
|
||||
sourceId,
|
||||
name: data.name,
|
||||
tables: data.tables.map((table) => {
|
||||
const [schema, name] = table.split('.')
|
||||
return { schema, name }
|
||||
}),
|
||||
connectionString: project.connectionString,
|
||||
})
|
||||
toast.success('Successfully created publication')
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error('Failed to create publication')
|
||||
}
|
||||
form.reset(defaultValues)
|
||||
|
||||
const tables = data.tables.map((table) => {
|
||||
const [schema, name] = table.split('.')
|
||||
return { schema, name }
|
||||
})
|
||||
|
||||
createPublication({
|
||||
projectRef,
|
||||
sourceId,
|
||||
name: data.name,
|
||||
tables,
|
||||
connectionString: project.connectionString,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+7
-3
@@ -56,6 +56,7 @@ import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from '@/state/replication-pipeline-request-status'
|
||||
import { type ResponseError } from '@/types'
|
||||
|
||||
const formId = 'destination-editor'
|
||||
|
||||
@@ -368,8 +369,11 @@ export const DestinationForm = ({
|
||||
const hasRequestError = results.some((r) => r.status === 'rejected')
|
||||
|
||||
if (hasRequestError) {
|
||||
// If any request failed, show a generic error and stop
|
||||
toast.error('Failed to validate configuration. Please try again.')
|
||||
// If any request failed, surface the upstream message so users see why
|
||||
const rejected = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
|
||||
const reason =
|
||||
rejected?.reason instanceof Error ? rejected.reason.message : 'Please try again.'
|
||||
toast.error(`Failed to validate configuration: ${reason}`)
|
||||
setHasRunValidation(false)
|
||||
return false
|
||||
}
|
||||
@@ -481,7 +485,7 @@ export const DestinationForm = ({
|
||||
}
|
||||
} catch (error) {
|
||||
const action = editMode ? 'apply and run' : 'create and start'
|
||||
toast.error(`Failed to ${action} destination`)
|
||||
toast.error(`Failed to ${action} destination: ${(error as ResponseError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { DeleteDestination } from './DeleteDestination'
|
||||
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { PipelineStatus } from './PipelineStatus'
|
||||
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
|
||||
import { RowMenu } from './RowMenu'
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from '@/state/replication-pipeline-request-status'
|
||||
import { type ResponseError } from '@/types'
|
||||
|
||||
interface DestinationRowProps {
|
||||
destinationId: number
|
||||
@@ -99,7 +99,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
return console.error('Project ref is required')
|
||||
}
|
||||
if (!pipeline) {
|
||||
return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
return toast.error('No pipeline found')
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -114,7 +114,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
setShowDeleteDestinationForm(false)
|
||||
toast.success(`Deleted destination "${destinationName}"`)
|
||||
} catch (error) {
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION)
|
||||
toast.error(`Failed to delete destination: ${(error as ResponseError).message}`)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ export const DestinationRow = ({ destinationId }: DestinationRowProps) => {
|
||||
return (
|
||||
<>
|
||||
{isPipelineError && (
|
||||
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
|
||||
<AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
|
||||
)}
|
||||
{isPipelineSuccess && (
|
||||
<TableRow>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { DestinationPanel } from './DestinationPanel/DestinationPanel'
|
||||
import { DestinationType } from './DestinationPanel/DestinationPanel.types'
|
||||
import { DestinationRow } from './DestinationRow'
|
||||
import { DisableExternalReplicationDialog } from './DisableExternalReplicationDialog'
|
||||
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
|
||||
import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow'
|
||||
import {
|
||||
useIsETLBigQueryPrivateAlpha,
|
||||
@@ -272,7 +271,7 @@ export const Destinations = () => {
|
||||
{hasErrorsFetchingData && (
|
||||
<AlertError
|
||||
error={destinationsError || databasesError}
|
||||
subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_DESTINATIONS}
|
||||
subject="Failed to retrieve destinations"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,18 +2,6 @@ import { PipelineStatusName } from './Replication.constants'
|
||||
import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
|
||||
import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status'
|
||||
|
||||
export const PIPELINE_ERROR_MESSAGES = {
|
||||
RETRIEVE_PIPELINE: 'Failed to retrieve pipeline information',
|
||||
RETRIEVE_PIPELINE_STATUS: 'Failed to retrieve pipeline status',
|
||||
RETRIEVE_REPLICATION_STATUS: 'Failed to retrieve table replication status',
|
||||
RETRIEVE_DESTINATIONS: 'Failed to retrieve destinations',
|
||||
ENABLE_DESTINATION: 'Failed to enable destination',
|
||||
DISABLE_DESTINATION: 'Failed to disable destination',
|
||||
DELETE_DESTINATION: 'Failed to delete destination',
|
||||
NO_PIPELINE_FOUND: 'No pipeline found',
|
||||
COPY_TABLE_STATUS: 'Failed to copy table status',
|
||||
} as const
|
||||
|
||||
export const getStatusName = (
|
||||
status: ReplicationPipelineStatusData['status'] | undefined
|
||||
): PipelineStatusName | undefined => {
|
||||
|
||||
+10
-4
@@ -39,7 +39,6 @@ import {
|
||||
getPipelineDisplayState,
|
||||
getStatusName,
|
||||
PIPELINE_ACTIONABLE_STATES,
|
||||
PIPELINE_ERROR_MESSAGES,
|
||||
} from '../Pipeline.utils'
|
||||
import { PipelineStatus } from '../PipelineStatus'
|
||||
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
|
||||
@@ -62,6 +61,7 @@ import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from '@/state/replication-pipeline-request-status'
|
||||
import { type ResponseError } from '@/types'
|
||||
|
||||
/**
|
||||
* Component for displaying replication pipeline status and table replication details.
|
||||
@@ -232,8 +232,14 @@ export const ReplicationPipelineStatus = () => {
|
||||
|
||||
const onPrimaryAction = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
if (!pipeline) return toast.error('No pipeline found')
|
||||
|
||||
const action =
|
||||
statusName === PipelineStatusName.STOPPED
|
||||
? 'start'
|
||||
: statusName === PipelineStatusName.STARTED
|
||||
? 'stop'
|
||||
: 'restart'
|
||||
try {
|
||||
if (statusName === PipelineStatusName.STOPPED) {
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
|
||||
@@ -247,7 +253,7 @@ export const ReplicationPipelineStatus = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
toast.error(`Failed to ${action} pipeline: ${(error as ResponseError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +321,7 @@ export const ReplicationPipelineStatus = () => {
|
||||
</div>
|
||||
</div>
|
||||
{isPipelineError && (
|
||||
<AlertError error={pipelineError} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE} />
|
||||
<AlertError error={pipelineError} subject="Failed to retrieve pipeline information" />
|
||||
)}
|
||||
|
||||
{isStatusError && (
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
getStatusName,
|
||||
PIPELINE_DISABLE_ALLOWED_FROM,
|
||||
PIPELINE_ENABLE_ALLOWED_FROM,
|
||||
PIPELINE_ERROR_MESSAGES,
|
||||
} from './Pipeline.utils'
|
||||
import { PipelineStatusName } from './Replication.constants'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
@@ -88,46 +87,46 @@ export const RowMenu = ({
|
||||
|
||||
const onEnablePipeline = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
if (!pipeline) return toast.error('No pipeline found')
|
||||
|
||||
try {
|
||||
// Only show 'enabling' when transitioning from allowed states
|
||||
if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as any)) {
|
||||
if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
|
||||
}
|
||||
await startPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
toast.error(`Failed to start pipeline: ${(error as ResponseError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const onDisablePipeline = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
if (!pipeline) return toast.error('No pipeline found')
|
||||
|
||||
try {
|
||||
// Only show 'disabling' when transitioning from allowed states
|
||||
if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as any)) {
|
||||
if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as PipelineStatusName)) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName)
|
||||
}
|
||||
await stopPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION)
|
||||
toast.error(`Failed to stop pipeline: ${(error as ResponseError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const onRestartPipeline = async () => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (!pipeline) return toast.error(PIPELINE_ERROR_MESSAGES.NO_PIPELINE_FOUND)
|
||||
if (!pipeline) return toast.error('No pipeline found')
|
||||
|
||||
try {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
|
||||
await restartPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
} catch (error) {
|
||||
setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
|
||||
toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION)
|
||||
toast.error(`Failed to restart pipeline: ${(error as ResponseError).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,9 +134,7 @@ export const RowMenu = ({
|
||||
<div className="flex justify-end items-center space-x-2">
|
||||
{isLoading && <ShimmeringLoader />}
|
||||
|
||||
{isError && (
|
||||
<AlertError error={error} subject={PIPELINE_ERROR_MESSAGES.RETRIEVE_PIPELINE_STATUS} />
|
||||
)}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve pipeline status" />}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
PipelineStatusRequestStatus,
|
||||
usePipelineRequestStatus,
|
||||
} from '@/state/replication-pipeline-request-status'
|
||||
import { type ResponseError } from '@/types'
|
||||
|
||||
interface UpdateVersionModalProps {
|
||||
visible: boolean
|
||||
@@ -65,9 +66,9 @@ export const UpdateVersionModal = ({
|
||||
// Step 1: Update to the new version
|
||||
try {
|
||||
await updatePipelineVersion({ projectRef, pipelineId: pipeline.id, versionId })
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
// 404: default changed; version cache will refresh via mutation onError. Keep dialog open.
|
||||
if (e?.code === 404) return
|
||||
if ((e as ResponseError)?.code === 404) return
|
||||
// Other errors are already toasted by the mutation; do not double-toast here.
|
||||
return
|
||||
}
|
||||
@@ -80,10 +81,10 @@ export const UpdateVersionModal = ({
|
||||
try {
|
||||
await restartPipeline({ projectRef, pipelineId: pipeline.id })
|
||||
toast.success('Pipeline successfully updated and is currently restarting')
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
// Clear optimistic state and surface a single concise error
|
||||
setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
|
||||
toast.error('Failed to restart pipeline')
|
||||
toast.error(`Failed to restart pipeline: ${(e as ResponseError).message}`)
|
||||
}
|
||||
} else {
|
||||
toast.success('Pipeline successfully updated')
|
||||
|
||||
@@ -6,7 +6,9 @@ import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
Collapsible,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -45,7 +47,6 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const { mutate: updateDatabaseRole, isPending: isUpdating } = useDatabaseRoleUpdateMutation()
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: role,
|
||||
@@ -93,7 +94,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
|
||||
const formId = `role-update-form-${role.id}`
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
<Collapsible_Shadcn_
|
||||
open={isExpanded}
|
||||
className={cn(
|
||||
'bg-surface-100',
|
||||
@@ -109,7 +110,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex items-center relative', !disabled && 'pr-(--card-padding-x)')}>
|
||||
<Collapsible.Trigger asChild>
|
||||
<CollapsibleTrigger_Shadcn_ asChild>
|
||||
<button
|
||||
id={`collapsible-trigger-${role.id}`}
|
||||
type="button"
|
||||
@@ -150,7 +151,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Collapsible.Trigger>
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
{!disabled && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -176,7 +177,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<Collapsible.Content>
|
||||
<CollapsibleContent_Shadcn_>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={formId}
|
||||
@@ -228,7 +229,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
|
||||
import type { PGSchema } from '@supabase/pg-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import {
|
||||
Background,
|
||||
@@ -64,6 +64,7 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
|
||||
import { tablesToSQL } from '@/lib/helpers'
|
||||
import type { SafePostgresTable } from '@/lib/postgres-types'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
import { useTableEditorStateSnapshot } from '@/state/table-editor'
|
||||
@@ -75,7 +76,7 @@ export const SchemaGraph = () => {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
|
||||
const [selectedTable, setSelectedTable] = useState<PostgresTable | null>(null)
|
||||
const [selectedTable, setSelectedTable] = useState<SafePostgresTable | null>(null)
|
||||
const snap = useTableEditorStateSnapshot()
|
||||
const { isDownloading, exportSchemaToImage } = useExportSchemaToImage()
|
||||
|
||||
@@ -241,7 +242,7 @@ export const SchemaGraph = () => {
|
||||
const isFirstLoad = useRef(true)
|
||||
useEffect(() => {
|
||||
if (isSuccessTables && isSuccessSchemas && tables.length > 0) {
|
||||
const schema = schemas.find((s) => s.name === selectedSchema) as PostgresSchema
|
||||
const schema = schemas.find((s) => s.name === selectedSchema) as PGSchema
|
||||
getGraphDataFromTables(ref as string, schema, tables).then(({ nodes, edges }) => {
|
||||
reactFlowInstance.setNodes(nodes)
|
||||
reactFlowInstance.setEdges(edges)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import dagre from '@dagrejs/dagre'
|
||||
import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta'
|
||||
import type { PGSchema, PGTable } from '@supabase/pg-meta'
|
||||
import { Edge, Node, Position } from '@xyflow/react'
|
||||
import { uniqBy } from 'lodash'
|
||||
|
||||
@@ -16,8 +16,8 @@ const RANK_SEP = 50
|
||||
|
||||
export async function getGraphDataFromTables(
|
||||
ref?: string,
|
||||
schema?: PostgresSchema,
|
||||
tables?: PostgresTable[]
|
||||
schema?: PGSchema,
|
||||
tables?: PGTable[]
|
||||
): Promise<{
|
||||
nodes: Node<TableNodeData>[]
|
||||
edges: Edge[]
|
||||
@@ -166,7 +166,7 @@ export async function getGraphDataFromTables(
|
||||
}
|
||||
|
||||
function findTablesHandleIds(
|
||||
tables: PostgresTable[],
|
||||
tables: PGTable[],
|
||||
table_name: string,
|
||||
column_name: string
|
||||
): [string?, string?] {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PostgresColumn } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { noop } from 'lodash'
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
getUniqueIndexColumnNames,
|
||||
} from './ColumnList.utils'
|
||||
import { ConstraintToken } from './ConstraintToken'
|
||||
import { displayColumnType } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
|
||||
@@ -57,8 +57,9 @@ import { isTableLike } from '@/data/table-editor/table-editor-types'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
import type { SafePostgresColumn } from '@/lib/postgres-types'
|
||||
|
||||
const getColumnTypeAffordancePresentation = (column: PostgresColumn) => {
|
||||
const getColumnTypeAffordancePresentation = (column: SafePostgresColumn) => {
|
||||
const { kind, label } = getColumnTypeAffordance(column.format)
|
||||
const iconClassName = 'text-foreground-muted'
|
||||
|
||||
@@ -98,8 +99,8 @@ const getColumnTypeAffordancePresentation = (column: PostgresColumn) => {
|
||||
|
||||
interface ColumnListProps {
|
||||
onAddColumn: () => void
|
||||
onEditColumn: (column: PostgresColumn) => void
|
||||
onDeleteColumn: (column: PostgresColumn) => void
|
||||
onEditColumn: (column: SafePostgresColumn) => void
|
||||
onDeleteColumn: (column: SafePostgresColumn) => void
|
||||
}
|
||||
|
||||
export const ColumnList = ({
|
||||
@@ -322,7 +323,11 @@ export const ColumnList = ({
|
||||
<span>{column.data_type}</span>
|
||||
{column.format !== column.data_type && (
|
||||
<span className="text-xs text-foreground-light">
|
||||
{column.format}
|
||||
{displayColumnType(
|
||||
column.format,
|
||||
column.format_schema,
|
||||
column.data_type === 'ARRAY'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -343,7 +348,13 @@ export const ColumnList = ({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="text-foreground-lighter">{column.format}</p>
|
||||
<p className="text-foreground-lighter">
|
||||
{displayColumnType(
|
||||
column.format,
|
||||
column.format_schema,
|
||||
column.data_type === 'ARRAY'
|
||||
)}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1.5">{constraintTokens}</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { PostgresTable } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { noop } from 'lodash'
|
||||
@@ -55,14 +54,15 @@ import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
|
||||
import { onSearchInputEscape } from '@/lib/keyboard'
|
||||
import type { SafePostgresTable } from '@/lib/postgres-types'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
|
||||
interface TableListProps {
|
||||
onAddTable: () => void
|
||||
onEditTable: (table: PostgresTable) => void
|
||||
onDeleteTable: (table: PostgresTable) => void
|
||||
onDuplicateTable: (table: PostgresTable) => void
|
||||
onEditTable: (table: SafePostgresTable) => void
|
||||
onDeleteTable: (table: SafePostgresTable) => void
|
||||
onDuplicateTable: (table: SafePostgresTable) => void
|
||||
}
|
||||
|
||||
export const TableList = ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PostgresMaterializedView, PostgresTable, PostgresView } from '@supabase/postgres-meta'
|
||||
import { PostgresForeignTable } from '@supabase/postgres-meta/dist/lib/types'
|
||||
import { PGForeignTable, PGMaterializedView, PGView } from '@supabase/pg-meta'
|
||||
|
||||
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
|
||||
import type { SafePostgresTable } from '@/lib/postgres-types'
|
||||
|
||||
// [Joshen] We just need name, schema, description, rows, size, and the number of columns
|
||||
// Just missing partitioned tables as missing pg-meta support
|
||||
@@ -11,10 +11,10 @@ export const formatAllEntities = ({
|
||||
materializedViews = [],
|
||||
foreignTables = [],
|
||||
}: {
|
||||
tables?: PostgresTable[]
|
||||
views?: PostgresView[]
|
||||
materializedViews?: PostgresMaterializedView[]
|
||||
foreignTables?: PostgresForeignTable[]
|
||||
tables?: SafePostgresTable[]
|
||||
views?: PGView[]
|
||||
materializedViews?: PGMaterializedView[]
|
||||
foreignTables?: PGForeignTable[]
|
||||
}) => {
|
||||
const formattedTables = tables.map((x) => {
|
||||
return {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { PostgresTrigger } from '@supabase/postgres-meta'
|
||||
import type { PGTrigger } from '@supabase/pg-meta'
|
||||
|
||||
import { TextConfirmModal } from '@/components/ui/TextConfirmModalWrapper'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
interface DeleteTriggerProps {
|
||||
trigger?: PostgresTrigger
|
||||
trigger?: PGTrigger
|
||||
visible: boolean
|
||||
setVisible: (value: string | null) => void
|
||||
onDelete: (params: {
|
||||
projectRef: string
|
||||
connectionString?: string | null
|
||||
trigger: PostgresTrigger
|
||||
trigger: PGTrigger
|
||||
}) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
+6
-1
@@ -17,6 +17,7 @@ import {
|
||||
|
||||
import type { EventTrigger } from './EventTriggerList.utils'
|
||||
import { SUPABASE_ROLES } from '@/components/interfaces/Database/Roles/Roles.constants'
|
||||
import { getDatabaseFunctionsHref } from '@/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
|
||||
interface EventTriggerListProps {
|
||||
@@ -126,7 +127,11 @@ export const EventTriggerList = ({
|
||||
<TableCell className="space-x-2">
|
||||
{trigger.function_name ? (
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/functions?search=${trigger.function_name}&schema=${trigger.function_schema}`}
|
||||
href={getDatabaseFunctionsHref(
|
||||
projectRef,
|
||||
trigger.function_schema,
|
||||
trigger.function_name
|
||||
)}
|
||||
className="text-link-table-cell block max-w-40 text-foreground-light"
|
||||
>
|
||||
{trigger.function_name}
|
||||
|
||||
+25
-11
@@ -3,7 +3,18 @@ import { DatabaseZap, Plus, Search } from 'lucide-react'
|
||||
import { parseAsJson, parseAsString, useQueryState } from 'nuqs'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, Card, Input, Table, TableBody, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { EmptyStatePresentational } from 'ui-patterns'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
@@ -181,16 +192,19 @@ export const EventTriggersList = () => {
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-2 flex-wrap">
|
||||
<Input
|
||||
inputRef={searchInputRef}
|
||||
placeholder="Search for an event trigger"
|
||||
size="tiny"
|
||||
icon={<Search />}
|
||||
value={filterString}
|
||||
className="w-full lg:w-64"
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
/>
|
||||
<InputGroup className="w-full lg:w-52">
|
||||
<InputGroupInput
|
||||
ref={searchInputRef}
|
||||
size="tiny"
|
||||
placeholder="Search for a trigger"
|
||||
value={filterString}
|
||||
onChange={(e) => setFilterString(e.target.value)}
|
||||
onKeyDown={onSearchInputEscape(filterString, setFilterString)}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<ReportsSelectFilter
|
||||
label="Owner"
|
||||
options={ownerOptions.map((owner) => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PostgresTrigger } from '@supabase/postgres-meta'
|
||||
import type { PGTrigger } from '@supabase/pg-meta'
|
||||
import { Terminal } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
@@ -76,7 +76,7 @@ const defaultValues: z.infer<typeof FormSchema> = {
|
||||
}
|
||||
|
||||
interface TriggerSheetProps {
|
||||
selectedTrigger?: PostgresTrigger
|
||||
selectedTrigger?: PGTrigger
|
||||
isDuplicatingTrigger?: boolean
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
|
||||
import { generateTriggerCreateSQL, type PostgresTrigger } from './TriggerList.utils'
|
||||
import {
|
||||
generateTriggerCreateSQL,
|
||||
getDatabaseFunctionsHref,
|
||||
type PostgresTrigger,
|
||||
} from './TriggerList.utils'
|
||||
import { selectFilterSchema } from '@/components/interfaces/Reports/v2/ReportsSelectFilter'
|
||||
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
@@ -131,7 +135,7 @@ export const TriggerList = ({ editTrigger, duplicateTrigger, deleteTrigger }: Tr
|
||||
<TableCell className="space-x-2">
|
||||
{x.function_name ? (
|
||||
<Link
|
||||
href={`/project/${projectRef}/database/functions?search=${x.function_name}&schema=${x.function_schema}`}
|
||||
href={getDatabaseFunctionsHref(projectRef, x.function_schema, x.function_name)}
|
||||
className="text-link-table-cell block max-w-40 text-foreground-light"
|
||||
>
|
||||
{x.function_name}
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDatabaseFunctionsHref } from './TriggerList.utils'
|
||||
|
||||
describe('TriggerList.utils: getDatabaseFunctionsHref', () => {
|
||||
it('builds the functions href with plain values', () => {
|
||||
expect(getDatabaseFunctionsHref('abc', 'public', 'do_thing')).toBe(
|
||||
'/project/abc/database/functions?search=do_thing&schema=public'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves special characters in the function name', () => {
|
||||
const href = getDatabaseFunctionsHref('abc', 'public', 'do_thing&secret=1')
|
||||
const parsed = new URL(href, 'http://example.com')
|
||||
expect(parsed.searchParams.get('search')).toBe('do_thing&secret=1')
|
||||
expect(parsed.searchParams.get('schema')).toBe('public')
|
||||
})
|
||||
|
||||
it('preserves special characters in the schema', () => {
|
||||
const href = getDatabaseFunctionsHref('abc', 'my schema+x', 'do_thing')
|
||||
const parsed = new URL(href, 'http://example.com')
|
||||
expect(parsed.searchParams.get('schema')).toBe('my schema+x')
|
||||
expect(parsed.searchParams.get('search')).toBe('do_thing')
|
||||
})
|
||||
|
||||
it('encodes both name and schema together', () => {
|
||||
const href = getDatabaseFunctionsHref('abc', 'a&b=c', 'd e+f')
|
||||
const parsed = new URL(href, 'http://example.com')
|
||||
expect(parsed.searchParams.get('schema')).toBe('a&b=c')
|
||||
expect(parsed.searchParams.get('search')).toBe('d e+f')
|
||||
})
|
||||
|
||||
it('falls back to empty strings for undefined inputs', () => {
|
||||
expect(getDatabaseFunctionsHref(undefined, undefined, undefined)).toBe(
|
||||
'/project//database/functions?search=&schema='
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -37,3 +37,13 @@ FOR EACH ${keyword(trigger.orientation)}
|
||||
|
||||
return sql
|
||||
}
|
||||
|
||||
export const getDatabaseFunctionsHref = (
|
||||
projectRef: string | null | undefined,
|
||||
schema: string | null | undefined,
|
||||
name: string | null | undefined
|
||||
): string => {
|
||||
return `/project/${projectRef ?? ''}/database/functions?search=${encodeURIComponent(
|
||||
name ?? ''
|
||||
)}&schema=${encodeURIComponent(schema ?? '')}`
|
||||
}
|
||||
Loaded 100 of 426 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user