diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index b442e4c0ecc..9a7bdddcb15 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -192,13 +192,13 @@ export const Index: Record = { 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: [] diff --git a/apps/design-system/components/color-palette.tsx b/apps/design-system/components/color-palette.tsx index 2f8f3012bd7..024b7312f8a 100644 --- a/apps/design-system/components/color-palette.tsx +++ b/apps/design-system/components/color-palette.tsx @@ -56,11 +56,11 @@ const ColorPalette = () => {
{name}
{SCALE_STEPS.map((step) => { - const reference = `var(--colors-${slug}${step})` + const reference = `var(--color-${slug}-${step * 100})` const isCopied = copied === reference return ( ) diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index ced6685f7aa..6cc07a6d0cb 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -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', diff --git a/apps/design-system/content/docs/color-usage.mdx b/apps/design-system/content/docs/color-usage.mdx index b8f5f16b555..b62441323eb 100644 --- a/apps/design-system/content/docs/color-usage.mdx +++ b/apps/design-system/content/docs/color-usage.mdx @@ -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. + diff --git a/apps/design-system/content/docs/components/alert.mdx b/apps/design-system/content/docs/components/alert.mdx index 3204cf452e8..f5c277e7c21 100644 --- a/apps/design-system/content/docs/components/alert.mdx +++ b/apps/design-system/content/docs/components/alert.mdx @@ -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. -## 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' ``` + +### Variants + +Use `alertVariants` when building shared wrappers around the primitive. + +```tsx +import { alertVariants } from 'ui/src/components/shadcn/ui/alert' +``` diff --git a/apps/design-system/content/docs/components/collapsible.mdx b/apps/design-system/content/docs/components/collapsible.mdx index d581612ebac..7eb271478ed 100644 --- a/apps/design-system/content/docs/components/collapsible.mdx +++ b/apps/design-system/content/docs/components/collapsible.mdx @@ -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 --- - +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. + + ## Usage ```tsx -import { AlertCollapsible } from 'ui' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from 'ui/src/components/shadcn/ui/collapsible' ``` ```tsx - -

Content revealed when expanded.

-
+ + Toggle + +

Content revealed when expanded.

+
+
``` ### Default open ```tsx - -

This section is expanded by default.

-
+ + Details + +

This section is expanded by default.

+
+
``` -### 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 - -

Something needs attention.

-
+ +

Content revealed when expanded.

+
``` diff --git a/apps/design-system/content/docs/fragments/admonition.mdx b/apps/design-system/content/docs/fragments/admonition.mdx index c0adf6a7803..afbb2230391 100644 --- a/apps/design-system/content/docs/fragments/admonition.mdx +++ b/apps/design-system/content/docs/fragments/admonition.mdx @@ -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. -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. diff --git a/apps/design-system/content/docs/fragments/collapsible-alert.mdx b/apps/design-system/content/docs/fragments/collapsible-alert.mdx new file mode 100644 index 00000000000..5cae9a4934f --- /dev/null +++ b/apps/design-system/content/docs/fragments/collapsible-alert.mdx @@ -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. + + + +## Usage + +```tsx +import { CollapsibleAlert } from 'ui-patterns/collapsible-alert' +``` + +```tsx + +

Content revealed when expanded.

+
+``` + +### Default open + +```tsx + +

This section is expanded by default.

+
+``` + +### Variants + +Supports the same `variant` prop as Alert: `default`, `destructive`, and `warning`. + +```tsx + +

Something needs attention.

+
+``` diff --git a/apps/design-system/registry/default/example/alert-collapsible-demo.tsx b/apps/design-system/registry/default/example/alert-collapsible-demo.tsx deleted file mode 100644 index 0aa7671fedd..00000000000 --- a/apps/design-system/registry/default/example/alert-collapsible-demo.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { AlertCollapsible } from 'ui' - -export default function AlertCollapsibleDemo() { - return ( - -

- Try a different browser or disable extensions that block network requests. -

-
- ) -} diff --git a/apps/design-system/registry/default/example/alert-demo.tsx b/apps/design-system/registry/default/example/alert-demo.tsx index cbb9ce1b70e..5618d1c845f 100644 --- a/apps/design-system/registry/default/example/alert-demo.tsx +++ b/apps/design-system/registry/default/example/alert-demo.tsx @@ -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 ( - + - Heads up! - - You can also add components to your app using the CLI. - - + Heads up! + You can also add components to your app using the CLI. + ) } diff --git a/apps/design-system/registry/default/example/collapsible-alert-demo.tsx b/apps/design-system/registry/default/example/collapsible-alert-demo.tsx new file mode 100644 index 00000000000..96cb3e30324 --- /dev/null +++ b/apps/design-system/registry/default/example/collapsible-alert-demo.tsx @@ -0,0 +1,11 @@ +import { CollapsibleAlert } from 'ui-patterns/collapsible-alert' + +export default function CollapsibleAlertDemo() { + return ( + +

+ Try a different browser or disable extensions that block network requests. +

+
+ ) +} diff --git a/apps/design-system/registry/default/example/collapsible-demo.tsx b/apps/design-system/registry/default/example/collapsible-demo.tsx index 5e569b1a35f..a5a5b556bbb 100644 --- a/apps/design-system/registry/default/example/collapsible-demo.tsx +++ b/apps/design-system/registry/default/example/collapsible-demo.tsx @@ -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 ( - +

@peduarte starred 3 repositories

- + - +
@radix-ui/primitives
- +
@radix-ui/colors
@stitches/react
-
-
+ + ) } diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index d5f9a19ae22..4c363b3ece2 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -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', diff --git a/apps/docs/app/guides/ai-tools/[[...slug]]/page.tsx b/apps/docs/app/guides/ai-tools/[[...slug]]/page.tsx new file mode 100644 index 00000000000..e23f1811e35 --- /dev/null +++ b/apps/docs/app/guides/ai-tools/[[...slug]]/page.tsx @@ -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 }) => { + const params = await props.params + const slug = ['ai-tools', ...(params.slug ?? [])] + const data = await getGuidesMarkdown(slug) + + return +} + +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 } diff --git a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/page.tsx b/apps/docs/app/guides/ai-tools/ai-prompts/[slug]/page.tsx similarity index 92% rename from apps/docs/app/guides/getting-started/ai-prompts/[slug]/page.tsx rename to apps/docs/app/guides/ai-tools/ai-prompts/[slug]/page.tsx index 1213cb07a74..7eaf8f3ceae 100644 --- a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/page.tsx +++ b/apps/docs/app/guides/ai-tools/ai-prompts/[slug]/page.tsx @@ -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}`} /> ) } diff --git a/apps/docs/app/guides/ai-tools/layout.tsx b/apps/docs/app/guides/ai-tools/layout.tsx new file mode 100644 index 00000000000..187abe5919e --- /dev/null +++ b/apps/docs/app/guides/ai-tools/layout.tsx @@ -0,0 +1,5 @@ +import Layout from '~/layouts/guides' + +export default async function AiToolsLayout({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/apps/docs/app/guides/ai-tools/troubleshooting/page.tsx b/apps/docs/app/guides/ai-tools/troubleshooting/page.tsx new file mode 100644 index 00000000000..573b36fb795 --- /dev/null +++ b/apps/docs/app/guides/ai-tools/troubleshooting/page.tsx @@ -0,0 +1,15 @@ +import SectionTroubleshootingPage, { + generateSectionTroubleshootingMetadata, +} from '~/features/docs/TroubleshootingSection.page' + +export default async function AiToolsTroubleshootingPage() { + return ( + + ) +} + +export const metadata = generateSectionTroubleshootingMetadata('AI', 'AI Tools') diff --git a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx b/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx index ed32afb8c67..6e6bf5b3053 100644 --- a/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx +++ b/apps/docs/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex.tsx @@ -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) => ( Start with Supabase AI prompts diff --git a/apps/docs/components/Mermaid.tsx b/apps/docs/components/Mermaid.tsx new file mode 100644 index 00000000000..1ffbf346c4c --- /dev/null +++ b/apps/docs/components/Mermaid.tsx @@ -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 ( +
+ ) +} diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 1da27c51790..82204bdb456 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -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: [] }, diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx index 7e9056aad82..5b51b704c83 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx @@ -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', diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts index 4f50c7ee18c..556d87971a0 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.utils.ts @@ -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'): diff --git a/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx b/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx index 7da99cf94c6..6546754dab7 100644 --- a/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx +++ b/apps/docs/components/RealtimeLimitsEstimator/RealtimeLimitsEstimator.tsx @@ -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({}) {
)} - - + +

View raw throughput table

-
- + +
{throughputTable .map((l) => l.computeAddOn) @@ -218,8 +220,8 @@ export default function RealtimeLimitsEstimater({}) {
))}
- - + + ) } diff --git a/apps/docs/content/guides/ai-tools.mdx b/apps/docs/content/guides/ai-tools.mdx new file mode 100644 index 00000000000..64ab99ea562 --- /dev/null +++ b/apps/docs/content/guides/ai-tools.mdx @@ -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 diff --git a/apps/docs/content/guides/getting-started/ai-prompts.mdx b/apps/docs/content/guides/ai-tools/ai-prompts.mdx similarity index 100% rename from apps/docs/content/guides/getting-started/ai-prompts.mdx rename to apps/docs/content/guides/ai-tools/ai-prompts.mdx diff --git a/apps/docs/content/guides/getting-started/ai-skills.mdx b/apps/docs/content/guides/ai-tools/ai-skills.mdx similarity index 100% rename from apps/docs/content/guides/getting-started/ai-skills.mdx rename to apps/docs/content/guides/ai-tools/ai-skills.mdx diff --git a/apps/docs/content/guides/getting-started/byo-mcp.mdx b/apps/docs/content/guides/ai-tools/byo-mcp.mdx similarity index 100% rename from apps/docs/content/guides/getting-started/byo-mcp.mdx rename to apps/docs/content/guides/ai-tools/byo-mcp.mdx diff --git a/apps/docs/content/guides/getting-started/mcp.mdx b/apps/docs/content/guides/ai-tools/mcp.mdx similarity index 99% rename from apps/docs/content/guides/getting-started/mcp.mdx rename to apps/docs/content/guides/ai-tools/mcp.mdx index 3996ea5f5d5..9379f4fb1dd 100644 --- a/apps/docs/content/guides/getting-started/mcp.mdx +++ b/apps/docs/content/guides/ai-tools/mcp.mdx @@ -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. diff --git a/apps/docs/content/guides/getting-started/plugins.mdx b/apps/docs/content/guides/ai-tools/plugins.mdx similarity index 100% rename from apps/docs/content/guides/getting-started/plugins.mdx rename to apps/docs/content/guides/ai-tools/plugins.mdx diff --git a/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx b/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx index 11f0672f9d6..b6b7b05ea47 100644 --- a/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx +++ b/apps/docs/content/guides/ai/examples/huggingface-image-captioning.mdx @@ -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')!) diff --git a/apps/docs/content/guides/ai/hugging-face.mdx b/apps/docs/content/guides/ai/hugging-face.mdx index 424c16dc55c..082a73a1370 100644 --- a/apps/docs/content/guides/ai/hugging-face.mdx +++ b/apps/docs/content/guides/ai/hugging-face.mdx @@ -94,12 +94,11 @@ HUGGING_FACE_ACCESS_TOKEN= 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( diff --git a/apps/docs/content/guides/auth/oauth-server/token-security.mdx b/apps/docs/content/guides/auth/oauth-server/token-security.mdx index 069d6098c1f..b5da836eb94 100644 --- a/apps/docs/content/guides/auth/oauth-server/token-security.mdx +++ b/apps/docs/content/guides/auth/oauth-server/token-security.mdx @@ -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')!) diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx index 2029bedf276..81ad16a50cc 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -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 } ``` diff --git a/apps/docs/content/guides/functions/kysely-postgres.mdx b/apps/docs/content/guides/functions/kysely-postgres.mdx index 22f4e09ecbf..a8930899f96 100644 --- a/apps/docs/content/guides/functions/kysely-postgres.mdx +++ b/apps/docs/content/guides/functions/kysely-postgres.mdx @@ -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) @@ -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({ }, }) -serve(async (_req) => { +Deno.serve(async (_req) => { try { // Run a query const animals = await db.selectFrom('animals').select(['id', 'animal', 'created_at']).execute() diff --git a/apps/docs/content/guides/getting-started.mdx b/apps/docs/content/guides/getting-started.mdx index 3509c2a3c9a..45807648272 100644 --- a/apps/docs/content/guides/getting-started.mdx +++ b/apps/docs/content/guides/getting-started.mdx @@ -11,17 +11,17 @@ hideToc: true
{[ - { - 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', diff --git a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx index 1aa5d7e3fca..64fbbdbc2b1 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-expo-react-native.mdx @@ -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, diff --git a/apps/docs/content/guides/integrations/partner-integration-guide.mdx b/apps/docs/content/guides/integrations/partner-integration-guide.mdx new file mode 100644 index 00000000000..44cc2915556 --- /dev/null +++ b/apps/docs/content/guides/integrations/partner-integration-guide.mdx @@ -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. + +>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:///?project_id=&organization_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. + +>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. + + + +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. + + + +### 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:/// +Content-Type: application/json +``` + +#### Request body + +```json +{ + "token": "" +} +``` + +#### 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": "", + "redirectUrl": "https:////", + "expiresAt": "" +} +``` + +| 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. + + + +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. + + + +### 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. diff --git a/apps/docs/content/guides/local-development/customizing-email-templates.mdx b/apps/docs/content/guides/local-development/customizing-email-templates.mdx index 60bcb1ea69e..88b8608e143 100644 --- a/apps/docs/content/guides/local-development/customizing-email-templates.mdx +++ b/apps/docs/content/guides/local-development/customizing-email-templates.mdx @@ -207,6 +207,28 @@ Contains your application's Site URL. This can be configured in your project's [

Visit here to log in.

``` +### `RedirectTo` + +Contains the redirect URL passed as the `redirectTo` option in the auth method call. + +**Usage** + +```html + + Confirm your email + +``` + +### `Data` + +Contains metadata from `auth.users.user_metadata`. Use this to personalize the email message. + +**Usage** + +```html +

Hello {{ .Data.first_name }}, please confirm your signup.

+``` + ### `Email` Contains the user's email address. diff --git a/apps/docs/content/guides/storage/serving/downloads.mdx b/apps/docs/content/guides/storage/serving/downloads.mdx index 4bf08585f65..fe267d37111 100644 --- a/apps/docs/content/guides/storage/serving/downloads.mdx +++ b/apps/docs/content/guides/storage/serving/downloads.mdx @@ -111,6 +111,21 @@ You can access them only by: ### Signing URLs + + +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=). + + You can sign a time-limited URL that you can share to your users by invoking the `createSignedUrl` method on the SDK. ```js diff --git a/apps/docs/content/troubleshooting/are-all-features-available-in-self-hosted-supabase-THPcqw.mdx b/apps/docs/content/troubleshooting/are-all-features-available-in-self-hosted-supabase-THPcqw.mdx index 7b87e4e2a6e..57d163952f6 100644 --- a/apps/docs/content/troubleshooting/are-all-features-available-in-self-hosted-supabase-THPcqw.mdx +++ b/apps/docs/content/troubleshooting/are-all-features-available-in-self-hosted-supabase-THPcqw.mdx @@ -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 diff --git a/apps/docs/content/troubleshooting/edge-function-error-rate-limit-exceeded-for-trace-1094d3.mdx b/apps/docs/content/troubleshooting/edge-function-error-rate-limit-exceeded-for-trace-1094d3.mdx index 611849ea7ec..8cd152de262 100644 --- a/apps/docs/content/troubleshooting/edge-function-error-rate-limit-exceeded-for-trace-1094d3.mdx +++ b/apps/docs/content/troubleshooting/edge-function-error-rate-limit-exceeded-for-trace-1094d3.mdx @@ -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). diff --git a/apps/docs/content/troubleshooting/how-do-i-reset-my-supabase-database-password-oTs5sB.mdx b/apps/docs/content/troubleshooting/how-do-i-reset-my-supabase-database-password-oTs5sB.mdx index 118ae5543b1..e305d8b5427 100644 --- a/apps/docs/content/troubleshooting/how-do-i-reset-my-supabase-database-password-oTs5sB.mdx +++ b/apps/docs/content/troubleshooting/how-do-i-reset-my-supabase-database-password-oTs5sB.mdx @@ -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. diff --git a/apps/docs/content/troubleshooting/how-to-migrate-from-supabase-auth-helpers-to-ssr-package-5NRunM.mdx b/apps/docs/content/troubleshooting/how-to-migrate-from-supabase-auth-helpers-to-ssr-package-5NRunM.mdx index 954e619a0bf..6a9484f1f04 100644 --- a/apps/docs/content/troubleshooting/how-to-migrate-from-supabase-auth-helpers-to-ssr-package-5NRunM.mdx +++ b/apps/docs/content/troubleshooting/how-to-migrate-from-supabase-auth-helpers-to-ssr-package-5NRunM.mdx @@ -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 diff --git a/apps/docs/content/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd.mdx b/apps/docs/content/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd.mdx index 71d8384c1a6..4d62b30e58d 100644 --- a/apps/docs/content/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd.mdx +++ b/apps/docs/content/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd.mdx @@ -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: diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index 264292b1928..6e6ccca207f 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -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, diff --git a/apps/docs/features/docs/NavigationPageStatus.utils.ts b/apps/docs/features/docs/NavigationPageStatus.utils.ts index 6968e1e23aa..f8e10c42c84 100644 --- a/apps/docs/features/docs/NavigationPageStatus.utils.ts +++ b/apps/docs/features/docs/NavigationPageStatus.utils.ts @@ -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 = { 'getting-started': gettingstarted, graphql, integrations, + 'ai-tools': ai_tools, 'local-development': local_development, platform, queues, diff --git a/apps/docs/features/docs/Troubleshooting.utils.common.mjs b/apps/docs/features/docs/Troubleshooting.utils.common.mjs index 8984b753282..c938dced206 100644 --- a/apps/docs/features/docs/Troubleshooting.utils.common.mjs +++ b/apps/docs/features/docs/Troubleshooting.utils.common.mjs @@ -60,6 +60,7 @@ export const TroubleshootingSchema = z topics: z.array( z.enum([ 'ai', + 'ai-tools', 'auth', 'branching', 'cli', diff --git a/apps/docs/package.json b/apps/docs/package.json index 78f80fcb0e4..5b9073aeccd 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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:*", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 49be8cdf0b5..f035b189420 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -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 diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index 51c5582ea39..6ad1debd533 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -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( + const { data: targetTable, isPending: isLoadingTargetTable } = useTableQuery( { projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx index 0d7e1043991..ace88f95002 100644 --- a/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx +++ b/apps/studio/components/grid/components/formatter/ReferenceRecordPeek.tsx @@ -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 } diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 0e014d5160c..f6df2b27017 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -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, }) } } diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx index 04d1f5a9285..1ea719f27b3 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeyPill.tsx @@ -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 }) { - 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 ? ( <> {apiKey?.api_key.slice(0, 15)} - {show && data?.api_key ? data?.api_key.slice(15) : '••••••••••••••••'} + {show && revealedKey ? revealedKey.slice(15) : '••••••••••••••••'} ) : ( @@ -141,7 +115,7 @@ export function ApiKeyPill({ className="rounded-full px-2 pointer-events-auto" loading={show && isLoading} icon={show ? : } - onClick={onSubmitToggle} + onClick={onToggleShow} disabled={isRestricted} /> diff --git a/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts b/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts new file mode 100644 index 00000000000..5af3b323928 --- /dev/null +++ b/apps/studio/components/interfaces/APIKeys/useRevealedSecret.ts @@ -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() + 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 } +} diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index b59ad529fab..22a4cb613c1 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -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<{}>) => { {showNoticeBanner && } {showNoticeBanner2 && } + {/* Disabled until reintroduced or removed altogether. */} {/* */} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index ea7ac88e49e..5cbbdfc4e3a 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -28,7 +28,7 @@ const FeaturePreviewContext = createContext({ 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) diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index b438fc92ecc..3420fb92201 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -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, diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.types.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.types.ts index 5b01fea84f7..911fb6426fc 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.types.ts +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.types.ts @@ -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 +} diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts index fe78ee7e615..e785b11c2b9 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.test.ts @@ -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'], }, diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts index 13ad83f6b83..d98c9322558 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.utils.ts @@ -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 > & - Pick & { + Pick & { + 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 & { + 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( + (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 [] diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx index 2f43e92d505..ba98ee0bdfb 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/index.tsx @@ -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 @@ -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) } diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx index 6f5d5aa5a0b..fea81efb341 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/LockedQuerySection.tsx @@ -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 } } diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts index f4a77e9c52e..f0341e8a06b 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanel.utils.ts @@ -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[] diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx index 4ac4b166872..52967a692ac 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyEditorPanelHeader.tsx @@ -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 }) => { diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx index b5f7afb781e..57655955d15 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyTemplates.tsx @@ -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 } diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts index d563e081ea3..5dcf3033906 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils.ts @@ -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 & { +export type Policy = Omit & { definition: SafeSqlFragment | null check: SafeSqlFragment | null } diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx index e57f1221b14..42a243b2a14 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx +++ b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx @@ -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)
- - + {!disabled && ( @@ -176,7 +177,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps )} - +
-
- + + ) } diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 3ea45e0170b..450ea08914a 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -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(null) + const [selectedTable, setSelectedTable] = useState(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) diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts index 50eea3ca24e..7089fae8093 100644 --- a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts +++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts @@ -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[] edges: Edge[] @@ -166,7 +166,7 @@ export async function getGraphDataFromTables( } function findTablesHandleIds( - tables: PostgresTable[], + tables: PGTable[], table_name: string, column_name: string ): [string?, string?] { diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index 9b3bdacfe2d..b282284b55c 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -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 = ({ {column.data_type} {column.format !== column.data_type && ( - {column.format} + {displayColumnType( + column.format, + column.format_schema, + column.data_type === 'ARRAY' + )} )} @@ -343,7 +348,13 @@ export const ColumnList = ({ -

{column.format}

+

+ {displayColumnType( + column.format, + column.format_schema, + column.data_type === 'ARRAY' + )} +

{constraintTokens}
diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 47cbd65cf83..9482d574220 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -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 = ({ diff --git a/apps/studio/components/interfaces/Database/Tables/Tables.utils.ts b/apps/studio/components/interfaces/Database/Tables/Tables.utils.ts index 2e032162c76..ba680101425 100644 --- a/apps/studio/components/interfaces/Database/Tables/Tables.utils.ts +++ b/apps/studio/components/interfaces/Database/Tables/Tables.utils.ts @@ -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 { diff --git a/apps/studio/components/interfaces/Database/Triggers/DeleteTrigger.tsx b/apps/studio/components/interfaces/Database/Triggers/DeleteTrigger.tsx index 7126cc2f5e8..37050c66e91 100644 --- a/apps/studio/components/interfaces/Database/Triggers/DeleteTrigger.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/DeleteTrigger.tsx @@ -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 } diff --git a/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggerList.tsx index eee7c8df88e..64a25391311 100644 --- a/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggerList.tsx @@ -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 = ({ {trigger.function_name ? ( {trigger.function_name} diff --git a/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList.tsx index 5f78536a7ea..b275b18b842 100644 --- a/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/EventTriggersList/EventTriggersList.tsx @@ -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 = () => {
- } - value={filterString} - className="w-full lg:w-64" - onChange={(e) => setFilterString(e.target.value)} - onKeyDown={onSearchInputEscape(filterString, setFilterString)} - /> + + setFilterString(e.target.value)} + onKeyDown={onSearchInputEscape(filterString, setFilterString)} + /> + + + + ({ diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx index 1d3caaadbdf..7b722bbaf64 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx @@ -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 = { } interface TriggerSheetProps { - selectedTrigger?: PostgresTrigger + selectedTrigger?: PGTrigger isDuplicatingTrigger?: boolean open: boolean onClose: () => void diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index 78672168922..6d73d5fabd9 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -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 {x.function_name ? ( {x.function_name} diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.test.ts b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.test.ts new file mode 100644 index 00000000000..3b9ecfa0d3c --- /dev/null +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.test.ts @@ -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=' + ) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.ts b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.ts index 19398130527..7c0a33ad330 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.ts +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.utils.ts @@ -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 ?? '')}` +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 76c075fbf7e..f105c7d6e6d 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { ChevronRight } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' import { CloudProvider } from 'shared-data' import { toast } from 'sonner' @@ -13,6 +13,7 @@ import { Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + DialogSectionSeparator, Form, Separator, } from 'ui' @@ -77,6 +78,8 @@ export function DiskManagementForm() { const { data: org } = useSelectedOrganizationQuery() const { setProjectStatus } = useSetProjectStatus() + const advancedSettingsRef = useRef(null) + const isSpendCapEnabled = org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY' @@ -163,43 +166,8 @@ export function DiskManagementForm() { reValidateMode: 'onChange', }) - useEffect(() => { - if (!isDiskAttributesSuccess) return - // @ts-ignore - const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 } - const formValues = { - storageType: type, - provisionedIOPS: iops, - throughput: throughput_mbps, - totalSize: size_gb, - computeSize: form.getValues('computeSize'), - } - - if (!('requested_modification' in data)) { - if (refetchInterval !== false) { - form.reset(formValues) - setRefetchInterval(false) - toast.success('Disk configuration changes have been successfully applied!') - } - } else { - setRefetchInterval(2000) - } - }, [data, isDiskAttributesSuccess, form, refetchInterval]) - const { computeSize: modifiedComputeSize } = form.watch() - // We only support disk configurations for >=Large instances - // If a customer downgrades back to { - if (modifiedComputeSize && project?.infra_compute_size && isDialogOpen) { - if (RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)) { - form.setValue('storageType', DiskType.GP3) - form.setValue('throughput', DISK_LIMITS['gp3'].minThroughput) - form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops) - } - } - }, [modifiedComputeSize, isDialogOpen, project]) - const isSuccess = isAddonsSuccess && isDiskAttributesSuccess && @@ -213,6 +181,7 @@ export function DiskManagementForm() { const isPlanUpgradeRequired = !hasAccess const { formState } = form + const errors = formState.errors const usedSize = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 const totalSize = formState.defaultValues?.totalSize || 0 const usedPercentage = (usedSize / totalSize) * 100 @@ -328,6 +297,41 @@ export function DiskManagementForm() { } } + useEffect(() => { + if (!isDiskAttributesSuccess) return + // @ts-ignore + const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 } + const formValues = { + storageType: type, + provisionedIOPS: iops, + throughput: throughput_mbps, + totalSize: size_gb, + computeSize: form.getValues('computeSize'), + } + + if (!('requested_modification' in data)) { + if (refetchInterval !== false) { + form.reset(formValues) + setRefetchInterval(false) + toast.success('Disk configuration changes have been successfully applied!') + } + } else { + setRefetchInterval(2000) + } + }, [data, isDiskAttributesSuccess, form, refetchInterval]) + + // We only support disk configurations for >=Large instances + // If a customer downgrades back to { + if (modifiedComputeSize && project?.infra_compute_size && isDialogOpen) { + if (RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(modifiedComputeSize)) { + form.setValue('storageType', DiskType.GP3) + form.setValue('throughput', DISK_LIMITS['gp3'].minThroughput) + form.setValue('provisionedIOPS', DISK_LIMITS['gp3'].minIops) + } + } + }, [modifiedComputeSize, isDialogOpen, project]) + useEffect(() => { // Initialize field values properly when data has been loaded, preserving any user changes if (isDiskAttributesSuccess || isSuccess) { @@ -336,6 +340,26 @@ export function DiskManagementForm() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSuccess, isDiskAttributesSuccess]) + useEffect(() => { + const fieldErrors = Object.keys(errors) + if (fieldErrors.length > 0) { + if ( + fieldErrors.includes('throughput') || + fieldErrors.includes('provisionedIOPS') || + fieldErrors.includes('maxSizeGb') + ) { + setAdvancedSettingsOpenState(true) + + // [Joshen] The timeout is to let the collapsible open prior to scrolling + const timeoutId = setTimeout(() => { + advancedSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 100) + + return () => clearTimeout(timeoutId) + } + } + }, [errors]) + return ( <> @@ -447,8 +471,6 @@ export function DiskManagementForm() { setAdvancedSettingsOpenState((prev) => !prev)} > @@ -467,16 +489,17 @@ export function DiskManagementForm() { />
- +
{ }) }) + it('should return a sql function command for lowercase select', () => { + const command = 'select lowercase.issue ()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_function', + schema: 'lowercase', + functionName: 'issue', + snippet: command, + }) + }) + it('should return a sql snippet command when the command is SELECT public.test_fn(1, 2)', () => { const command = 'SELECT public.test_fn(1, 2)' expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index b56d313668a..522b0bb71d5 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -135,7 +135,7 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string) const regexDBFunction = /select\s+[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\s*\(\)/g if (command.toLocaleLowerCase().match(regexDBFunction)) { const [schemaName, functionName] = command - .replace('SELECT ', '') + .replace(/^select\s+/i, '') .replace(/\(.*\);*/, '') .trim() diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton.tsx index fb08a5e10c6..c2e5241fe42 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTabV2/InstallIntegrationSheet/InstallOAuthIntegrationButton.tsx @@ -6,6 +6,7 @@ import { Button } from 'ui' import type { IntegrationDefinition } from '@/components/interfaces/Integrations/Landing/Integrations.constants' import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query' import { useInstallOAuthIntegrationMutation } from '@/data/marketplace/install-oauth-integration-mutation' +import { useSecretsQuery } from '@/data/secrets/secrets-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' interface InstallOAuthIntegrationButtonProps { @@ -16,9 +17,21 @@ export function InstallOAuthIntegrationButton({ integration }: InstallOAuthInteg const { ref: projectRef } = useParams() const { data: selectedOrg } = useSelectedOrganizationQuery() + const requiresApiKeysCheck = + integration.installIdentificationMethod === 'secret_key_prefix' && !!integration.secretKeyPrefix + + const requiresEdgeFunctionSecretsCheck = + integration.installIdentificationMethod === 'edge_function_secret_name' && + !!integration.edgeFunctionSecretName + const { data: apiKeys, isLoading: isApiKeysLoading } = useAPIKeysQuery( { projectRef, reveal: false }, - { enabled: !!projectRef } + { enabled: requiresApiKeysCheck } + ) + + const { data: edgeFunctionSecrets, isPending: isEdgeFunctionSecretsLoading } = useSecretsQuery( + { projectRef }, + { enabled: requiresEdgeFunctionSecretsCheck } ) const { mutate: installOAuthIntegration, isPending: isInstalling } = @@ -37,18 +50,26 @@ export function InstallOAuthIntegrationButton({ integration }: InstallOAuthInteg }) const isLoading = - integration.installIdentificationMethod === 'secret_key_prefix' && isApiKeysLoading + (requiresApiKeysCheck && isApiKeysLoading) || + (requiresEdgeFunctionSecretsCheck && isEdgeFunctionSecretsLoading) const isIntegrationInstalled = useMemo(() => { if (!integration) return false - const prefix = integration.secretKeyPrefix + if (integration.installIdentificationMethod === 'secret_key_prefix') { + const prefix = integration.secretKeyPrefix + if (!prefix || isApiKeysLoading || !apiKeys) return false + return apiKeys.some((k) => k.type === 'secret' && k.name.startsWith(prefix)) + } - if (integration.installIdentificationMethod !== 'secret_key_prefix' || !prefix) return false - if (isApiKeysLoading || !apiKeys) return false + if (integration.installIdentificationMethod === 'edge_function_secret_name') { + const secretName = integration.edgeFunctionSecretName + if (!secretName || isEdgeFunctionSecretsLoading || !edgeFunctionSecrets) return false + return edgeFunctionSecrets.some((secret) => secret.name === secretName) + } - return apiKeys.some((k) => k.type === 'secret' && k.name.startsWith(prefix)) - }, [apiKeys, integration, isApiKeysLoading]) + return false + }, [apiKeys, edgeFunctionSecrets, integration, isApiKeysLoading, isEdgeFunctionSecretsLoading]) const handleInstallClick = async () => { if (!integration || !projectRef) return diff --git a/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx index 4bde37f6017..c5463073d32 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx @@ -97,9 +97,9 @@ export const AvailableIntegrations = () => {
{isLoading && - new Array(3) - .fill(0) - .map((_, idx) => )} + Array.from({ length: 3 }).map((_, idx) => ( + + ))} {isError && ( +type InstallUrlType = NonNullable +type InstallIdentificationMethod = NonNullable /** * [Joshen] For marketplace, we probably need to revisit this definition @@ -59,6 +60,7 @@ export type IntegrationDefinition = { name: string status?: 'alpha' | 'beta' categories?: string[] + featured?: boolean icon: (props?: { className?: string; style?: Record }) => ReactNode description: string | null content?: string | null @@ -107,6 +109,7 @@ export type IntegrationDefinition = { installUrlType?: InstallUrlType installIdentificationMethod?: InstallIdentificationMethod secretKeyPrefix?: string + edgeFunctionSecretName?: string listingId?: string } & ( | { type: 'wrapper'; meta: WrapperMeta } diff --git a/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts new file mode 100644 index 00000000000..2fc1c9fe088 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Landing/Landing.utils.ts @@ -0,0 +1,63 @@ +import { parseSchemaComment } from 'stripe-experiment-sync/supabase' + +import { type WrapperMeta } from '../Wrappers/Wrappers.types' +import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils' +import { type IntegrationDefinition } from './Integrations.constants' +import { + isInstalled as checkIsInstalled, + findStripeSchema, +} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status' +import { type APIKey } from '@/data/api-keys/api-keys-query' +import { type DatabaseExtension } from '@/data/database-extensions/database-extensions-query' +import { type Schema } from '@/data/database/schemas-query' +import { type FDW } from '@/data/fdw/fdws-query' +import { type ProjectSecret } from '@/data/secrets/secrets-query' + +export const isStripeSyncEngineInstalled = (schemas: Schema[]) => { + const stripeSchema = findStripeSchema(schemas) + const parsedSchema = parseSchemaComment(stripeSchema?.comment) + return checkIsInstalled(parsedSchema.status) +} + +export const isOAuthInstalled = ({ + integration, + apiKeys, + secrets, +}: { + integration: IntegrationDefinition + apiKeys: APIKey[] + secrets: ProjectSecret[] +}) => { + if (integration.installIdentificationMethod === 'secret_key_prefix') { + const prefix = integration.secretKeyPrefix + if (!prefix) return false + + return apiKeys.some((key) => key.type === 'secret' && key.name.startsWith(prefix)) + } + + if (integration.installIdentificationMethod === 'edge_function_secret_name') { + const secretName = integration.edgeFunctionSecretName + if (!secretName) return false + + return secrets.some((secret) => secret.name === secretName) + } + + return false +} + +export const hasMatchingWrapper = ({ meta, wrappers }: { meta: WrapperMeta; wrappers: FDW[] }) => { + return wrappers.find((w) => wrapperMetaComparator(meta, w)) +} + +export const hasRequiredExtensions = ({ + integration, + extensions, +}: { + integration: IntegrationDefinition + extensions: DatabaseExtension[] +}) => { + return integration.requiredExtensions.every((extName) => { + const foundExtension = extensions.find((ext) => ext.name === extName) + return !!foundExtension?.installed_version + }) +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx index f65a769b7c4..1b62826f164 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useAvailableIntegrations.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { FeatureFlagContext, IS_PLATFORM, useFlag } from 'common' +import { fullImageUrl } from 'common/marketplace-client' import { Boxes } from 'lucide-react' import dynamic from 'next/dynamic' import Image from 'next/image' @@ -11,11 +12,6 @@ import { marketplaceIntegrationsQueryOptions } from '@/data/marketplace/integrat import { useCLIReleaseVersionQuery } from '@/data/misc/cli-release-version-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -const fullImageUrl = (imagePath: string) => { - const API_URL = process.env.NEXT_PUBLIC_MARKETPLACE_API_URL || '' - return `${API_URL}${imagePath}` -} - /** * [Joshen] Returns a combination of * - Marketplace integrations retrieved remotely (Only if feature flag enabled) @@ -46,6 +42,7 @@ export const useAvailableIntegrations = () => { id: listingId, slug, categories, + featured, title, description, documentation_url: docsUrl, @@ -54,6 +51,7 @@ export const useAvailableIntegrations = () => { installation_url_type: installUrlType, installation_identification_method: installMethod, secret_key_prefix: secretKeyPrefix, + edge_function_secret_name: edgeFunctionSecretName, images, content, partner_name: authorName, @@ -67,6 +65,7 @@ export const useAvailableIntegrations = () => { id: slug ?? '', name: title ?? '', status, + featured: !!featured, type: 'oauth' as const, // Currently marketplace only supports oauth apps categories: Array.isArray(categories) ? (categories as Array<{ slug: string }>).map((x) => x.slug) @@ -80,6 +79,7 @@ export const useAvailableIntegrations = () => { installUrlType: installUrlType ?? undefined, installIdentificationMethod: installMethod ?? undefined, secretKeyPrefix: secretKeyPrefix ?? undefined, + edgeFunctionSecretName: edgeFunctionSecretName ?? undefined, listingId: listingId ?? undefined, author, requiredExtensions: [], diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx index 026c54e41fa..9c1326e3601 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -1,21 +1,23 @@ import { useMemo } from 'react' -import { parseSchemaComment } from 'stripe-experiment-sync/supabase' -import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils' -import { useAvailableIntegrations } from './useAvailableIntegrations' import { - isInstalled as checkIsInstalled, - findStripeSchema, -} from '@/components/interfaces/Integrations/templates/StripeSyncEngine/stripe-sync-status' + hasMatchingWrapper, + hasRequiredExtensions, + isOAuthInstalled, + isStripeSyncEngineInstalled, +} from './Landing.utils' +import { useAvailableIntegrations } from './useAvailableIntegrations' import { useAPIKeysQuery } from '@/data/api-keys/api-keys-query' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSchemasQuery } from '@/data/database/schemas-query' import { useFDWsQuery } from '@/data/fdw/fdws-query' +import { useSecretsQuery } from '@/data/secrets/secrets-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { EMPTY_ARR } from '@/lib/void' export const useInstalledIntegrations = () => { const { data: project } = useSelectedProjectQuery() + const { data: allIntegrations = EMPTY_ARR, error: availableIntegrationsError, @@ -33,19 +35,39 @@ export const useInstalledIntegrations = () => { ) }, [allIntegrations]) + const hasEdgeFunctionSecretNameIntegration = useMemo(() => { + return allIntegrations.some( + (integration) => + integration.type === 'oauth' && + integration.installIdentificationMethod === 'edge_function_secret_name' && + !!integration.edgeFunctionSecretName + ) + }, [allIntegrations]) + const { - data: apiKeys, + data: apiKeys = EMPTY_ARR, error: apiKeysError, isError: isErrorApiKeys, isLoading: isApiKeysLoading, isSuccess: isSuccessApiKeys, } = useAPIKeysQuery( { projectRef: project?.ref, reveal: false }, - { enabled: !!project?.ref && hasSecretKeyPrefixIntegration } + { enabled: hasSecretKeyPrefixIntegration } ) const { - data, + data: edgeFunctionSecrets = EMPTY_ARR, + error: edgeFunctionSecretsError, + isError: isErrorEdgeFunctionSecrets, + isLoading: isEdgeFunctionSecretsLoading, + isSuccess: isSuccessEdgeFunctionSecrets, + } = useSecretsQuery( + { projectRef: project?.ref }, + { enabled: hasEdgeFunctionSecretNameIntegration } + ) + + const { + data: wrappers = EMPTY_ARR, error: fdwError, isError: isErrorFDWs, isPending: isFDWLoading, @@ -55,7 +77,7 @@ export const useInstalledIntegrations = () => { connectionString: project?.connectionString, }) const { - data: extensions, + data: extensions = EMPTY_ARR, error: extensionsError, isError: isErrorExtensions, isPending: isExtensionsLoading, @@ -66,7 +88,7 @@ export const useInstalledIntegrations = () => { }) const { - data: schemas, + data: schemas = EMPTY_ARR, error: schemasError, isError: isErrorSchemas, isPending: isSchemasLoading, @@ -77,70 +99,57 @@ export const useInstalledIntegrations = () => { }) const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions') - const wrappers = useMemo(() => data ?? EMPTY_ARR, [data]) const installedIntegrations = useMemo(() => { return allIntegrations .filter((integration) => { - // special handling for supabase webhooks - if (integration.id === 'webhooks') { - return isHooksEnabled - } - if (integration.id === 'data_api') { - return true - } + if (integration.id === 'webhooks') return isHooksEnabled + if (integration.id === 'data_api') return true if (integration.id === 'stripe_sync_engine') { - const stripeSchema = findStripeSchema(schemas) - const parsedSchema = parseSchemaComment(stripeSchema?.comment) - return checkIsInstalled(parsedSchema.status) + return isStripeSyncEngineInstalled(schemas) } if (integration.type === 'wrapper') { - return wrappers.find((w) => wrapperMetaComparator(integration.meta, w)) + return hasMatchingWrapper({ meta: integration.meta, wrappers }) } if (integration.type === 'postgres_extension') { - return integration.requiredExtensions.every((extName) => { - const foundExtension = (extensions ?? []).find((ext) => ext.name === extName) - return !!foundExtension?.installed_version - }) + return hasRequiredExtensions({ integration, extensions }) } if (integration.type === 'oauth') { - const prefix = integration.secretKeyPrefix - - if (integration.installIdentificationMethod !== 'secret_key_prefix' || !prefix) { - return false - } - - return (apiKeys ?? []).some((key) => key.type === 'secret' && key.name.startsWith(prefix)) + return isOAuthInstalled({ integration, apiKeys, secrets: edgeFunctionSecrets }) } return false }) .sort((a, b) => a.name.localeCompare(b.name)) - }, [allIntegrations, wrappers, extensions, schemas, isHooksEnabled, apiKeys]) + }, [allIntegrations, wrappers, extensions, schemas, isHooksEnabled, apiKeys, edgeFunctionSecrets]) const error = fdwError || extensionsError || schemasError || availableIntegrationsError || - (hasSecretKeyPrefixIntegration ? apiKeysError : null) + (hasSecretKeyPrefixIntegration ? apiKeysError : null) || + (hasEdgeFunctionSecretNameIntegration ? edgeFunctionSecretsError : null) const isLoading = isSchemasLoading || isFDWLoading || isExtensionsLoading || isAvailableIntegrationsLoading || - (hasSecretKeyPrefixIntegration && isApiKeysLoading) + (hasSecretKeyPrefixIntegration && isApiKeysLoading) || + (hasEdgeFunctionSecretNameIntegration && isEdgeFunctionSecretsLoading) const isError = isErrorFDWs || isErrorExtensions || isErrorSchemas || isErrorAvailableIntegrations || - (hasSecretKeyPrefixIntegration && isErrorApiKeys) + (hasSecretKeyPrefixIntegration && isErrorApiKeys) || + (hasEdgeFunctionSecretNameIntegration && isErrorEdgeFunctionSecrets) const isSuccess = isSuccessFDWs && isSuccessExtensions && isSuccessSchemas && isSuccessAvailableIntegrations && - (!hasSecretKeyPrefixIntegration || isSuccessApiKeys) + (!hasSecretKeyPrefixIntegration || isSuccessApiKeys) && + (!hasEdgeFunctionSecretNameIntegration || isSuccessEdgeFunctionSecrets) return { // show all integrations at once instead of showing partial results diff --git a/apps/studio/components/interfaces/LocalDropdown.tsx b/apps/studio/components/interfaces/LocalDropdown.tsx index de4f1ea3bf1..315ad590d7c 100644 --- a/apps/studio/components/interfaces/LocalDropdown.tsx +++ b/apps/studio/components/interfaces/LocalDropdown.tsx @@ -49,6 +49,7 @@ export const LocalDropdown = ({ tooltip={{ content: { text: 'Settings' } }} > + Settings diff --git a/apps/studio/components/interfaces/LogDrains/AnimatedLogos.tsx b/apps/studio/components/interfaces/LogDrains/AnimatedLogos.tsx index edcee81935b..1242885bb16 100644 --- a/apps/studio/components/interfaces/LogDrains/AnimatedLogos.tsx +++ b/apps/studio/components/interfaces/LogDrains/AnimatedLogos.tsx @@ -1,31 +1,35 @@ import { AnimatePresence, motion } from 'framer-motion' -import { Datadog, Grafana, Sentry } from 'icons' -import { BracesIcon } from 'lucide-react' +import { Axiom, Datadog, Grafana, Last9, Otlp, Sentry } from 'icons' +import { BracesIcon, Cloud, Server } from 'lucide-react' import { useEffect, useState } from 'react' import { cn } from 'ui' -export const AnimatedLogos = () => { +interface AnimatedLogosProps { + iconSize?: number + className?: string +} + +export const AnimatedLogos = ({ iconSize = 36, className }: AnimatedLogosProps) => { const [currIndex, setCurrIndex] = useState(0) const timer = 2500 - const iconSize = 36 + + const centerWrapperSize = Math.round(iconSize * 2.67) + const sideWrapperSize = Math.round(iconSize * 2.22) + const containerW = Math.round(iconSize * 5.33) + const containerH = Math.round(iconSize * 3.56) + const sideOffset = Math.round(iconSize * 2.22) + const hiddenOffset = Math.round(iconSize * 3.33) const logos = [ - { - id: 'datadog', - name: 'Datadog', - icon: , - }, - { - id: 'loki', - name: 'Loki', - icon: , - }, - { id: 'https', name: 'HTTPS', icon: }, - { - id: 'sentry', - name: 'Sentry', - icon: , - }, + { id: 'webhook', name: 'Custom Endpoint', icon: }, + { id: 'otlp', name: 'OTLP', icon: }, + { id: 'datadog', name: 'Datadog', icon: }, + { id: 'loki', name: 'Loki', icon: }, + { id: 's3', name: 'Amazon S3', icon: }, + { id: 'sentry', name: 'Sentry', icon: }, + { id: 'axiom', name: 'Axiom', icon: }, + { id: 'last9', name: 'Last9', icon: }, + { id: 'syslog', name: 'Syslog', icon: }, ] useEffect(() => { @@ -47,14 +51,14 @@ export const AnimatedLogos = () => { const logoVariants = { hidden: { - x: 'calc(-50% + 120px)', + x: `calc(-50% + ${hiddenOffset}px)`, y: '-50%', scale: 0.6, opacity: 0, filter: 'blur(1px)', }, right: { - x: 'calc(-50% + 80px)', + x: `calc(-50% + ${sideOffset}px)`, y: '-50%', scale: 0.8, opacity: 0.5, @@ -70,7 +74,7 @@ export const AnimatedLogos = () => { filter: 'blur(0px)', }, left: { - x: 'calc(-50% - 80px)', + x: `calc(-50% - ${sideOffset}px)`, y: '-50%', scale: 0.8, opacity: 0.5, @@ -78,7 +82,7 @@ export const AnimatedLogos = () => { filter: 'blur(1px)', }, exit: { - x: 'calc(-50% - 120px)', + x: `calc(-50% - ${hiddenOffset}px)`, y: '-50%', scale: 0.6, opacity: 0, @@ -89,21 +93,23 @@ export const AnimatedLogos = () => { const visibleIndices = [getPreviousIndex(), currIndex, getNextIndex()] return ( -
+
{logos.map((logo, index) => { if (!visibleIndices.includes(index)) return null const position = getPosition(index) const isCenter = index === currIndex + const wrapperSize = isCenter ? centerWrapperSize : sideWrapperSize return ( { ) : ( <> - Your upcoming invoice (excluding credits) will continue to update until the end of - your billing cycle on {billingCycleEnd.format('MMMM DD')}. For a more detailed - breakdown, visit the{' '} + Your upcoming invoice will continue to update until the end of your billing cycle on{' '} + {billingCycleEnd.format('MMMM DD')}. For a more detailed breakdown, visit the{' '} usage page. )} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index 580cc1cbe61..653332de04d 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -1,4 +1,3 @@ -import Link from 'next/link' import React from 'react' import { Table, TableBody, TableCell, TableFooter, TableRow } from 'ui' import { InfoTooltip } from 'ui-patterns/info-tooltip' @@ -12,6 +11,7 @@ import { UpcomingInvoiceResponse, useOrgUpcomingInvoiceQuery, } from '@/data/invoices/org-invoice-upcoming-query' +import { useOrganizationQuery } from '@/data/organizations/organization-query' import { DOCS_URL } from '@/lib/constants' import { formatCurrency } from '@/lib/helpers' @@ -58,10 +58,15 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { isSuccess, } = useOrgUpcomingInvoiceQuery({ orgSlug: slug }) + const { data: organization } = useOrganizationQuery({ slug }) + // For non-platform customers, compute is broken down per project and contains a breakdown array const computeItems = - upcomingInvoice?.lines?.filter((item) => item.description?.toLowerCase().includes('compute')) || - [] + upcomingInvoice?.lines?.filter( + (item) => + item.description?.toLowerCase().includes('compute') && + !item.description.startsWith('Compute Credits') + ) || [] const computeCreditsItem = upcomingInvoice?.lines?.find((item) => item.description.startsWith('Compute Credits')) ?? null @@ -90,10 +95,16 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { ) .sort((a, b) => b.amount_before_discount - a.amount_before_discount) || [] + const prepaidCreditsItem = + upcomingInvoice?.lines?.find((item) => item.item_name === 'Prepaid Credits') ?? null + const hasTax = upcomingInvoice?.tax_status === 'calculated' && (upcomingInvoice?.tax?.tax_amount ?? 0) > 0 const taxFailed = upcomingInvoice?.tax_status === 'failed' + const planFeePaidInAdvance = + !planItem && upcomingInvoice?.fixed_fees_billing_mode === 'in_arrears' + return ( <> {isLoading && ( @@ -111,19 +122,21 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
- - {planItem?.description} - - {planItem == null ? ( - '-' - ) : ( - - )} - - + {planItem && !planFeePaidInAdvance && ( + + {planItem?.description} + + {!planItem ? ( + '-' + ) : ( + + )} + + + )} {/* Compute section */} { The first project is covered by Compute Credits. Additional projects incur compute costs starting at $10/month, independent of activity. See{' '} - + docs - + .

} @@ -155,12 +165,11 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { Each Read Replica is a dedicated database. You are charged for its resources: Compute, Disk Size, provisioned Disk IOPS, provisioned Disk Throughput, and IPv4. See{' '} - docs - + .

} @@ -219,6 +228,13 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { {item.description ?? 'Unknown'} {(sortedBreakdown.length > 0 || item.usage_metric !== null) && ( + {item.item_name === 'minimum_amount' && ( +

+ Minimum Fee - If your cost is below the minimum fee, you will be + charged the difference as a floor fee +

+ )} + {item.unit_price_desc && (

Pricing: {item.unit_price_desc} @@ -283,6 +299,20 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { + {prepaidCreditsItem && ( + + + {prepaidCreditsItem.item_name} + + Prepaid credits purchased upfront, applied automatically against your + invoice. Any remaining balance rolls over to the next billing cycle. + + + + {formatCurrency(prepaidCreditsItem.amount) ?? '-'} + + + )} Current Costs @@ -299,72 +329,66 @@ export const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { Projected Costs - - Projected costs at the end of the billing cycle. Includes predictable costs - for Compute Hours, IPv4, Custom Domain and Point-In-Time-Recovery, but no - costs for metrics like MAU, storage or function invocations. Final amounts - may vary depending on your usage. + +

+ Projected costs at the end of the billing cycle. Includes predictable + costs for Compute Hours, IPv4, Custom Domain and Point-In-Time-Recovery, + but no costs for metrics like MAU, storage or function invocations. Final + amounts may vary depending on your usage. +

+ + {hasTax && ( +
+
+ Subtotal + + {formatCurrency(upcomingInvoice.tax!.total_amount_excluding_tax)} + +
+
+ + Tax + {upcomingInvoice.tax?.tax_rate_percentage != null && + ` (${upcomingInvoice.tax.tax_rate_percentage}%)`} + + + {formatCurrency(upcomingInvoice.tax!.tax_amount)} + +
+

+ Estimated based on your organization's billing address. The final + amount may be adjusted at the end of the billing cycle. +

+
+ )} + + {taxFailed && ( +

+ We were unable to estimate tax for your organization. Please verify your + billing address in your organization settings. +

+ )}
{formatCurrency( hasTax - ? upcomingInvoice.tax!.total_amount_excluding_tax + ? upcomingInvoice.tax!.total_amount_including_tax : upcomingInvoice.amount_projected ) ?? '-'} )} - {hasTax && ( - <> - - -
- Projected Tax - - Estimated tax - {upcomingInvoice?.tax?.tax_rate_percentage != null && - ` at ${upcomingInvoice.tax.tax_rate_percentage}%`}{' '} - based on your organization's billing address. The final amount may be - adjusted at the end of the billing cycle. - -
-
- - {formatCurrency(upcomingInvoice?.tax?.tax_amount) ?? '-'} - -
- - - Projected Total - - Projected costs including applicable tax. The final amount may be adjusted - at the end of the billing cycle. - - - - {formatCurrency(upcomingInvoice?.tax!.total_amount_including_tax) ?? '-'} - - - - )} - - {taxFailed && ( - - -
- Projected Tax - - We were unable to estimate tax for your organization. Please verify your - billing address in your organization settings. - -
-
- -
- Could not be estimated -
+ {planFeePaidInAdvance && ( + + + Your {organization?.plan?.name && `${organization.plan.name} `}Plan fee for + this period has already been paid. This invoice will only reflect usage + charges. )} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index 13bf71bbfec..be588279c70 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -4,7 +4,8 @@ import { ExternalLink } from 'lucide-react' import { useTheme } from 'next-themes' import Image from 'next/image' import Link from 'next/link' -import { Alert, Alert_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' +import { Alert_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' @@ -118,23 +119,28 @@ const CostControl = ({}: CostControlProps) => { {isSuccess && !costControlDisabled && (
{['team', 'enterprise', 'platform'].includes(currentPlan?.id || '') ? ( - - {currentPlan?.name || ''} plan requires you to have spend cap off at all - times. Your projects will never become unresponsive. Only when your{' '} - - included usage - {' '} - is exceeded will you be charged for any additional usage. - + <> + + {currentPlan?.name || ''} plan requires you to have spend cap off at all + times. Your projects will never become unresponsive. Only when your{' '} + + included usage + {' '} + is exceeded will you be charged for any additional usage. + + } + /> + ) : (

If you need to go beyond the included quota, simply switch off your spend cap diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx index ca45f08f494..9517fa50f21 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx @@ -7,7 +7,14 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { pricing } from 'shared-data/pricing' import { toast } from 'sonner' -import { Button, cn, Collapsible, SidePanel } from 'ui' +import { + Button, + cn, + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + SidePanel, +} from 'ui' import { Admonition } from 'ui-patterns/admonition' import Table from '@/components/to-be-cleaned/Table' @@ -104,7 +111,7 @@ const SpendCapSidePanel = () => { onCancel={onClose} onConfirm={onConfirm} header={ -

+

Spend cap

{ ) })} /> - - + + {isFreePlan && ( {hasProjectsWithComputeDowngrade && ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx index 9babe1cb8dd..c329abba68b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx @@ -307,13 +307,14 @@ export const PlanUpdateSidePanel = () => { - ) : !canUpdateSubscription ? ( + ) : !canUpdateSubscription && !isDowngradeOption ? ( ) : ( { side: 'bottom', className: hasOrioleProjects ? 'w-96 text-center' : '', text: - subscription?.plan?.id === 'enterprise' || - subscription?.plan?.id === 'platform' - ? 'Reach out to us via support to update your plan' - : hasOrioleProjects - ? 'Your organization has projects that are using the OrioleDB extension which is only available on the Free plan. Remove all OrioleDB projects before changing your plan.' - : selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE - ? 'You cannot change the plan for an organization managed by AWS Marketplace' - : undefined, + !canUpdateSubscription && isDowngradeOption + ? "You need additional permissions to change your organization's plan" + : subscription?.plan?.id === 'enterprise' || + subscription?.plan?.id === 'platform' + ? 'Reach out to us via support to update your plan' + : hasOrioleProjects + ? 'Your organization has projects that are using the OrioleDB extension which is only available on the Free plan. Remove all OrioleDB projects before changing your plan.' + : selectedOrganization?.managed_by === + MANAGED_BY.AWS_MARKETPLACE + ? 'You cannot change the plan for an organization managed by AWS Marketplace' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index c24380673ed..30817aa61a7 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -1,8 +1,8 @@ import { PermissionAction, SupportCategories } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' import Link from 'next/link' -import { Alert, Button } from 'ui' -import { Admonition } from 'ui-patterns' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' @@ -95,22 +95,20 @@ const Subscription = () => { ) : projectUpdateDisabled ? ( - - We have temporarily disabled project and subscription changes - our - engineers are working on a fix. - + description="We have temporarily disabled project and subscription changes - our + engineers are working on a fix." + /> ) : ( - { > Contact support - , - ]} - > - Please contact us if you'd like to change your plan. - + + } + /> )} diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx index 26b4c287635..2559618c65a 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx @@ -94,7 +94,7 @@ export const OrganizationDetailsForm = () => { - + diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index dd4101c5e45..214702fd3f7 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -1,21 +1,31 @@ import { useIsLoggedIn, useParams } from 'common' -import { CheckSquare } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' +import type { ReactNode } from 'react' import { toast } from 'sonner' -import { Button, cn } from 'ui' -import { Admonition, GenericSkeletonLoader } from 'ui-patterns' +import { Button, Card, CardContent } from 'ui' +import { Admonition, ShimmeringLoader } from 'ui-patterns' +import { + getOrganizationInviteContent, + getOrganizationInviteStatus, +} from './OrganizationInvite.utils' import { OrganizationInviteError } from './OrganizationInviteError' +import { + InterstitialAccountRow, + InterstitialLayout, + SupabaseLogo, +} from '@/components/layouts/InterstitialLayout' import { useOrganizationAcceptInvitationMutation } from '@/data/organization-members/organization-invitation-accept-mutation' import { useOrganizationInvitationTokenQuery } from '@/data/organization-members/organization-invitation-token-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' -import { useProfile } from '@/lib/profile' +import { useProfile, useProfileNameAndPicture } from '@/lib/profile' export const OrganizationInvite = () => { const router = useRouter() const isLoggedIn = useIsLoggedIn() const { profile, isLoading: isLoadingProfile } = useProfile() + const { username, avatarUrl, primaryEmail } = useProfileNameAndPicture() const { slug, token } = useParams() const isSignUpEnabled = useIsFeatureEnabled('dashboard_auth:sign_up') @@ -31,16 +41,28 @@ export const OrganizationInvite = () => { { retry: false, refetchOnWindowFocus: false, - enabled: !!profile, + enabled: !!profile && !!slug && !!token, } ) - const hasError = - isErrorInvitation || - (isSuccessInvitation && (data.token_does_not_exist || data.expired_token || !data.email_match)) - const inviteIsNoLongerValid = - error?.code === 401 && error?.message.includes('Failed to retrieve organization') - - const organizationName = isSuccessInvitation ? data?.organization_name : 'An organization' + const inviteStatus = getOrganizationInviteStatus({ + data, + error, + isErrorInvitation, + isLoadingInvitation, + isLoadingProfile, + isLoggedIn, + isRouterReady: router.isReady, + isSuccessInvitation, + profileExists: !!profile, + }) + const isSignedOut = inviteStatus === 'signed-out' + const isInvitationLoading = inviteStatus === 'loading' + const inviteContent = getOrganizationInviteContent({ + data, + isSignUpEnabled, + status: inviteStatus, + }) + const hasError = ['wrong-account', 'expired', 'invalid', 'error'].includes(inviteStatus) const loginRedirectLink = `/sign-in?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}` const signupRedirectLink = `/sign-up?returnTo=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}` @@ -60,92 +82,107 @@ export const OrganizationInvite = () => { joinOrganization({ slug, token }) } - return ( -
( + } + title={ + isInvitationLoading ? ( + + ) : inviteContent.title ? ( + inviteContent.title + ) : undefined + } + description={ + isInvitationLoading ? ( + + ) : inviteContent.description ? ( + inviteContent.description + ) : undefined + } + titleClassName="text-xl" > - {!isLoggedIn || (!profile && !isLoadingProfile) ? ( - <> - -
- - {isSignUpEnabled && ( - - )} -
- - ) : isLoadingProfile || isLoadingInvitation ? ( -
- -
- ) : inviteIsNoLongerValid ? ( - <> - -
- -
- - ) : ( - <> -
-

You have been invited to join

-

{organizationName}

- {isSuccessInvitation && ( -

{`Organization slug: ${slug}`}

- )} -
-
-
- {hasError && ( - - )} - {isSuccessInvitation && !hasError && ( -
- - -
- )} +
{children}
+ + ) + + if (isSignedOut) { + return withLayout( +
+ + {isSignUpEnabled && ( + + )} +
+ ) + } + + if (isInvitationLoading) { + return withLayout( +
+ + + +
+ +
-
- - )} + + +
+ + +
+
+ ) + } + + if (inviteStatus === 'no-longer-valid') { + return withLayout( +
+ + +
+ ) + } + + if (hasError) { + return withLayout( + + ) + } + + return withLayout( +
+ + +
+ + +
) } diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts new file mode 100644 index 00000000000..308bc1e702a --- /dev/null +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.utils.ts @@ -0,0 +1,96 @@ +import type { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' +import type { ResponseError } from '@/types' + +type OrganizationInviteStatusVariables = { + data?: OrganizationInviteByToken + error?: ResponseError | null + isErrorInvitation: boolean + isLoadingInvitation: boolean + isLoadingProfile: boolean + isLoggedIn: boolean + isRouterReady: boolean + isSuccessInvitation: boolean + profileExists: boolean +} + +type OrganizationInviteContentVariables = { + data?: OrganizationInviteByToken + isSignUpEnabled: boolean + status: OrganizationInviteStatus +} + +export type OrganizationInviteStatus = + | 'signed-out' + | 'loading' + | 'ready' + | 'wrong-account' + | 'expired' + | 'invalid' + | 'no-longer-valid' + | 'error' + +export function getOrganizationInviteStatus({ + data, + error, + isErrorInvitation, + isLoadingInvitation, + isLoadingProfile, + isLoggedIn, + isRouterReady, + isSuccessInvitation, + profileExists, +}: OrganizationInviteStatusVariables): OrganizationInviteStatus { + const isSignedOut = !isLoggedIn || (!profileExists && !isLoadingProfile) + + if (isSignedOut) return 'signed-out' + if (isLoadingProfile || isLoadingInvitation || !isRouterReady) return 'loading' + + if (error?.code === 401 && error?.message.includes('Failed to retrieve organization')) { + return 'no-longer-valid' + } + + if ( + (isSuccessInvitation && !!data?.token_does_not_exist) || + (isErrorInvitation && error?.code === 404) + ) { + return 'invalid' + } + + if (isErrorInvitation) return 'error' + if (isSuccessInvitation && !!data?.expired_token) return 'expired' + if (isSuccessInvitation && !!data && !data.email_match) return 'wrong-account' + + return 'ready' +} + +export function getOrganizationInviteContent({ + data, + isSignUpEnabled, + status, +}: OrganizationInviteContentVariables) { + const signedOutDescription = `Sign in${ + isSignUpEnabled ? ' or create an account' : '' + } to view this invitation` + + if (status === 'signed-out') { + return { + title: 'View invitation', + description: signedOutDescription, + } + } + + if (status === 'ready') { + return { + title: `Join ${data?.organization_name ?? 'an organization'}`, + description: 'You have been invited to join this Supabase organization', + } + } + + if (status === 'wrong-account') return { title: 'Wrong account' } + if (status === 'expired') return { title: 'Invite expired' } + if (status === 'invalid') return { title: 'Invite invalid' } + if (status === 'no-longer-valid') return { title: 'Invite no longer available' } + if (status === 'error') return { title: 'Unable to load invitation' } + + return {} +} diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx index c4c7b1c699c..e009e3789b7 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInviteError.tsx @@ -1,7 +1,7 @@ import { useRouter } from 'next/router' -import { cn } from 'ui' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns' -import AlertError from '@/components/ui/AlertError' import { OrganizationInviteByToken } from '@/data/organization-members/organization-invitation-token-query' import { useSignOut } from '@/lib/auth' import { useProfile } from '@/lib/profile' @@ -11,58 +11,79 @@ interface OrganizationInviteError { data?: OrganizationInviteByToken error?: ResponseError | null isError: boolean + isInvalidInvite?: boolean } -export const OrganizationInviteError = ({ data, error, isError }: OrganizationInviteError) => { +export const OrganizationInviteError = ({ + data, + error, + isError, + isInvalidInvite, +}: OrganizationInviteError) => { const router = useRouter() const signOut = useSignOut() const { profile } = useProfile() - return ( -
- {isError ? ( - { + await signOut() + router.reload() + } + + if (isInvalidInvite) { + return ( + + ) + } + + if (isError) { + return ( + + ) + } + + if (!data?.email_match) { + return ( +
+ + You are signed in as{' '} + {profile.primary_email}. Sign + in with the email address that received this invite. + + ) : ( + 'Sign in with the email address that received this invite.' + ) + } /> - ) : !data?.email_match ? ( -
-

- Your email address {profile?.primary_email} does not match the email address this - invitation was sent to. -

-

- To accept this invitation, you will need to{' '} - { - await signOut() - router.reload() - }} - > - sign out - {' '} - and then sign in or create a new account using the same email address used in the - invitation. -

-
- ) : data.expired_token ? ( -
-

The invite token has expired.

-

- Please request a new one from the organization owner. -

-
- ) : ( -
-

The invite token is invalid.

-

- You could be logged in with the wrong account. Try copying and pasting the link from the - invite email, or ask the organization owner to invite you again. -

-
- )} -
+ +
+ ) + } + + if (data.expired_token) { + return ( + + ) + } + + return ( + ) } diff --git a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx index a8a9027d259..3d126e3914b 100644 --- a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx @@ -1,7 +1,13 @@ -import { CollapsibleContent, CollapsibleTrigger } from '@ui/components/shadcn/ui/collapsible' import { ChevronRight } from 'lucide-react' import { UseFormReturn } from 'react-hook-form' -import { Collapsible, FormControl, FormField, Input_Shadcn_ } from 'ui' +import { + Collapsible_Shadcn_, + CollapsibleContent_Shadcn_, + CollapsibleTrigger_Shadcn_, + FormControl, + FormField, + Input_Shadcn_, +} from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateProjectForm } from './ProjectCreation.schema' @@ -14,16 +20,16 @@ interface InternalOnlyConfigurationProps { export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationProps) => { return ( - - + + Internal-only Configuration - - + +

These settings are only applicable for local/staging projects

@@ -60,8 +66,8 @@ export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationPro )} />
- - + + ) } diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts index 14170d2bd59..1db7baf5732 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.constants.ts @@ -13,7 +13,7 @@ export const QUERY_PERFORMANCE_PRESET_MAP = { } as const export const QUERY_PERFORMANCE_COLUMNS = [ - { id: 'query', name: 'Query', description: undefined, minWidth: 500 }, + { id: 'query', name: 'Query', description: undefined, minWidth: 450 }, { id: 'prop_total_time', name: 'Time consumed', description: undefined, minWidth: 150 }, { id: 'calls', name: 'Calls', description: undefined, minWidth: 100 }, { id: 'max_time', name: 'Max time', description: undefined, minWidth: 100 }, diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 5115ebec593..5bfb6e69c73 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -187,11 +187,11 @@ export const QueryPerformanceGrid = ({
{onCurrentSelectQuery && ( diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx index 81fb051976e..eb3066e2ec3 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ChartBlock.tsx @@ -9,6 +9,7 @@ import { METRIC_THRESHOLDS } from './ReportBlock.constants' import { ReportBlockContainer } from './ReportBlockContainer' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { timestampFormatter } from '@/components/ui/Charts/Charts.utils' import NoDataPlaceholder from '@/components/ui/Charts/NoDataPlaceholder' import { checkHasNonPositiveValues, @@ -74,6 +75,13 @@ export const ChartBlock = ({ const logScale = useMemo(() => defaultLogScale, [defaultLogScale]) const [latestValue, setLatestValue] = useState() const formatChartDate = useFormatDateTime() + const formatTooltipDate = useCallback( + (value: string | number, format: string) => + /^\d{4}-\d{2}-\d{2}$/.test(String(value)) + ? timestampFormatter(String(value), format, true) + : formatChartDate(value, format), + [formatChartDate] + ) const databaseIdentifier = state.selectedDatabaseId @@ -318,7 +326,7 @@ export const ChartBlock = ({ formatChartDate(x as string | number, 'DD MMM YYYY')} + labelFormatter={(x) => formatTooltipDate(x as string | number, 'DD MMM YYYY')} /> } /> @@ -345,7 +353,7 @@ export const ChartBlock = ({ content={ formatChartDate(x as string | number, 'DD MMM YYYY')} + labelFormatter={(x) => formatTooltipDate(x as string | number, 'DD MMM YYYY')} /> } /> diff --git a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index 178a4e242bd..92cfc8e5f4b 100644 --- a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -9,7 +9,6 @@ import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button, - Collapsible, Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, @@ -179,9 +178,10 @@ export const TopApiRoutesRenderer = ( if (props.data.length === 0) return null return ( - + <>
Request @@ -207,11 +207,11 @@ export const TopApiRoutesRenderer = ( - + {datum.count} {props.data[0].avg !== undefined && ( - + {Number(datum.avg).toFixed(2)}ms )} @@ -223,22 +223,20 @@ export const TopApiRoutesRenderer = ( } /> - -
- -
-
- +
+ +
+ ) } diff --git a/apps/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx b/apps/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx index 7ae70a05698..db27d752f99 100644 --- a/apps/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx +++ b/apps/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx @@ -53,6 +53,7 @@ export const TopCacheMissesRenderer = ( <>

Top Cache Misses

Request @@ -73,7 +74,7 @@ export const TopCacheMissesRenderer = ( /> - + {datum.count} diff --git a/apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx b/apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx index e3581268501..c5fa970eed1 100644 --- a/apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx @@ -1,18 +1,16 @@ +import { useCallback, useEffect, useRef, type ReactNode } from 'react' import { - Button, - cn, - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, } from 'ui' -import { Admonition } from 'ui-patterns' -import { PotentialIssues } from './SQLEditor.types' -import { DOCS_URL } from '@/lib/constants' +import { type PotentialIssues } from './SQLEditor.types' interface RunQueryWarningModalProps { visible: boolean @@ -22,6 +20,17 @@ interface RunQueryWarningModalProps { onConfirmWithRLS?: () => void } +type WarningMessage = { + id: string + summary: ReactNode + description: ReactNode +} + +type MissingRLSTable = NonNullable[number] + +const getMissingRLSTableName = (table: MissingRLSTable) => + table.schema ? `${table.schema}.${table.tableName}` : table.tableName + export const RunQueryWarningModal = ({ visible, potentialIssues, @@ -38,124 +47,142 @@ export const RunQueryWarningModal = ({ const missingRLSTables = createTablesMissingRLS ?? [] const hasMissingRLS = missingRLSTables.length > 0 - const issueCount = - (hasDestructiveOperations ? 1 : 0) + - (hasUpdateWithoutWhere ? 1 : 0) + - (hasAlterDatabasePreventConnection ? 1 : 0) + - (hasMissingRLS ? 1 : 0) + const isConfirmingRef = useRef(false) + + useEffect(() => { + if (visible) { + isConfirmingRef.current = false + } + }, [visible]) + + const handleOpenChange = useCallback( + (open: boolean) => { + if (open) return + + if (isConfirmingRef.current) { + isConfirmingRef.current = false + return + } + + onCancel() + }, + [onCancel] + ) + + const handleConfirm = useCallback(() => { + isConfirmingRef.current = true + onConfirm() + }, [onConfirm]) + + const handleConfirmWithRLS = useCallback(() => { + if (!onConfirmWithRLS) return + + isConfirmingRef.current = true + onConfirmWithRLS() + }, [onConfirmWithRLS]) + + const warnings: WarningMessage[] = [] + + if (hasDestructiveOperations) { + warnings.push({ + id: 'destructive-operations', + summary: 'This query includes destructive operations', + description: 'It may permanently change or remove data, tables, schemas, or other objects.', + }) + } + + if (hasUpdateWithoutWhere) { + warnings.push({ + id: 'update-without-where', + summary: ( + <> + This query runs an UPDATE without a{' '} + WHERE clause + + ), + description: 'It may update every row in the target table.', + }) + } + + if (hasAlterDatabasePreventConnection) { + warnings.push({ + id: 'prevent-database-connections', + summary: 'This query may prevent new database connections', + description: + 'The dashboard may lose access until the setting is restored from a direct database connection.', + }) + } + + if (hasMissingRLS) { + const tableName = + missingRLSTables.length === 1 ? getMissingRLSTableName(missingRLSTables[0]) : undefined + + warnings.push({ + id: 'missing-rls', + summary: + missingRLSTables.length === 1 + ? 'This query creates a table without enabling Row Level Security' + : 'This query creates tables without enabling Row Level Security', + description: ( + <> + Clients using anon or authenticated keys may be able to access{' '} + {tableName ? {tableName} : 'these tables'}. + + ), + }) + } + + const canEnableRLS = hasMissingRLS && onConfirmWithRLS !== undefined + const confirmationCopy = canEnableRLS + ? warnings.length > 1 + ? 'Review each issue, then choose whether to enable Row Level Security before running this query.' + : 'Choose whether to enable Row Level Security before running this query.' + : 'Run this query only if you intend these changes and understand the risks.' + const title = warnings.length > 1 ? 'Potential issues detected' : 'Potential issue detected' return ( - { - if (!open) onCancel() - }} - > - - - - {`Potential issue${issueCount > 1 ? 's' : ''} detected with your query`} - - - Review the warnings below before running this query. - - - - 1 - ? 'The following potential issues have been detected:' - : 'The following potential issue has been detected:' - } - description="Ensure that these are intentional before executing this query" - className="border-x-0 rounded-none -mt-px" - /> - - -
-
    - {hasDestructiveOperations && ( -
  • - Query has destructive operations - - Make sure you are not accidentally removing something important. - -
  • - )} - {hasUpdateWithoutWhere && ( -
  • - Query uses update without a where clause - - Without a where clause, this could - update all rows in the table. - -
  • - )} - {hasAlterDatabasePreventConnection && ( -
  • - Query will prevent connections to your database - - The dashboard will no longer have access to your database, and you will need a - direct connection to your database to reconfigure this setting - -
  • - )} - {hasMissingRLS && ( -
  • - - {missingRLSTables.length === 1 - ? 'New table will not have Row Level Security enabled' - : 'New tables will not have Row Level Security enabled'} - - - Without RLS, any client using your project's anon or authenticated keys can read - and write to{' '} - {missingRLSTables.length === 1 ? ( - - {missingRLSTables[0].schema - ? `${missingRLSTables[0].schema}.${missingRLSTables[0].tableName}` - : missingRLSTables[0].tableName} - - ) : ( - 'these tables' - )} - . Enable RLS and add policies before exposing this table via the API.{' '} - - Learn more - - . - -
  • - )} -
-
-

- Please confirm that you would like to execute this query. -

-
- - - -
- - - {hasMissingRLS && onConfirmWithRLS && ( - + + + + {title} + + {warnings.length === 0 ? ( +
+

Are you sure you want to run this query?

+
+ ) : warnings.length === 1 ? ( +
+

+ {warnings[0].summary}. {warnings[0].description} +

+

{confirmationCopy}

+
+ ) : ( +
+

This query has multiple potential issues:

+
    + {warnings.map((warning) => ( +
  • + {warning.summary}.{' '} + {warning.description} +
  • + ))} +
+

{confirmationCopy}

+
+ )} +
+
+ + Cancel + + {canEnableRLS ? 'Run without RLS' : 'Run query'} + + {canEnableRLS && ( + Run and enable RLS )} -
-
-
+ + + ) } diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 404cee75745..ae8f3d56825 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -2,7 +2,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { Button, Card, CardContent, Input, Modal } from 'ui' +import { Button, Card, CardContent, Modal } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, PageSectionContent, @@ -142,14 +144,11 @@ const ResetDbPassword = ({ disabled = false }) => { onCancel={() => setShowResetDbPass(false)} > - 0} - onChange={onDbPassChange} + { generateStrongPassword={generatePassword} /> } - /> + > + 0} + aria-invalid={!!passwordStrengthWarning} + type="password" + placeholder="Type in a strong password" + value={password} + autoComplete="off" + onChange={onDbPassChange} + /> + diff --git a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index 3c79ccdba71..1a78a50ab25 100644 --- a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -5,7 +5,6 @@ import { Download, Loader2 } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { - Alert, AlertDialog, AlertDialogAction, AlertDialogCancel, @@ -23,14 +22,15 @@ import { TooltipContent, TooltipTrigger, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' +import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' import { PageSection, PageSectionContent, PageSectionMeta, PageSectionSummary, PageSectionTitle, -} from 'ui-patterns' -import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' +} from 'ui-patterns/PageSection' import { SupportLink } from '@/components/interfaces/Support/SupportLink' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -186,15 +186,18 @@ export const SSLConfiguration = () => { {isSuccess && !sslEnforcementConfiguration?.appliedSuccessfully && ( - - Please try updating again, or contact{' '} - support if this error - persists - + description={ + <> + Please try updating again, or contact{' '} + support if this error + persists + + } + /> )} diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx index a7a9edafbaa..2f05a0c7c9e 100644 --- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx +++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' import { TextArea_Shadcn_ as TextArea } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CANCELLATION_REASONS } from '@/components/interfaces/Billing/Billing.constants' import { LogicalBackupCliInstructions } from '@/components/layouts/ProjectLayout/LogicalBackupCliInstructions' @@ -141,17 +142,18 @@ export const DeleteProjectModal = ({ >
+ {/* [Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor but leaving that for the future. */} {!isFree && ( - <> -
-

What made you decide to delete your project?

-
-
+
+
{shuffledReasons.map((option) => { const active = selectedReason[0] === option.value @@ -181,19 +183,17 @@ export const DeleteProjectModal = ({ ) })}
-
- -