Merge branch 'master' into fix/sign-in-wrapper-not-expected-in-client

This commit is contained in:
Jonathan Summers-Muir authored and GitHub committed 2025-03-06 08:36:03 +00:00
commit 46162bfaad
219 files changed
+62285 -92065

No files matched your search

+2 -2
View File
@@ -36,8 +36,8 @@ To ensure a positive and inclusive environment, please read our [code of conduct
You will need to install and configure the following dependencies on your machine to build [Supabase](https://supabase.com):
- [Git](http://git-scm.com/)
- [Node.js v20.x (LTS)](http://nodejs.org)
- [Git](https://git-scm.com/)
- [Node.js v20.x (LTS)](https://nodejs.org)
- [pnpm](https://pnpm.io/) version 9.x.x or higher
- [make](https://www.gnu.org/software/make/) or the equivalent to `build-essentials` for your OS
- [Docker](https://docs.docker.com/get-docker/) (to run studio locally)
+22
View File
@@ -1864,6 +1864,28 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"toc-demo": {
name: "toc-demo",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/toc-demo")),
source: "",
files: ["registry/default/example/toc-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"toc-single-demo": {
name: "toc-single-demo",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/toc-single-demo")),
source: "",
files: ["registry/default/example/toc-single-demo.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"multi-select-demo": {
name: "multi-select-demo",
type: "components:example",
+5
View File
@@ -122,6 +122,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/logs-bar-chart',
items: [],
},
{
title: 'Table of Contents (TOC)',
href: '/docs/fragments/toc',
items: [],
},
],
},
{
@@ -0,0 +1,18 @@
---
title: Table of Contents (TOC)
description: List of page anchors for the current page.
component: true
fragment: true
---
## Usage
Highlight one or more elements of the TOC if in view.
<ComponentPreview name="toc-demo" description="" peekCode wide />
## Single=true
Highlight one element of the TOC at a time.
<ComponentPreview name="toc-single-demo" description="" peekCode wide />
+2 -1
View File
@@ -6,8 +6,9 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "next dev --port 3003",
"build": "pnpm run content:build && pnpm run build:registry && next build",
"build": "pnpm run content:build && pnpm run build:registry && pnpm run build:llms && next build",
"build:registry": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --log-level silent --write \"registry/**/*.{ts,tsx,mdx}\" --cache",
"build:llms": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-llms-txt.ts",
"start": "next start",
"lint": "next lint",
"content:build": "contentlayer2 build",
+154
View File
@@ -0,0 +1,154 @@
# Supabase Design System
Last updated: 2025-03-05T12:59:50.911Z
## Overview
The Design System used in Supabase Studio.
## Docs
- [Changelog](https://supabase-design-system.vercel.app/design-system/docs/changelog)
- Latest updates and announcements.
- [Color usage](https://supabase-design-system.vercel.app/design-system/docs/color-usage)
- Colors system breakdown with best practise
- [Accordion](https://supabase-design-system.vercel.app/design-system/docs/components/accordion)
- A vertically stacked set of interactive headings that each reveal a section of content.
- [Alert](https://supabase-design-system.vercel.app/design-system/docs/components/alert copy)
- Displays a callout for user attention.
- [Alert Dialog](https://supabase-design-system.vercel.app/design-system/docs/components/alert-dialog)
- A modal dialog that interrupts the user with important content and expects a response.
- [Alert](https://supabase-design-system.vercel.app/design-system/docs/components/alert)
- Displays a callout for user attention.
- [Aspect Ratio](https://supabase-design-system.vercel.app/design-system/docs/components/aspect-ratio)
- Displays content within a desired ratio.
- [Atom components](https://supabase-design-system.vercel.app/design-system/docs/components/atom-components)
- [Avatar](https://supabase-design-system.vercel.app/design-system/docs/components/avatar)
- An image element with a fallback for representing the user.
- [Badge](https://supabase-design-system.vercel.app/design-system/docs/components/badge)
- Displays a badge or a component that looks like a badge.
- [Breadcrumb](https://supabase-design-system.vercel.app/design-system/docs/components/breadcrumb)
- Displays the path to the current resource using a hierarchy of links.
- [Button](https://supabase-design-system.vercel.app/design-system/docs/components/button)
- Displays a button or a component that looks like a button.
- [Calendar](https://supabase-design-system.vercel.app/design-system/docs/components/calendar)
- A date field component that allows users to enter and edit date.
- [Card](https://supabase-design-system.vercel.app/design-system/docs/components/card)
- Displays a card with header, content, and footer.
- [Carousel](https://supabase-design-system.vercel.app/design-system/docs/components/carousel)
- A carousel with motion and swipe built using Embla.
- [Chart](https://supabase-design-system.vercel.app/design-system/docs/components/chart)
- Beautiful charts. Built using Recharts. Copy and paste into your apps.
- [Checkbox](https://supabase-design-system.vercel.app/design-system/docs/components/checkbox)
- A control that allows the user to toggle between checked and not checked.
- [Collapsible](https://supabase-design-system.vercel.app/design-system/docs/components/collapsible)
- An interactive component which expands/collapses a panel.
- [Combobox](https://supabase-design-system.vercel.app/design-system/docs/components/combobox)
- Autocomplete input and command palette with a list of suggestions.
- [Command](https://supabase-design-system.vercel.app/design-system/docs/components/command)
- Fast, composable, unstyled command menu for React.
- [Command Menu (cmdk)](https://supabase-design-system.vercel.app/design-system/docs/components/commandmenu)
- A central command menu that acts as a control center with searchable actions.
- [Context Menu](https://supabase-design-system.vercel.app/design-system/docs/components/context-menu)
- Displays a menu to the user — such as a set of actions or functions — triggered by a button.
- [Data Table](https://supabase-design-system.vercel.app/design-system/docs/components/data-table)
- Powerful table and datagrids built using TanStack Table.
- [Date Picker](https://supabase-design-system.vercel.app/design-system/docs/components/date-picker)
- A date picker component with range and presets.
- [Dialog](https://supabase-design-system.vercel.app/design-system/docs/components/dialog)
- A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.
- [Drawer](https://supabase-design-system.vercel.app/design-system/docs/components/drawer)
- A drawer component for React.
- [Dropdown Menu](https://supabase-design-system.vercel.app/design-system/docs/components/dropdown-menu)
- Displays a menu to the user — such as a set of actions or functions — triggered by a button.
- [Expanding Textarea](https://supabase-design-system.vercel.app/design-system/docs/components/expanding-textarea)
- Displays a textarea which autoexpands (or shrinks) based on its content.
- [React Hook Form](https://supabase-design-system.vercel.app/design-system/docs/components/form)
- Building forms with React Hook Form and Zod.
- [Fragment components](https://supabase-design-system.vercel.app/design-system/docs/components/fragment-components)
- ''
- [Hover Card](https://supabase-design-system.vercel.app/design-system/docs/components/hover-card)
- For sighted users to preview content available behind a link.
- [Input OTP](https://supabase-design-system.vercel.app/design-system/docs/components/input-otp)
- Accessible one-time password component with copy paste functionality.
- [Input](https://supabase-design-system.vercel.app/design-system/docs/components/input)
- Displays a form input field or a component that looks like an input field.
- [Label](https://supabase-design-system.vercel.app/design-system/docs/components/label)
- Renders an accessible label associated with controls.
- [Menubar](https://supabase-design-system.vercel.app/design-system/docs/components/menubar)
- A visually persistent menu common in desktop applications that provides quick access to a consistent set of commands.
- [Navigation Menu](https://supabase-design-system.vercel.app/design-system/docs/components/navigation-menu)
- A collection of links for navigating websites.
- [Pagination](https://supabase-design-system.vercel.app/design-system/docs/components/pagination)
- Pagination with page navigation, next and previous links.
- [Popover](https://supabase-design-system.vercel.app/design-system/docs/components/popover)
- Displays rich content in a portal, triggered by a button.
- [Progress](https://supabase-design-system.vercel.app/design-system/docs/components/progress)
- Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.
- [Radio Group Card](https://supabase-design-system.vercel.app/design-system/docs/components/radio-group-card)
- A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
- [Radio Group Stacked](https://supabase-design-system.vercel.app/design-system/docs/components/radio-group-stacked)
- A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
- [Radio Group](https://supabase-design-system.vercel.app/design-system/docs/components/radio-group)
- A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time.
- [Resizable](https://supabase-design-system.vercel.app/design-system/docs/components/resizable)
- Accessible resizable panel groups and layouts with keyboard support.
- [Scroll-area](https://supabase-design-system.vercel.app/design-system/docs/components/scroll-area)
- Augments native scroll functionality for custom, cross-browser styling.
- [Select](https://supabase-design-system.vercel.app/design-system/docs/components/select)
- Displays a list of options for the user to pick from—triggered by a button.
- [Separator](https://supabase-design-system.vercel.app/design-system/docs/components/separator)
- Visually or semantically separates content.
- [Sheet](https://supabase-design-system.vercel.app/design-system/docs/components/sheet)
- Extends the Dialog component to display content that complements the main content of the screen.
- [Sidebar](https://supabase-design-system.vercel.app/design-system/docs/components/sidebar)
- A composable, themeable and customizable sidebar component.
- [Skeleton](https://supabase-design-system.vercel.app/design-system/docs/components/skeleton)
- Use to show a placeholder while content is loading.
- [Slider](https://supabase-design-system.vercel.app/design-system/docs/components/slider)
- An input where the user selects a value from within a given range.
- [Sonner](https://supabase-design-system.vercel.app/design-system/docs/components/sonner)
- An opinionated toast component for React.
- [Switch](https://supabase-design-system.vercel.app/design-system/docs/components/switch)
- A control that allows the user to toggle between checked and not checked.
- [Table](https://supabase-design-system.vercel.app/design-system/docs/components/table)
- A responsive table component.
- [Tabs](https://supabase-design-system.vercel.app/design-system/docs/components/tabs)
- A set of layered sections of content—known as tab panels—that are displayed one at a time.
- [Textarea](https://supabase-design-system.vercel.app/design-system/docs/components/textarea)
- Displays a form textarea or a component that looks like a textarea.
- [Toggle Group](https://supabase-design-system.vercel.app/design-system/docs/components/toggle-group)
- A set of two-state buttons that can be toggled on or off.
- [Toggle](https://supabase-design-system.vercel.app/design-system/docs/components/toggle)
- A two-state button that can be either on or off.
- [Tooltip](https://supabase-design-system.vercel.app/design-system/docs/components/tooltip)
- A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
- [Tree View](https://supabase-design-system.vercel.app/design-system/docs/components/tree-view)
- A tree view that assembles all the functionalities of the Accordion component to create a tree view.
- [Typography](https://supabase-design-system.vercel.app/design-system/docs/components/typography)
- Styles for headings, paragraphs, lists...etc
- [Figma](https://supabase-design-system.vercel.app/design-system/docs/figma)
- Every component recreated in Figma. With customizable props, typography and icons.
- [Admonition](https://supabase-design-system.vercel.app/design-system/docs/fragments/admonition)
- Displays a callout for user attention.
- [Assistant Chat](https://supabase-design-system.vercel.app/design-system/docs/fragments/assistant-chat)
- Bespoke Assistant chat text area, with support for a commands Popover.
- [Form Item Layout](https://supabase-design-system.vercel.app/design-system/docs/fragments/form-item-layout)
- A helper component that provides a layout for form items.
- [Info Tooltip](https://supabase-design-system.vercel.app/design-system/docs/fragments/info-tooltip)
- InfoTooltip provides a tooltip with an information icon.
- [Inner Side Menu](https://supabase-design-system.vercel.app/design-system/docs/fragments/inner-side-menu)
- InnerSideMenu is a component that provides a collapsible side menu with multiple sections.
- [Logs Bar Chart](https://supabase-design-system.vercel.app/design-system/docs/fragments/logs-bar-chart)
- A stacked bar chart that displays logs errors and successes.
- [Modal](https://supabase-design-system.vercel.app/design-system/docs/fragments/modal)
- A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.
- [Multi Select](https://supabase-design-system.vercel.app/design-system/docs/fragments/multi-select)
- Multiple selection component.
- [Text Confirm Dialog](https://supabase-design-system.vercel.app/design-system/docs/fragments/text-confirm-dialog)
- A modal dialog that interrupts the user with important content and expects a response.
- [Icons](https://supabase-design-system.vercel.app/design-system/docs/icons)
- Icons system breakdown. Copy values of Icons.
- [Introduction](https://supabase-design-system.vercel.app/design-system/docs/index)
- Components and patterns that you can copy and paste into Supabase apps. Accessible. Customizable. Open Source.
- [Tailwind Classes](https://supabase-design-system.vercel.app/design-system/docs/tailwind-classes)
- Icons system breakdown. Copy values of Icons.
- [Themes](https://supabase-design-system.vercel.app/design-system/docs/theming)
- Themes used in Supabase
@@ -0,0 +1,227 @@
'use client'
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns'
export default function MultiSelectDemo() {
return (
<TocAnchorsProvider>
<div
id="example-toc-demo"
className="p-4 md:p-8 grid md:grid-cols-4 gap-4 max-h-[600px] overflow-y-auto"
>
<div className="md:col-span-3">
<div className="prose max-w-none">
<h1>Getting Started with Cloud Computing</h1>
<h2 id="introduction">
Introduction
<a
href="#introduction"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h2>
<p>
Cloud computing has revolutionized how we build and deploy applications. This guide
will walk you through the fundamental concepts and best practices.
</p>
<h2 id="key-concepts">
Key Concepts
<a
href="#key-concepts"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h2>
<p>
Before diving deep into cloud services, it's important to understand the basic
building blocks that make cloud computing possible.
</p>
<h3 id="iaas">
Infrastructure as a Service (IaaS)
<a
href="#iaas"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h3>
<p>
IaaS provides virtualized computing resources over the internet. This includes virtual
machines, storage, and networking.
</p>
<h3 id="paas">
Platform as a Service (PaaS)
<a
href="#paas"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h3>
<p>
PaaS delivers a platform allowing customers to develop, run, and manage applications
without dealing with infrastructure.
</p>
<h2 id="best-practices">
Best Practices
<a
href="#best-practices"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h2>
<p>
Following cloud computing best practices ensures optimal performance, security, and
cost-effectiveness.
</p>
<h3 id="security">
Security Considerations
<a
href="#security"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h3>
<p>
Security should be your top priority when working with cloud services. Implement
proper authentication, encryption, and access controls.
</p>
<h3 id="cost-optimization">
Cost Optimization
<a
href="#cost-optimization"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h3>
<p>
Learn how to optimize your cloud spending through resource planning, monitoring, and
implementing cost-saving strategies.
</p>
<h2 id="conclusion">
Conclusion
<a
href="#conclusion"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h2>
<p>
Cloud computing continues to evolve, offering new possibilities for businesses and
developers alike. Stay updated with the latest trends and best practices.
</p>
</div>
</div>
<TocComponent />
</div>
</TocAnchorsProvider>
)
}
const TocComponent = () => {
const { toc } = useTocAnchors()
return (
<Toc className="sticky top-0 border-l">
<h3 className="inline-flex items-center gap-1.5 font-mono text-xs uppercase text-foreground pl-[calc(1.5rem+6px)]">
On this page
</h3>
<TOCScrollArea className="-ml-[2px]">
<TOCItems items={toc} />
</TOCScrollArea>
</Toc>
)
}
import { type AnchorProviderProps, AnchorProvider } from 'ui-patterns'
interface TOCHeader {
id?: string
text: string
link: string
level: number
}
const TocAnchorsContext = createContext<AnchorProviderProps | undefined>(undefined)
const useTocAnchors = () => {
const context = useContext(TocAnchorsContext)
if (!context) {
throw new Error('useTocAnchors must be used within an TocAnchorsContext')
}
return context
}
const TocAnchorsProvider = ({ children }: PropsWithChildren) => {
const [tocList, setTocList] = useState<TOCHeader[]>([])
const toc = tocList.map((item) => ({
title: item.text,
url: item.link,
depth: item.level,
}))
useEffect(() => {
/**
* Because we're directly querying the DOM, needs the setTimeout so the DOM
* update will happen first.
*/
const timeoutHandle = setTimeout(() => {
const headings = Array.from(
document.querySelector('#example-toc-demo')?.querySelectorAll('h2, h3') ?? []
)
const newHeadings = headings
.filter((heading) => heading.id)
.map((heading) => {
const text = heading?.textContent?.replace('#', '')
const link = heading.querySelector('a')?.getAttribute('href')
if (!link) return null
const level = heading.tagName === 'H2' ? 2 : 3
return { text, link, level } as Partial<TOCHeader>
})
.filter((x): x is TOCHeader => !!x && !!x.text && !!x.link && !!x.level)
setTocList(newHeadings)
}, 100)
return () => clearTimeout(timeoutHandle)
/**
* window.location.href needed to recalculate toc when page changes,
* `useSubscribeTocRerender` above will trigger the rerender
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeof window !== 'undefined' && window.location.href])
return (
<TocAnchorsContext.Provider value={{ toc }}>
<AnchorProvider toc={toc} single={false}>
{children}
</AnchorProvider>
</TocAnchorsContext.Provider>
)
}
@@ -0,0 +1,227 @@
'use client'
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns'
export default function MultiSelectDemo() {
return (
<TocAnchorsProvider>
<div
id="example-toc-single-demo"
className="p-4 md:p-8 grid md:grid-cols-4 gap-4 max-h-[600px] overflow-y-auto"
>
<div className="md:col-span-3">
<div className="prose max-w-none">
<h1>Getting Started with Cloud Computing</h1>
<h2 id="single-introduction">
Introduction
<a
href="#single-introduction"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h2>
<p>
Cloud computing has revolutionized how we build and deploy applications. This guide
will walk you through the fundamental concepts and best practices.
</p>
<h2 id="single-key-concepts">
Key Concepts
<a
href="#single-key-concepts"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h2>
<p>
Before diving deep into cloud services, it's important to understand the basic
building blocks that make cloud computing possible.
</p>
<h3 id="single-iaas">
Infrastructure as a Service (IaaS)
<a
href="#single-iaas"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h3>
<p>
IaaS provides virtualized computing resources over the internet. This includes virtual
machines, storage, and networking.
</p>
<h3 id="single-paas">
Platform as a Service (PaaS)
<a
href="#single-paas"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h3>
<p>
PaaS delivers a platform allowing customers to develop, run, and manage applications
without dealing with infrastructure.
</p>
<h2 id="single-best-practices">
Best Practices
<a
href="#single-best-practices"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h2>
<p>
Following cloud computing best practices ensures optimal performance, security, and
cost-effectiveness.
</p>
<h3 id="single-security">
Security Considerations
<a
href="#single-security"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h3>
<p>
Security should be your top priority when working with cloud services. Implement
proper authentication, encryption, and access controls.
</p>
<h3 id="single-cost-optimization">
Cost Optimization
<a
href="#single-cost-optimization"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h3>
<p>
Learn how to optimize your cloud spending through resource planning, monitoring, and
implementing cost-saving strategies.
</p>
<h2 id="single-conclusion">
Conclusion
<a
href="#single-conclusion"
aria-hidden="true"
className="ml-2 opacity-0 group-hover:opacity-100 transition"
>
<span aria-hidden="true">#</span>
</a>
</h2>
<p>
Cloud computing continues to evolve, offering new possibilities for businesses and
developers alike. Stay updated with the latest trends and best practices.
</p>
</div>
</div>
<TocComponent />
</div>
</TocAnchorsProvider>
)
}
const TocComponent = () => {
const { toc } = useTocAnchors()
return (
<Toc className="sticky top-0 border-l">
<h3 className="inline-flex items-center gap-1.5 font-mono text-xs uppercase text-foreground pl-[calc(1.5rem+6px)]">
On this page
</h3>
<TOCScrollArea className="-ml-[2px]">
<TOCItems items={toc} />
</TOCScrollArea>
</Toc>
)
}
import { type AnchorProviderProps, AnchorProvider } from 'ui-patterns'
interface TOCHeader {
id?: string
text: string
link: string
level: number
}
const TocAnchorsContext = createContext<AnchorProviderProps | undefined>(undefined)
const useTocAnchors = () => {
const context = useContext(TocAnchorsContext)
if (!context) {
throw new Error('useTocAnchors must be used within an TocAnchorsContext')
}
return context
}
const TocAnchorsProvider = ({ children }: PropsWithChildren) => {
const [tocList, setTocList] = useState<TOCHeader[]>([])
const toc = tocList.map((item) => ({
title: item.text,
url: item.link,
depth: item.level,
}))
useEffect(() => {
/**
* Because we're directly querying the DOM, needs the setTimeout so the DOM
* update will happen first.
*/
const timeoutHandle = setTimeout(() => {
const headings = Array.from(
document.querySelector('#example-toc-single-demo')?.querySelectorAll('h2, h3') ?? []
)
const newHeadings = headings
.filter((heading) => heading.id)
.map((heading) => {
const text = heading?.textContent?.replace('#', '')
const link = heading.querySelector('a')?.getAttribute('href')
if (!link) return null
const level = heading.tagName === 'H2' ? 2 : 3
return { text, link, level } as Partial<TOCHeader>
})
.filter((x): x is TOCHeader => !!x && !!x.text && !!x.link && !!x.level)
setTocList(newHeadings)
}, 100)
return () => clearTimeout(timeoutHandle)
/**
* window.location.href needed to recalculate toc when page changes,
* `useSubscribeTocRerender` above will trigger the rerender
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeof window !== 'undefined' && window.location.href])
return (
<TocAnchorsContext.Provider value={{ toc }}>
<AnchorProvider toc={toc} single={true}>
{children}
</AnchorProvider>
</TocAnchorsContext.Provider>
)
}
+10
View File
@@ -1066,6 +1066,16 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/inner-side-menu-with-search.tsx'],
},
{
name: 'toc-demo',
type: 'components:example',
files: ['example/toc-demo.tsx'],
},
{
name: 'toc-single-demo',
type: 'components:example',
files: ['example/toc-single-demo.tsx'],
},
{
name: 'multi-select-demo',
type: 'components:example',
@@ -0,0 +1,106 @@
import { registry } from '@/registry/registry'
import fs from 'fs'
import path from 'path'
const BASE_URL = 'https://supabase-design-system.vercel.app/design-system/docs'
interface DocMeta {
title: string
description?: string
path: string
}
console.log('🤖 Building llms.txt')
// Function to extract frontmatter from MDX files
function extractFrontmatter(content: string): { title?: string; description?: string } {
const frontmatterRegex = /---\n([\s\S]*?)\n---/
const match = content.match(frontmatterRegex)
if (!match) return {}
const frontmatter = match[1]
const titleMatch = frontmatter.match(/title:\s*(.*)/)
const descriptionMatch = frontmatter.match(/description:\s*(.*)/)
return {
title: titleMatch?.[1],
description: descriptionMatch?.[1],
}
}
// Function to recursively get all MDX files
function getMdxFiles(dir: string): string[] {
const files: string[] = []
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
files.push(...getMdxFiles(fullPath))
} else if (entry.name.endsWith('.mdx')) {
files.push(fullPath)
}
}
return files
}
// Function to get all MDX files and their metadata
function getDocFiles(): DocMeta[] {
const docsDir = path.join(process.cwd(), 'content/docs')
const mdxFiles = getMdxFiles(docsDir)
const docs: DocMeta[] = []
for (const fullPath of mdxFiles) {
const content = fs.readFileSync(fullPath, 'utf-8')
const { title, description } = extractFrontmatter(content)
if (title) {
// Get relative path and convert to URL path
const relativePath = path.relative(docsDir, fullPath)
const urlPath = relativePath
.replace(/\.mdx$/, '')
.replace(/\/index$/, '')
.replace(/\\/g, '/')
docs.push({
title,
description,
path: urlPath,
})
}
}
return docs
}
// Generate the llms.txt content
const docs = getDocFiles()
let content = `# Supabase Design System
Last updated: ${new Date().toISOString()}
## Overview
The Design System used in Supabase Studio.
## Docs
`
// Add documentation links
for (const doc of docs) {
const url = `${BASE_URL}/${doc.path}`
content += `- [${doc.title}](${url})`
if (doc.description) {
content += `\n - ${doc.description}`
}
content += '\n'
}
// Write the file
const publicDir = path.join(process.cwd(), 'public')
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true })
}
fs.writeFileSync(path.join(publicDir, 'llms.txt'), content)
console.log('✅ Generated llms.txt in public directory')
@@ -50,6 +50,13 @@ const pageMap = [
},
remoteFile: 'bigquery.md',
},
{
slug: 'clerk',
meta: {
title: 'Clerk',
},
remoteFile: 'clerk.md',
},
{
slug: 'clickhouse',
meta: {
@@ -0,0 +1,25 @@
import {
getGuidesMarkdown,
genGuideMeta,
genGuidesStaticParams,
} from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate } from '~/features/docs/GuidesMdx.template'
export const dynamicParams = false
type Params = { slug?: string[] }
const SecurityGuidePage = async ({ params }: { params: Params }) => {
const slug = ['security', ...(params.slug ?? [])]
const data = await getGuidesMarkdown(slug)
return <GuideTemplate {...data!} />
}
const generateStaticParams = genGuidesStaticParams('security')
const generateMetadata = genGuideMeta((params: { slug?: string[] }) =>
getGuidesMarkdown(['security', ...(params.slug ?? [])])
)
export default SecurityGuidePage
export { generateStaticParams, generateMetadata }
+5
View File
@@ -0,0 +1,5 @@
import Layout from '~/layouts/guides'
export default async function SecurityLayout({ children }: { children: React.ReactNode }) {
return <Layout>{children}</Layout>
}
-22
View File
@@ -1,22 +0,0 @@
import { Search } from 'lucide-react'
import { FC } from 'react'
// [Terry] — delete this after development
// just for making it easy to see where things are coming from
interface Props {
link: string
text: string
className?: string
}
export const ComesFrom: FC<Props> = ({ link, text, className }) => {
return (
<div className={`comes-from text-xs flex items-center gap-2 -mb-6 ${className}`}>
<span className="uppercase bg-slate-100 p-1 text-slate-600 flex gap-2 items-center">
<Search size={14} />
Comes from:
</span>
<a className="text-slate-600 no-underline hover:underline" href={link}>
{text}
</a>
</div>
)
}
-14
View File
@@ -1,14 +0,0 @@
export function DateFormat({
date,
options,
}: {
date: string
options?: Intl.DateTimeFormatOptions
}) {
const dateObject = new Date(date)
return (
<time dateTime={dateObject.toISOString()} suppressHydrationWarning>
{dateObject.toLocaleDateString(undefined, options)}
</time>
)
}
@@ -1,41 +0,0 @@
import { ReactMarkdown } from 'react-markdown/lib/react-markdown'
export type FooterHelpCalloutType = 'default' | 'postgres'
const content = {
default: {
title: 'Need some help?',
description: `Not to worry, our specialist engineers are here to help. Submit a support ticket through the [Dashboard](https://supabase.com/dashboard/support/new).`,
},
postgres: {
title: 'Looking for Serverless Postgres?',
description: `Supabase is the fastest way to get started with Postgres in a serverless environment. [Learn more](https://supabase.com/database?utm=postgres-helpers).`,
},
}
const FooterHelpCallout = ({
footerHelpType = 'default',
title,
}: {
footerHelpType: FooterHelpCalloutType
title: any
}) => {
return (
<div className="mt-32 prose prose--remove-p-margin max-w-none">
<div
className="
min-w-full
px-8 py-6
bg-background
rounded
text-sm text-foreground-muted
"
>
<h5 className="text-sm text-foreground-light m-0">{content[footerHelpType].title}</h5>
<ReactMarkdown>{content[footerHelpType].description}</ReactMarkdown>
</div>
</div>
)
}
export default FooterHelpCallout
+23 -156
View File
@@ -1,67 +1,11 @@
'use client'
import { usePathname } from 'next/navigation'
import { Fragment, useEffect, useState } from 'react'
import { cn } from 'ui'
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
import { proxy, useSnapshot } from 'valtio'
import {
highlightSelectedTocItem,
removeAnchor,
} from 'ui/src/components/CustomHTMLElements/CustomHTMLElements.utils'
import { Feedback } from '~/components/Feedback'
import useHash from '~/hooks/useHash'
const formatSlug = (slug: string) => {
// [Joshen] We will still provide support for headers declared like this:
// ## REST API {#rest-api-overview}
// At least for now, this was a docusaurus thing.
if (slug.includes('#')) return slug.split('#')[1]
return slug
}
function formatTOCHeader(content: string) {
let insideInlineCode = false
const res: Array<{ type: 'text'; value: string } | { type: 'code'; value: string }> = []
for (const x of content) {
if (x === '`') {
if (!insideInlineCode) {
insideInlineCode = true
res.push({ type: 'code', value: '' })
} else {
insideInlineCode = false
}
} else {
if (insideInlineCode) {
res[res.length - 1].value += x
} else {
if (res.length === 0 || res[res.length - 1].type === 'code') {
res.push({ type: 'text', value: x })
} else {
res[res.length - 1].value += x
}
}
}
}
return res
}
const tocRenderSwitch = proxy({
renderFlag: 0,
toggleRenderFlag: () => void (tocRenderSwitch.renderFlag = (tocRenderSwitch.renderFlag + 1) % 2),
})
const useSubscribeTocRerender = () => {
const { renderFlag } = useSnapshot(tocRenderSwitch)
return void renderFlag // Prevent it from being detected as unused code
}
const useTocRerenderTrigger = () => {
const { toggleRenderFlag } = useSnapshot(tocRenderSwitch)
return toggleRenderFlag
}
import { Toc, TOCItems, TOCScrollArea } from 'ui-patterns'
import { useTocAnchors } from '../features/docs/GuidesMdx.client'
interface TOCHeader {
id?: string
@@ -70,114 +14,37 @@ interface TOCHeader {
level: number
}
const GuidesTableOfContents = ({
className,
overrideToc,
video,
}: {
className?: string
overrideToc?: Array<TOCHeader>
video?: string
}) => {
useSubscribeTocRerender()
const [tocList, setTocList] = useState<TOCHeader[]>([])
const GuidesTableOfContents = ({ className, video }: { className?: string; video?: string }) => {
const pathname = usePathname()
const [hash] = useHash()
const displayedList = overrideToc ?? tocList
useEffect(() => {
if (overrideToc) return
/**
* Because we're directly querying the DOM, needs the setTimeout so the DOM
* update will happen first.
*/
const timeoutHandle = setTimeout(() => {
const headings = Array.from(
document.querySelector('#sb-docs-guide-main-article')?.querySelectorAll('h2, h3') ?? []
)
const newHeadings = headings
.filter((heading) => heading.id)
.map((heading) => {
const text = heading.textContent.replace('#', '')
const link = heading.querySelector('a')?.getAttribute('href')
if (!link) return null
const level = heading.tagName === 'H2' ? 2 : 3
return { text, link, level } as Partial<TOCHeader>
})
.filter((x): x is TOCHeader => !!x && !!x.text && !!x.link && !!x.level)
setTocList(newHeadings)
})
return () => clearTimeout(timeoutHandle)
/**
* window.location.href needed to recalculate toc when page changes,
* `useSubscribeTocRerender` above will trigger the rerender
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overrideToc, typeof window !== 'undefined' && window.location.href])
useEffect(() => {
if (hash && displayedList.length > 0) {
highlightSelectedTocItem(hash)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hash, JSON.stringify(displayedList)])
const { toc } = useTocAnchors()
const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg`
return (
<div
className={cn(
'border-l flex flex-col gap-6 lg:gap-8',
'thin-scrollbar overflow-y-auto',
'px-2',
className
)}
>
{video && (
<div className="relative pl-5">
<ExpandableVideo imgUrl={tocVideoPreview} videoId={video} />
<div className={cn('thin-scrollbar overflow-y-auto h-fit', 'px-px', className)}>
<div className="w-full relative border-l flex flex-col gap-6 lg:gap-8 px-2 h-fit">
{video && (
<div className="relative pl-5">
<ExpandableVideo imgUrl={tocVideoPreview} videoId={video} />
</div>
)}
<div className="pl-5">
<Feedback key={pathname} />
</div>
)}
<div className="pl-5">
<Feedback key={pathname} />
{toc.length !== 0 && (
<Toc className="-ml-[calc(0.25rem+6px)]">
<h3 className="inline-flex items-center gap-1.5 font-mono text-xs uppercase text-foreground pl-[calc(1.5rem+6px)]">
On this page
</h3>
<TOCScrollArea>
<TOCItems items={toc} />
</TOCScrollArea>
</Toc>
)}
</div>
{displayedList.length > 0 && (
<div>
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-3">
On this page
</span>
<ul className="toc-menu list-none pl-5 text-[0.8rem] grid gap-2">
{displayedList.map((item, i) => (
<li key={`${item.level}-${i}`} className={item.level === 3 ? 'ml-4' : ''}>
<a
href={`#${formatSlug(item.link)}`}
className="text-foreground-lighter hover:text-brand-link transition-colors"
>
{formatTOCHeader(removeAnchor(item.text)).map((x, index) => (
<Fragment key={index}>
{x.type === 'code' ? (
<code className="text-xs border rounded bg-muted">{x.value}</code>
) : (
x.value
)}
</Fragment>
))}
</a>
</li>
))}
</ul>
</div>
)}
</div>
)
}
export default GuidesTableOfContents
export { useTocRerenderTrigger }
export type { TOCHeader }
-32
View File
@@ -1,32 +0,0 @@
import Link from 'next/link'
export default function LinkCard({
title,
description,
icon,
link,
}: {
title: string
description: string
icon: any
link: string
}) {
return (
<Link
href={link || '#'}
className="border-background hover:border-brand bg-surface-200 m-4 w-full cursor-pointer rounded-md border p-4 shadow-sm hover:shadow-2xl lg:w-2/5"
>
{description ? (
<>
<h4 className="m-0 mb-4 p-0 text-base font-semibold">{title}</h4>
<p className="text-foreground-light m-0 p-0">{description}</p>
</>
) : (
<div className="flex">
{icon}
<p className="text-foreground m-0 p-0">{title}</p>
</div>
)}
</Link>
)
}
@@ -1,5 +0,0 @@
import { ReactElement } from 'react'
export default function LinkCardsWrapper({ children }: { children: any }) {
return <div className="flex w-full flex-col lg:flex-row lg:flex-wrap">{children}</div>
}
@@ -1091,6 +1091,10 @@ export const database: NavMenuConstant = {
name: 'Connecting to BigQuery',
url: '/guides/database/extensions/wrappers/bigquery',
},
{
name: 'Connecting to Clerk',
url: '/guides/database/extensions/wrappers/clerk',
},
{
name: 'Connecting to ClickHouse',
url: '/guides/database/extensions/wrappers/clickhouse',
@@ -2026,6 +2030,27 @@ export const MIGRATION_PAGES: Partial<NavMenuSection & ComponentProps<typeof Ico
},
]
export const security: NavMenuConstant = {
icon: 'platform',
title: 'Security',
url: '/guides/security',
items: [
{ name: 'Overview', url: '/guides/security' },
{
name: 'Product security',
url: '/guides/security/product-security',
},
{
name: 'Compliance',
url: undefined,
items: [
{ name: 'SOC 2', url: '/guides/security/soc-2-compliance' },
{ name: 'HIPAA', url: '/guides/security/hipaa-compliance' },
],
},
],
}
export const platform: NavMenuConstant = {
icon: 'platform',
title: 'Platform',
@@ -2373,6 +2398,7 @@ export const deployment: NavMenuConstant = {
},
{ name: 'Maturity model', url: '/guides/deployment/maturity-model' },
{ name: 'Production checklist', url: '/guides/deployment/going-into-prod' },
{ name: 'SOC 2 compliance', url: '/guides/security/soc-2-compliance' },
],
},
{
@@ -21,6 +21,7 @@ enum MenuId {
Deployment = 'deployment',
Telemetry = 'telemetry',
Resources = 'resources',
Security = 'security',
SelfHosting = 'self_hosting',
Integrations = 'integrations',
LocalDevelopment = 'local_development',
@@ -117,6 +118,10 @@ const menus: Menu[] = [
id: MenuId.Resources,
type: 'guide',
},
{
id: MenuId.Security,
type: 'guide',
},
{
id: MenuId.SelfHosting,
type: 'guide',
@@ -135,6 +135,8 @@ export const getMenuId = (pathname: string | null) => {
return MenuId.Realtime
case pathname.startsWith('resources'):
return MenuId.Resources
case pathname.startsWith('security'):
return MenuId.Security
case pathname.startsWith('self-hosting'):
return MenuId.SelfHosting
case pathname.startsWith('storage'):
-48
View File
@@ -1,48 +0,0 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
import Link from 'next/link'
const Pagination = ({ currentPage, totalCount }: { currentPage: number; totalCount: number }) => {
// TODO: not sure if this is the most efficient way to do this. may need to refactor.
const totalArray = Array.from({ length: totalCount }, (_, i: number) => i + 1)
const pages = totalArray.filter((page: number) => {
return page >= currentPage - 2 && page <= currentPage + 2
})
return (
<ul className="flex justify-center space-x-1 text-xs font-medium">
<li>
<Link
href={`/discussions?page=${currentPage - 1}`}
className="border border-control bg-surface-100 inline-flex h-8 w-8 items-center justify-center rounded"
>
<ArrowLeft className="stroke-2 transition group-hover:-translate-x-1" height={12.5} />
</Link>
</li>
{pages.map((page: number, i: number) => {
i = i + 1
return (
<li key={i}>
<Link
href={`/discussions?page=${page}`}
className={`border-scale-600 inline-flex h-8 w-8 items-center justify-center rounded border ${
currentPage === page ? 'bg-brand' : 'bg-surface-100'
}`}
>
{page}
</Link>
</li>
)
})}
<li>
<Link
href={`/discussions?page=${currentPage + 1}`}
className="border-control bg-surface-100 inline-flex h-8 w-8 items-center justify-center rounded border"
>
<ArrowRight className="stroke-2 transition group-hover:-translate-x-1" height={12.5} />
</Link>
</li>
</ul>
)
}
export default Pagination
@@ -1,117 +0,0 @@
import { CodeBlock } from 'ui'
import Param from '~/components/Params'
import { Tabs, TabPanel } from '~/features/ui/Tabs'
import RefSubLayout from '~/layouts/ref/RefSubLayout'
import { ReactMarkdown } from 'react-markdown/lib/react-markdown'
const ApiOperationSection = (props) => {
const operation = props.spec.operations.find((x: any) => x.operationId === props.funcData.id)
// gracefully return nothing if function does not exist
if (!operation) return <></>
return (
<RefSubLayout.Section
key={operation.operationId}
slug={operation.operationId}
title={operation.summary}
id={operation.operationId}
scrollSpyHeader={true}
monoFont={false}
>
<RefSubLayout.Details>
<div className="mt-4">
<code className="text-md flex gap-4 text-md text-foreground-lighter break-all">
<span
className="
uppercase
whitespace-nowrap
bg-foreground text-background
flex items-center justify-center
rounded-full font-mono font-medium text-xs px-2 py-0.5"
>
{operation.operation}
</span>
{operation.path}
</code>
</div>
<div className="prose py-4">
<p>{operation.description}</p>
</div>
{/* Path Parameters */}
{operation.parameters &&
operation.parameters.filter((parameter) => parameter.in === 'path').length > 0 && (
<div className="not-prose mt-12">
<h5 className="mb-3 text-base text-foreground">Path Parameters</h5>
<ul className="mt-4">
{operation.parameters &&
operation.parameters
.filter((parameter: any) => parameter.in === 'path')
.map((parameter: any, index: number) => (
<Param key={index} {...parameter} isOptional={!parameter.required}></Param>
))}
</ul>
</div>
)}
{/* Query Parameters */}
{operation.parameters &&
operation.parameters.filter((parameter) => parameter.in === 'query').length > 0 && (
<div className="not-prose mt-12">
<h5 className="mb-3 text-base text-foreground">Query Parameters</h5>
<ul className="mt-4">
{operation.parameters &&
operation.parameters
.filter((parameter: any) => parameter.in === 'query')
.map((parameter: any, index: number) => (
<Param key={index} {...parameter} isOptional={!parameter.required}></Param>
))}
</ul>
</div>
)}
{/* Header Parameters */}
{operation.parameters &&
operation.parameters.filter((parameter) => parameter.in === 'header').length > 0 && (
<div className="not-prose mt-12">
<h5 className="mb-3 text-base text-foreground">Query Parameters</h5>
<ul className="mt-4">
{operation.parameters &&
operation.parameters
.filter((parameter: any) => parameter.in === 'header')
.map((parameter: any, index) => (
<Param key={index} {...parameter} isOptional={!parameter.required}></Param>
))}
</ul>
</div>
)}
</RefSubLayout.Details>
{operation.responseList && operation.responseList.length > 0 && (
<RefSubLayout.Examples>
<h5 className="mb-3 text-base text-foreground">Responses</h5>
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId={operation.responseList[0].responseCode}
queryGroup="response-status"
>
{operation.responseList.map((response: any, i: number) => (
<TabPanel key={i} id={response.responseCode} label={response.responseCode}>
<ReactMarkdown className="text-foreground">{response.description}</ReactMarkdown>
{response?.content && response?.content['application/json'] && (
<div className="mt-8">
<CodeBlock language="bash" className="relative">
{JSON.stringify(response.content['application/json'], null, 2)}
</CodeBlock>
</div>
)}
</TabPanel>
))}
</Tabs>
</RefSubLayout.Examples>
)}
</RefSubLayout.Section>
)
}
export default ApiOperationSection
@@ -1,214 +0,0 @@
import { ChevronRight } from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import { CodeBlock } from 'ui'
import Options from '~/components/Options'
import Param from '~/components/Params'
import { TabPanel, Tabs } from '~/features/ui/Tabs'
import RefSubLayout from '~/layouts/ref/RefSubLayout'
import spec from '~/spec/cli_v1_commands.yaml' with { type: 'yml' }
import RefDetailCollapse from './RefDetailCollapse'
export type Flag = {
id: string
name: string
description: string
default_value: string
accepted_values: AcceptedValue[]
required?: boolean
/** Whether subcommands inherit this flag. */
inherit?: boolean
}
export type AcceptedValue = {
id: string
name: string
type: 'string' | 'boolean' | 'object'
description?: string
}
export type Example = {
id: string
name: string
code: string
response: string
description?: string
}
export type Command = {
id: string
title: string
description: string
flags?: Flag[]
summary: string
tags?: string[]
links?: string[]
subcommands?: string[]
usage?: string
examples?: Example[]
}
const CliCommandSection = (props) => {
const command = spec.commands.find((x: any) => x.id === props.funcData.id)
const parentCommand = spec.commands.find(
(x: any) => x.subcommands && x.subcommands.find((y: any) => y === props.funcData.id)
)
const commandFlags = [
...(parentCommand?.flags?.filter((x: any) => x.inherit) || []),
...command.flags,
]
return (
<RefSubLayout.Section
key={command.id}
slug={command.id}
title={'$ ' + command.title}
id={command.id}
monoFont={true}
scrollSpyHeader={true}
>
<RefSubLayout.Details>
<div className="grid ref-container" id={command.id}>
<div className="border-b pb-8" key={command.id}>
<header
className={[
// 'border-b sticky top-16 z-10',
' mb-16',
].join(' ')}
>
{command.description ? (
<div className="prose">
<ReactMarkdown>{command.description}</ReactMarkdown>
</div>
) : (
<p className="capitalize mb-4 scroll-mt-24 mt-0 text-foreground-light text-base">
{command.summary}
</p>
)}
</header>
{command.subcommands?.length > 0 && (
<div className="mb-3">
<h3 className="text-lg text-foreground mb-3">Available Commands</h3>
<ul>
{command.subcommands.map((subcommand) => (
<li key={subcommand} className="flex items-center gap-3">
<div className="text-foreground-muted">
<ChevronRight size={14} strokeWidth={2} />
</div>
<a
href={`#${subcommand}`}
className="transition text-foreground-light hover:text-brand"
>
$ {subcommand.replace(/-/g, ' ')}
</a>
</li>
))}
</ul>
</div>
)}
{commandFlags.length > 0 && (
<>
<h3 className="text-lg text-foreground mb-3">Flags</h3>
<ul>
{commandFlags.map((flag: Flag) => (
<li key={flag.id} className="mt-0">
<Param {...flag} isOptional={!flag.required}>
{flag?.accepted_values && (
<Options>
{flag?.accepted_values.map((value) => {
return <Options.Option key={value.id} {...value} />
})}
</Options>
)}
</Param>
</li>
))}
</ul>
</>
)}
</div>
</div>
</RefSubLayout.Details>
{(command.examples || command.usage) && (
<RefSubLayout.Examples>
<div className="overflow-hidden w-full">
<Tabs
defaultActiveId={`${command.id}-basic-usage`}
size="tiny"
type="rounded-pills"
scrollable
queryGroup="example"
>
{command.examples ? (
command.examples.map((example) => {
const exampleId = `${command.id}-${example.id}`
return (
<TabPanel
id={exampleId}
key={exampleId}
label={example.name}
className="flex flex-col gap-3"
>
<CodeBlock
className="useless-code-block-class"
language="bash"
hideLineNumbers={true}
>
{example.code}
</CodeBlock>
<RefDetailCollapse
id={`${exampleId}-response`}
label="Response"
defaultOpen={false}
>
<CodeBlock
className="useless-code-block-class rounded !rounded-tl-none !rounded-tr-none border border-DEFAULT"
language="bash"
hideLineNumbers={true}
>
{example.response}
</CodeBlock>
</RefDetailCollapse>
{example.description && (
<RefDetailCollapse
id={`${exampleId}-notes`}
label="Notes"
defaultOpen={false}
>
<div className="bg-overlay border border-overlay rounded !rounded-tl-none !rounded-tr-none prose max-w-none px-5 py-2">
<ReactMarkdown className="text-sm">{example.description}</ReactMarkdown>
</div>
</RefDetailCollapse>
)}
</TabPanel>
)
})
) : (
// TODO: remove this block once all commands have examples
<TabPanel
id={`${command.id}-basic-usage`}
key={`${command.id}-basic-usage`}
label="Basic usage"
className="flex flex-col gap-3"
>
<CodeBlock
className="useless-code-block-class"
language="bash"
hideLineNumbers={true}
>
{command.usage}
</CodeBlock>
</TabPanel>
)}
</Tabs>
</div>
</RefSubLayout.Examples>
)}
</RefSubLayout.Section>
)
}
export default CliCommandSection
@@ -1,223 +0,0 @@
import Param from '~/components/Params'
import RefSubLayout from '~/layouts/ref/RefSubLayout'
import BodyContentTypeDropdown from '~/components/BodyContentTypeDropdown'
import { useState } from 'react'
import Options from '~/components/Options'
import ApiBodyParam from '~/components/ApiBodyParam'
import ApiSchema from '~/components/ApiSchema'
const MgmtApiOperationSection = (props) => {
const operation = props.spec.operations.find((x: any) => x.operationId === props.funcData.id)
const bodyContentTypes = Object.keys(operation?.requestBody?.content ?? {})
const [selectedContentType, setSelectedContentType] = useState(bodyContentTypes[0] || undefined)
const hasBodyArray =
operation?.requestBody?.content[selectedContentType]?.schema?.type === 'array'
const hasBodyArrayObject =
operation?.requestBody?.content[selectedContentType]?.schema?.items?.type === 'object'
// gracefully return nothing if function does not exist
if (!operation) return <></>
const handleSelectType = (value: string) => {
setSelectedContentType(value)
}
return (
<RefSubLayout.Section
key={operation.operationId}
slug={operation.operationId}
title={operation.summary}
id={operation.operationId}
scrollSpyHeader={true}
monoFont={false}
>
<RefSubLayout.Details>
<div className="mt-4">
<code className="text-md flex gap-4 text-md text-foreground-lighter break-all">
<span
className="
uppercase
whitespace-nowrap
bg-foreground text-background
flex items-center justify-center
rounded-full font-mono font-medium text-xs px-2 py-0.5"
>
{operation.operation}
</span>
{operation.path}
</code>
</div>
<div className="prose py-4">
<p>{operation.description}</p>
</div>
{/* Path Parameters */}
{operation.parameters &&
operation.parameters.filter((parameter) => parameter.in === 'path').length > 0 && (
<div className="not-prose mt-12">
<h5 className="mb-3 text-base text-foreground">Path Parameters</h5>
<ul className="mt-4">
{operation.parameters &&
operation.parameters
.filter((parameter: any) => parameter.in === 'path')
.map((parameter: any, index: number) => (
<Param
key={index}
{...parameter}
type={parameter.schema.type}
isOptional={!parameter.required}
></Param>
))}
</ul>
</div>
)}
{/* Query Parameters */}
{operation.parameters &&
operation.parameters.filter((parameter) => parameter.in === 'query').length > 0 && (
<div className="not-prose mt-12">
<h5 className="mb-3 text-base text-foreground">Query Parameters</h5>
<ul className="mt-4">
{operation.parameters &&
operation.parameters
.filter((parameter: any) => parameter.in === 'query')
.map((parameter: any, index: number) => (
<Param
key={index}
{...parameter}
type={
parameter.schema.type === 'array'
? `${parameter.schema.items?.type} array`
: parameter.schema.type
}
isOptional={!parameter.required}
>
{parameter.schema.enum && (
<Options>
{parameter.schema.enum.map((value) => {
return <Options.Option key={value} name={value} isEnum={true} />
})}
</Options>
)}
{parameter.schema.items?.enum && (
<Options>
{parameter.schema.items?.enum.map((value) => {
return <Options.Option key={value} name={value} isEnum={true} />
})}
</Options>
)}
</Param>
))}
</ul>
</div>
)}
{/* Header Parameters */}
{operation.parameters &&
operation.parameters.filter((parameter) => parameter.in === 'header').length > 0 && (
<div className="not-prose mt-12">
<h5 className="mb-3 text-base text-foreground">Header Parameters</h5>
<ul className="mt-4">
{operation.parameters &&
operation.parameters
.filter((parameter: any) => parameter.in === 'header')
.map((parameter: any, index) => (
<Param
key={index}
{...parameter}
type={parameter.schema.type}
isOptional={!parameter.required}
></Param>
))}
</ul>
</div>
)}
{/* Body */}
{operation.requestBody && (
<div className="not-prose mt-12">
<div className="mb-3 flex flex-row justify-between">
<h5 className="text-base text-foreground">Body</h5>
<BodyContentTypeDropdown
types={Object.keys(operation.requestBody?.content)}
onSelect={handleSelectType}
/>
</div>
<ul className="mt-4">
{hasBodyArray ? (
<div className="space-y-4">
<span>array of:</span>
<div className="ml-10">
{/*object array*/}
{hasBodyArrayObject &&
operation?.requestBody?.content[selectedContentType]?.schema?.items
?.properties &&
Object.entries(
operation?.requestBody?.content[selectedContentType]?.schema?.items
?.properties
).map(([key, value]) => (
<ApiBodyParam
key={key}
name={key}
value={value}
isOptional={
!operation.requestBody?.content[
selectedContentType
]?.schema?.items?.required?.includes(key)
}
/>
))}
{/*primitive type array*/}
{!hasBodyArrayObject &&
operation?.requestBody?.content[selectedContentType]?.schema?.items?.type && (
<Param
type={
operation?.requestBody?.content[selectedContentType]?.schema?.items
?.type
}
isPrimitive={true}
></Param>
)}
</div>
</div>
) : (
operation?.requestBody?.content[selectedContentType]?.schema?.properties &&
Object.entries(
operation?.requestBody?.content[selectedContentType]?.schema?.properties
).map(([key, value]) => (
<ApiBodyParam
key={key}
name={key}
value={value}
isOptional={
!operation.requestBody?.content[
selectedContentType
]?.schema?.items?.required?.includes(key)
}
/>
))
)}
</ul>
</div>
)}
</RefSubLayout.Details>
{operation.responseList && operation.responseList.length > 0 && (
<RefSubLayout.Examples>
<h5 className="mb-3 text-base text-foreground">Response</h5>
{operation.responseList[0] &&
operation.responseList[0]?.content &&
operation.responseList[0]?.content['application/json'] ? (
<ApiSchema
id={operation.operationId}
schema={operation.responseList[0].content['application/json'].schema}
></ApiSchema>
) : (
<span className="text-foreground text-sm">[No content]</span>
)}
</RefSubLayout.Examples>
)}
</RefSubLayout.Section>
)
}
export default MgmtApiOperationSection
@@ -1,42 +0,0 @@
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Admonition } from 'ui-patterns/admonition'
import { useMenuActiveRefId } from '~/hooks/useMenuState'
import type { ICommonSection } from './Reference.types'
export interface OldVersionAlertProps {
sections: ICommonSection[]
}
const OldVersionAlert = ({ sections }: OldVersionAlertProps) => {
const pathname = usePathname()
const activeRefId = useMenuActiveRefId()
const activeSection = sections.find(({ id }) => id === activeRefId)
// Remove the version number from URL to get the latest
const latestVersionUrl = pathname
.split('/')
.slice(0, -2)
.concat(activeSection ? [activeSection.slug] : [])
.join('/')
return (
<div className="sticky top-10 z-10 lg:top-14 lg:w-1/2">
<Admonition type="caution">
You&apos;re viewing an older version of this library.
<br />
<Link
href={latestVersionUrl}
className="underline decoration-brand-400 underline-offset-4 decoration-1"
>
Switch to the latest
</Link>
.
</Admonition>
</div>
)
}
export default OldVersionAlert
@@ -1,42 +0,0 @@
import * as Accordion from '@radix-ui/react-accordion'
import { ChevronRight } from 'lucide-react'
import React from 'react'
const RefDetailCollapse: React.FC<
React.PropsWithChildren<{ id: string; label: string; defaultOpen?: boolean }>
> = ({ defaultOpen = true, ...props }) => {
return (
<Accordion.Root
className="AccordionRoot"
type="single"
defaultValue={defaultOpen ? `${props.id}` : ''}
collapsible
>
<Accordion.Item className="AccordionItem" value={`${props.id}`}>
<Accordion.Trigger asChild>
<button
className={[
'transition-all ease-out',
'h-8',
'bg-surface-100 data-open:bg-surface-200',
'border w-full flex items-center gap-3 px-5',
'rounded-tl rounded-tr',
'data-closed:rounded-bl data-closed:rounded-br',
'text-foreground-light text-xs',
].join(' ')}
>
<div className="data-open-parent:rotate-90 text-foreground-lighter">
<ChevronRight size={12} strokeWidth={2} />
</div>
{props.label}
</button>
</Accordion.Trigger>
<Accordion.Content className="transition data-open:animate-slide-down data-closed:animate-slide-up">
{props.children}
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
)
}
export default RefDetailCollapse
@@ -1,27 +0,0 @@
import RefSubLayout from '~/layouts/ref/RefSubLayout'
import { MDXRemote } from 'next-mdx-remote'
import components from '~/components'
const RefEducationSection = (props) => {
// gracefully reject pages we can't render
if (!props.markdownContent) {
//console.log(props.item.id)
return <div></div>
}
return (
<RefSubLayout.EducationSection
key={props.item.id}
title={props.item.title}
hideTitle={props.markdownContent.meta.hideTitle}
id={props.item.id}
slug={props.item.id}
scrollSpyHeader={true}
icon={props.markdownContent.meta.icon}
>
<MDXRemote {...props.markdownContent.content} components={components} />
</RefSubLayout.EducationSection>
)
}
export default RefEducationSection
@@ -1,226 +0,0 @@
import { Fragment } from 'react'
import ReactMarkdown from 'react-markdown'
import { CodeBlock, Tabs } from 'ui'
import components from '~/components'
import Options from '~/components/Options'
import Param from '~/components/Params'
import RefDetailCollapse from '~/components/reference/RefDetailCollapse'
import RefSubLayout from '~/layouts/ref/RefSubLayout'
import { extractTsDocNode, generateParameters } from '~/lib/refGenerator/helpers'
import type { IRefFunctionSection } from './Reference.types'
import { Database } from 'icons'
const RefFunctionSection: React.FC<IRefFunctionSection> = (props) => {
const item = props.spec.functions.find((x: any) => x.id === props.funcData.id)
if (!item) return <></>
const hasTsRef = item['$ref'] || null
const tsDefinition =
hasTsRef && props.typeSpec ? extractTsDocNode(hasTsRef, props.typeSpec) : null
const parameters =
item.overwriteParams ??
(hasTsRef && tsDefinition ? generateParameters(tsDefinition) : item.params)
const shortText = hasTsRef && tsDefinition ? tsDefinition.signatures[0].comment?.shortText : ''
return (
<>
<RefSubLayout.Section
key={item.id}
title={`${props.commonFuncData.title}`}
id={item.id}
slug={props.commonFuncData.slug}
scrollSpyHeader={true}
>
<RefSubLayout.Details>
<>
<header className={['prose'].join(' ')}>
{shortText && <ReactMarkdown className="text-sm">{shortText}</ReactMarkdown>}
</header>
{item.description && (
<div className="prose">
<ReactMarkdown className="text-sm">{item.description}</ReactMarkdown>
</div>
)}
{item.notes && (
<div className="prose">
<ReactMarkdown className="text-sm" components={components}>
{item.notes}
</ReactMarkdown>
</div>
)}
{/* parameters */}
{parameters && (
<div className="not-prose mt-12">
<h5 className="mb-3 text-base text-foreground">Parameters</h5>
<ul>
{parameters.map((param) => {
return (
<Param {...param} key={param.name}>
{param.subContent && (
<div className="mt-3">
<Options>
{param.subContent.map((param) => {
return (
<Fragment key={param.name + 'subcontent'}>
<Options.Option {...param}>
{param.subContent && (
<Options>
{param.subContent.map((param) => {
return (
<Options.Option
{...param}
key={param.name + 'subcontent-option'}
/>
)
})}
</Options>
)}
</Options.Option>
</Fragment>
)
})}
</Options>
</div>
)}
</Param>
)
})}
</ul>
</div>
)}
</>
</RefSubLayout.Details>
<RefSubLayout.Examples>
{item.examples && (
<>
<div className="overflow-hidden w-full">
<Tabs
defaultActiveId={item.examples[0].id}
size="tiny"
type="rounded-pills"
scrollable
>
{item.examples &&
item.examples.map((example, exampleIndex) => {
const exampleString =
'' +
(example.code &&
example.code
.trim()
.replace(/^```.*/, '')
.replace(/```$/, ''))
const codeBlockLang = example?.code?.startsWith('```js')
? 'js'
: example?.code?.startsWith('```ts')
? 'ts'
: example?.code?.startsWith('```dart')
? 'dart'
: example?.code?.startsWith('```c#')
? 'csharp'
: example?.code?.startsWith('```kotlin')
? 'kotlin'
: 'js'
const staticExample = item.examples[exampleIndex]
const response = staticExample.response
const sql = staticExample?.data?.sql
const tables = staticExample?.data?.tables
return (
<Tabs.Panel
id={example.id}
key={exampleIndex}
label={example.name}
className="flex flex-col gap-3"
>
<CodeBlock
className="useless-code-block-class"
language={codeBlockLang}
hideLineNumbers={true}
>
{exampleString}
</CodeBlock>
{((tables && tables.length > 0) || sql) && (
<RefDetailCollapse
id={`${example.id}-${exampleIndex}-data`}
label="Data source"
defaultOpen={false}
>
<>
{tables &&
tables.length > 0 &&
tables.map((table) => {
return (
<div className="bg-surface-100 border rounded prose max-w-none">
<div className="bg-background px-5 py-2">
<div className="flex gap-2 items-center">
<div className="text-brand">
<Database size={16} />
</div>
<h5 className="text-xs text-foreground">
{table.name}
</h5>
</div>
</div>
</div>
)
})}
{sql && (
<CodeBlock
className="useless-code-block-class my-0 border border-t-0 border-default !rounded-tl-none !rounded-tr-none"
language="sql"
hideLineNumbers={true}
>
{sql.replace(/sql/g, '').replace(/```/g, '')}
</CodeBlock>
)}
</>
</RefDetailCollapse>
)}
{response && (
<RefDetailCollapse
id={`${example.id}-${exampleIndex}-response`}
label="Response"
defaultOpen={false}
>
<CodeBlock
className="useless-code-block-class rounded !rounded-tl-none !rounded-tr-none border border-default"
language={codeBlockLang}
hideLineNumbers={true}
>
{response.replace(/```/g, '').replace('json', '')}
</CodeBlock>
</RefDetailCollapse>
)}
{example.description && (
<RefDetailCollapse
id={`${example.id}-${exampleIndex}-notes`}
label="Notes"
defaultOpen={false}
>
<div className="bg-surface-100 border border-default rounded !rounded-tl-none !rounded-tr-none prose max-w-none px-5 py-2">
<ReactMarkdown className="text-sm">
{example.description}
</ReactMarkdown>
</div>
</RefDetailCollapse>
)}
</Tabs.Panel>
)
})}
</Tabs>
</div>
</>
)}
</RefSubLayout.Examples>
</RefSubLayout.Section>
</>
)
}
export default RefFunctionSection
-25
View File
@@ -1,25 +0,0 @@
import { NextSeo } from 'next-seo'
import { useRouter } from 'next/compat/router'
function RefSEO({ title }) {
const router = useRouter()
const path = router.asPath.replace('crawlers/', '')
return (
<NextSeo
title={title}
openGraph={{
title,
url: `https://supabase.com/docs${path}`,
images: [
{
url: `https://supabase.com/docs/img/supabase-og-image.png`,
},
],
}}
/>
)
}
export default RefSEO
@@ -1,149 +0,0 @@
import { useRouter } from 'next/compat/router'
import Head from 'next/head'
import { useEffect } from 'react'
import { type MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import RefEducationSection from '~/components/reference/RefEducationSection'
import RefFunctionSection from '~/components/reference/RefFunctionSection'
import RefSubLayout from '~/layouts/ref/RefSubLayout'
import ApiOperationSection from './ApiOperationSection'
import CliCommandSection from './CLICommandSection'
import OldVersionAlert from './OldVersionAlert'
import type { IAPISpec, ICommonSection, IRefStaticDoc, ISpec, TypeSpec } from './Reference.types'
import { SidebarSkeleton, TopNavSkeleton } from '~/layouts/MainSkeleton'
import MgmtApiOperationSection from '~/components/reference/MgmtApiOperationSection'
interface RefSectionHandlerProps {
sections: ICommonSection[]
spec?: ISpec | IAPISpec
typeSpec?: TypeSpec
pageProps: { docs: IRefStaticDoc[] }
type: 'client-lib' | 'cli' | 'api' | 'mgmt-api'
isOldVersion?: boolean
menuId: MenuId
}
const RefSectionHandler = (props: RefSectionHandlerProps) => {
const router = useRouter()
const [slug] = router.query.slug
// When user lands on a url like http://supabase.com/docs/reference/javascript/sign-up
// find the #sign-up element and scroll to that
useEffect(() => {
document.getElementById(slug)?.scrollIntoView()
}, [slug])
useEffect(() => {
function handler() {
const [slug] = window.location.pathname.split('/').slice(-1)
document.getElementById(slug)?.scrollIntoView()
}
window.addEventListener('popstate', handler)
return () => {
window.removeEventListener('popstate', handler)
}
}, [])
function getPageTitle() {
switch (props.type) {
case 'client-lib':
return props.spec.info.title
case 'cli':
return 'Supabase CLI reference'
case 'api':
case 'mgmt-api':
return 'Supabase API reference'
default:
return 'Supabase Docs'
}
}
const pageTitle = getPageTitle()
const section = props.sections.find((section) => section.slug === slug)
const fullTitle = `${pageTitle}${section ? ` - ${section.title}` : ''}`
const path = router.asPath.replace('/crawlers', '')
return (
<>
<Head>
<title>{fullTitle}</title>
<meta name="description" content={section?.title ?? pageTitle} />
<meta property="og:image" content={`https://supabase.com/docs/img/supabase-og-image.png`} />
<meta
name="twitter:image"
content={`https://supabase.com/docs/img/supabase-og-image.png`}
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="canonical" href={`https://supabase.com${router.basePath}${path}`} />
</Head>
<TopNavSkeleton>
<SidebarSkeleton menuId={props.menuId}>
{props.isOldVersion && <OldVersionAlert sections={props.sections} />}
<RefSubLayout>
{props.sections.map((section, i) => {
const sectionType = section.type
switch (sectionType) {
case 'markdown':
const markdownData = props.pageProps.docs.find((doc) => doc.id === section.id)
return (
<RefEducationSection
key={section.id + i}
item={section}
markdownContent={markdownData}
/>
)
case 'function':
return (
<RefFunctionSection
key={section.id + i}
funcData={section}
commonFuncData={section}
spec={props.spec}
typeSpec={props.typeSpec}
/>
)
case 'cli-command':
return (
<CliCommandSection
key={section.id + i}
funcData={section}
commonFuncData={section}
/>
)
case 'operation':
if (props.type === 'mgmt-api') {
return (
<MgmtApiOperationSection
key={section.id + i}
funcData={section}
commonFuncData={section}
spec={props.spec}
/>
)
} else {
return (
<ApiOperationSection
key={section.id + i}
funcData={section}
commonFuncData={section}
spec={props.spec}
/>
)
}
default:
throw new Error(`Unknown common section type '${sectionType}'`)
}
})}
</RefSubLayout>
</SidebarSkeleton>
</TopNavSkeleton>
</>
)
}
export default RefSectionHandler
@@ -144,7 +144,15 @@ To use `MergeDeep`, set `compilerOptions.strictNullChecks` to `true` in your `ts
You can also override the type of an individual successful response if needed:
```ts
const { data } = await supabase.from('countries').select().returns<MyType>()
// Partial type override allows you to only override some of the properties in your results
const { data } = await supabase.from('countries').select().overrideTypes<Array<{ id: string }>>()
// For a full replacement of the original return type use the `{ merge: false }` property as second argument
const { data } = await supabase
.from('countries')
.select()
.overrideTypes<Array<{ id: string }>, { merge: false }>()
// Use it with `maybeSingle` or `single`
const { data } = await supabase.from('countries').select().single().overrideTypes<{ id: string }>()
```
### Type shorthands
@@ -81,6 +81,10 @@ You are responsible of provisioning enough compute to run the workload that your
We recommend reviewing and applying the recommendations offered in our [Production Checklist](/docs/guides/platform/going-into-prod). This checklist covers the responsibilities discussed here and a few additional general production readiness best practices.
## SOC 2 and compliance
Supabase provides a SOC 2 compliant environment for hosting and managing sensitive data. We recommend reviewing the [SOC 2 compliance responsibilities document](/docs/guides/security/soc-2-compliance) alongside the aforementioned production checklist.
## Managing healthcare data
You can use Supabase to store and process Protected Health Information (PHI). You are responsible for the following
@@ -97,3 +101,5 @@ You can use Supabase to store and process Protected Health Information (PHI). Yo
- Not using [Edge functions](/docs/guides/functions) to process PHI.
- Not storing PHI in [public Storage buckets](/docs/guides/storage/buckets/fundamentals#public-buckets).
- Not [transferring projects](/docs/guides/platform/project-transfer) to a non-HIPAA organization.
For more information on the shared responsibilities and rules under HIPAA, review the [HIPAA compliance responsibilities document](/docs/guides/security/hipaa-compliance).
+1 -1
View File
@@ -77,7 +77,7 @@ Before deploying the Edge Function, we need to ensure it bundles the Wasm module
```toml
[functions.wasm-add]
static_files = [ "./functions/wasm-add/add-wasm/pkg/*.wasm"]
static_files = [ "./functions/wasm-add/add-wasm/pkg/*"]
```
Deploy the function by running:
@@ -67,17 +67,25 @@ Below are some ways to connect to the Postgres MCP server using popular AI tools
#### Cursor
1. Open Cursor and navigate to **Cursor Settings**.
1. Under the **Features** tab, tap **+ Add new MCP server** under the **MCP Servers** section.
1. Enter the following details:
1. Open Cursor and open `.cursor/mcp.json` file. Create the file if it doesn't exist.
1. Add the following configuration:
- **Name**: Supabase (or your project name)
- **Type**: command
- **Command**: `npx -y @modelcontextprotocol/server-postgres <connection-string>`
```json
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-postgres", "<connection-string>"]
}
}
}
```
(replace `<connection-string>` with your connection string)
Replace `<connection-string>` with your connection string.
1. You should see a green active status after the server is successfully connected.
1. Save the configuration file.
1. Open Cursor and navigate to **Settings/MCP**. You should see a green active status after the server is successfully connected.
#### Claude desktop
@@ -77,6 +77,8 @@ hideToc: true
You can change the schema of your Laravel application by modifying the `search_path` variable `app/config/database.php`.
Please note the schema you specify in `search_path` has to exist on Supabase. You can create a new schema from the [Table Editor](/dashboard/project/_/editor).
</StepHikeCompact.Details>
<StepHikeCompact.Code>
+16
View File
@@ -0,0 +1,16 @@
---
id: 'security'
title: 'Supabase Security'
description: 'Security and compliance on the Supabase platform.'
sidebar_label: 'Overview'
hideToc: true
---
Supabase is a hosted platform which makes it very simple to get started without needing to manage any infrastructure. The hosted platform comes with many security and compliance controls managed by Supabase.
# Compliance
Supabase is SOC 2 Type 2 compliant and regularly audited. All projects at Supabase are governed by the same set of compliance controls.
The [SOC 2 Compliance Guide](/docs/guides/security/soc-2-compliance) explains Supabase's SOC 2 responsibilities and controls in more detail.
The [HIPAA Compliance Guide](/docs/guides/security/hipaa-compliance) explains Supabase's HIPAA responsibilities. Additional [security and compliance controls](/docs/guides/deployment/shared-responsibility-model#managing-healthcare-data) for projects that deal with electronic Protected Health Information (ePHI) and require HIPAA compliance are available through the HIPAA add-on.
@@ -0,0 +1,60 @@
---
id: 'hipaa-compliance'
title: 'HIPAA Compliance and Supabase'
description: 'Supabase provides a HIPAA compliant environment and helps you meet your compliance controls.'
---
The [Health Insurance Portability and Accountability Act (HIPAA)](https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index.html) is a comprehensive law that protects individuals' health information while ensuring the continuity of health insurance coverage. It sets standards for privacy and security that must be followed by all entities that handle Protected Health Information (PHI), also known as electronic PHI (ePHI). HIPAA is specific to the United States, however many countries have similar or laws already in place or under legislation.
Under HIPAA, both covered entities and business associates have distinct responsibilities to ensure the protection of PHI. Supabase acts as a business associate for customers (the covered entity) who wish to provide healthcare related services. As a business associate, Supabase has a number of obligations and has undergone auditing of the security and privacy controls that are in place to meet these. Supabase has signed a Business Associate Agreement (BAA) with all of our vendors who would have access to ePHI, such as AWS, and ensure that we follow their terms listed in the agreements. Similarly when a customer signs a BAA with us, they have some responsibilities they agree to when using Supabase to store PHI.
### Customer responsibilities
Covered entities (the customer) are organizations that directly handle PHI, such as health plans, healthcare clearinghouses, and healthcare providers that conduct certain electronic transactions.
1. **Compliance with HIPAA Rules**: Covered entities must comply with the [HIPAA Privacy Rule](https://www.hhs.gov/hipaa/for-professionals/privacy/index.html), [Security Rule](https://www.hhs.gov/hipaa/for-professionals/security/index.html), and [Breach Notification Rule](https://www.hhs.gov/hipaa/for-professionals/breach-notification/index.html) to protect the privacy and security of ePHI.
2. **Business Associate Agreements (BAAs)**: Customers must sign a BAA with Supabase. When the covered entity engages a business associate to help carry out its healthcare activities, it must have a written BAA. This agreement outlines the business associate's responsibilities and requires them to comply with HIPAA Rules.
3. **Internal Compliance Programs**: Customers must [configure their HIPAA projects](/docs/guides/platform/hipaa-projects) and follow the guidance given by the security advisor. Covered entities are responsible for implementing internal processes and compliance programs to ensure they meet HIPAA requirements.
### Supabase responsibilities
Supabase as the business associate, and the vendors used by Supabase, are the entities that perform functions or activities on behalf of the customer.
1. **Direct Liability**: Supabase is directly liable for compliance with certain provisions of the HIPAA Rules. This means Supabase has to implement safeguards to protect ePHI and report breaches to the customer.
2. **Compliance with BAAs**: Supabase must comply with the terms of the BAA, which includes implementing appropriate administrative, physical, and technical safeguards to protect ePHI.
3. **Vendor Management**: Supabase must also ensure that our vendors, who may have access to ePHI, comply with HIPAA Rules. This is done through a BAA with each vendor.
## Staying compliant and secure
Compliance is a continuous process and should not be treated as a point-in-time audit of controls. Supabase applies all the necessary privacy and security controls to ensure HIPAA compliance at audit time, but also has additional checks and monitoring in place to ensure those controls are not disabled or altered in between audit periods. Customers commit to doing the same in their HIPAA environments. Supabase provides a growing set of checks that warn customers of changes to their projects that disable or weaken HIPAA required controls. Customers will receive warnings and guidance via the Security Advisor, however the responsibility of applying the recommended controls falls directly to the customer.
Our [shared responsibility model](/docs/guides/deployment/shared-responsibility-model#managing-healthcare-data) document discusses both HIPAA and general data management best practices, how this responsibility is shared between customers and Supabase, and how to stay compliant.
## Frequently asked questions
**What is the difference between SOC 2 and HIPAA?**
Both are frameworks for protecting sensitive data, however they serve two different purposes. They share many security and privacy controls and meeting the controls of one normally means being close to complying with the other.
The main differentiator comes down to purpose and scope.
- SOC 2 is not industry-specific and can be applied to any service organization that handles customer data.
- HIPAA is a federal regulation in the United States. HIPAA sets standards for the privacy and security of PHI/ePHI, ensuring that patient data is handled confidentially and securely.
**Are Supabase HIPAA environments also SOC 2 compliant?**
Yes. Supabase applies the same SOC 2 controls to all environments, with additional controls being applied to HIPAA environments.
**How often is Supabase audited?**
Supabase undergoes annual audits. The HIPAA controls are audited during the same audit period as the SOC 2 controls.
## Resources
1. [Health Insurance Portability and Accountability Act (HIPAA)](https://www.hhs.gov/hipaa/for-professionals/privacy/laws-regulations/index.html)
2. [HIPAA Privacy Rule](https://www.hhs.gov/hipaa/for-professionals/privacy/index.html)
3. [Security Rule](https://www.hhs.gov/hipaa/for-professionals/security/index.html)
4. [Breach Notification Rule](https://www.hhs.gov/hipaa/for-professionals/breach-notification/index.html)
5. [Configuring HIPAA projects](/docs/guides/platform/hipaa-projects) on Supabase
6. [Shared Responsibility Model](/docs/guides/deployment/shared-responsibility-model)
7. [HIPAA shared responsibility](/docs/guides/deployment/shared-responsibility-model#managing-healthcare-data)
@@ -0,0 +1,38 @@
---
id: 'product-security'
title: 'Secure configuration of Supabase products'
description: 'Supabase provides a secure yet flexible platform. Here is how to adjust various security settings across the platform.'
---
The Supabase [production checklist](/docs/guides/deployment/going-into-prod) provides detailed advice on preparing an app for production. While our [SOC 2](/docs/guides/security/soc-2-compliance) and [HIPAA](/docs/guides/security/hipaa-compliance) compliance documents outline the roles and responsibilities for building a secure and compliant app.
Various products at Supabase have their own hardening and configuration guides, below is a definitive list of these to help guide your way.
## Auth
- [Password security](/docs/guides/auth/password-security)
- [Rate limits](/docs/guides/auth/rate-limits)
- [Bot detection / Prevention](/docs/guides/auth/auth-captcha)
- [JWTs](/docs/guides/auth/jwts)
## Database
- [Row Level Security](/docs/guides/database/postgres/row-level-security)
- [Column Level Security](/docs/guides/database/postgres/column-level-security)
- [Hardening the Data API](/docs/guides/database/hardening-data-api)
- [Additional security controls for the Data API](/docs/guides/api/securing-your-api)
- [Custom claims and role based access control](/docs/guides/database/postgres/custom-claims-and-role-based-access-control-rbac)
- [Managing Postgres roles](/docs/guides/database/postgres/roles)
- [Managing secrets with Vault](/docs/guides/database/vault)
- [Superuser access and unsupported operations](docs/guides/database/postgres/roles-superuser)
## Storage
- [Object ownership](/docs/guides/storage/security/ownership)
- [Access control](/docs/guides/storage/security/access-control)
- The Storage API docs contain hints about required [RLS policy permissions](/docs/reference/javascript/storage-createbucket)
- [Custom roles with the storage schema](/docs/guides/storage/schema/custom-roles)
## Realtime
- [Authorization](docs/guides/realtime/authorization)
@@ -0,0 +1,79 @@
---
id: 'soc-2-compliance'
title: 'SOC 2 Compliance and Supabase'
description: 'Supabase is SOC 2 compliant and helps you meet your compliance controls.'
---
Supabase is Systems and Organization Controls 2 (SOC 2) Type 2 compliant and is assessed annually to ensure continued adherence to the SOC 2 security framework. SOC 2 assesses Supabase’s adherence to, and implementation of, controls governing the security, availability, processing integrity, confidentiality, and privacy on the Supabase platform. These controls define requirements for the management and storage of customer data on the platform. These controls applied to Supabase, as a service provider, serve two customer data environments.
The first environment is the customer relationship with Supabase, this refers to the data Supabase has on a customer of the platform. All billing, contact, usage and contract information is managed and stored according to SOC 2 requirements.
The second environment is the backend as a service (the product) that Supabase provides to customers. Supabase implements the controls from the SOC 2 framework to ensure the security of the platform, which hosts the backend as a service (the product), including the Postgres Database, Storage, Authentication, Realtime, Edge Functions and Data API features. Supabase can assert that the environment hosting customer data, stored within the product, adheres to SOC 2 requirements. And the management and storage of data within this environment (the product) is strictly controlled and kept secure.
Supabase’s SOC 2 compliance does not transfer to environments outside of the Supabase product or Supabase’s control. This is known as the security or compliance boundary and forms part of the Shared Responsibility Model that Supabase and their customers enter into.
<Admonition type="note">
SOC 2 does not cover, nor is it a substitute for, compliance with the Health Insurance Portability and Accountability Act (HIPAA).
Organizations must have a signed Business Associate Agreement (BAA) with Supabase and have the HIPAA add-on enabled when dealing with Protected Health Information (PHI).
Our [HIPAA documentation](/docs/guides/security/hipaa-compliance) provides more information about the responsibilities and requirements for HIPAA on Supabase.
</Admonition>
# Meeting compliance requirements
SOC 2 compliance is a critical aspect of data security for Supabase and our customers. Being fully SOC 2 compliant is a shared responsibility and here’s a breakdown of the responsibilities for both parties:
### Supabase responsibilities
1. **Security Measures**: Supabase implements robust security controls to protect customer data. These includes measures to prevent data breaches and ensure the confidentiality and integrity of the information managed and stored by the platform. Supabase is obliged to be vigilant about security risks and must demonstrate that our security measures meet industry standards through regular audits.
2. **Compliance Audits**: Supabase undergoes SOC 2 audits yearly to verify that our data management practices comply with the Trust Services Criteria (TSC), which include security, availability, processing integrity, confidentiality, and privacy. These audits are conducted by an independent third party.
3. **Incident Response**: Supabase has an incident response plan in place to handle data breaches efficiently. This plan outlines how the organization detects issues, responds to incidents, and manages system vulnerabilities.
4. **Reporting**: Upon a successful audit, Supabase receive a SOC 2 report that details our compliance status. This report is available to customers as a SOC 2 Type 2 report, and allows customers and stakeholders to assure that Supabase has implemented adequate and the requisite safeguards to protect sensitive information.
### Customer responsibilities
1. **Compliance Requirements**: Understand your own compliance requirements. While SOC 2 compliance is not a legal requirement, many enterprise customers require their providers to have a SOC 2 report. This is because it provides assurance that the provider has implemented robust controls to protect customer data.
2. **Due Diligence**: Customers must perform due diligence when selecting Supabase as a provider. This includes reviewing the SOC 2 Type 2 report to ensure that Supabase meets the expected security standards. Customers should also understand the division of responsibilities between themselves and Supabase to avoid duplication of effort.
3. **Monitoring and Review**: Customers should regularly monitor and review Supabase’s compliance status.
4. **Control Compliance**: If a customer needs to be SOC 2 compliant, they should themselves implement the requisite controls and undergo a SOC 2 audit.
### Shared responsibilities
1. **Data Security**: Both customers and Supabase share the responsibility of ensuring data security. While the Supabase, as the provider, implements the security controls, the customer must ensure that their use of the Supabase platform does not compromise these controls.
2. **Control Compliance**: Supabase asserts through our SOC 2 that all requisite security controls are met. Customers wishing to also be SOC 2 compliant need to go through their own SOC 2 audit, verifying that security controls are met on the customer's side.
In summary, SOC 2 compliance involves a shared responsibility between Supabase and our customers to ensure the security and integrity of data. Supabase, as a provider, must implement and maintain robust security measures, customers must perform due diligence and monitor Supabase's compliance status, while also implement their own compliance controls to protect their sensitive information.
## Frequently asked questions
**How often is Supabase SOC 2 audited?**
Supabase has obtained SOC 2 Type 2 certification, which means Supabase's controls are fully audited annually. The auditor's reports on these examinations are issued as soon as they are ready after the audit. Supabase makes the SOC 2 Type 2 report available to [Enterprise and Team Plan](https://supabase.com/pricing) customers. The audit report covers a rolling 12-month window, known as the audit period, and runs from 1 March to 28 February of the next calendar year.
**How to obtain Supabase's SOC 2 Type 2 report?**
To access the SOC 2 Type 2 report, you must be a Enterprise or Team Plan Supabase customer. The report is downloadable from the [Legal Documents](https://supabase.com/dashboard/org/_/documents) section in the organization dashboard.
**Why does it matter that Supabase is SOC 2 Compliant?**
SOC 2 is used to assert that controls are in place to ensure the proper management and storage of data. SOC 2 provides a framework for measuring how secure a service provider is and re-evaluates the provider on an annual basis. This provides the confidence and assurance that data stored within the Supabase platform is correctly secured and managed.
**If Supabase’s SOC 2 does not transfer to the customer, why does it matter that Supabase has SOC 2?**
Even though Supabase’s SOC 2 compliance does not transfer outside of the product, it does provide the assurance that all data within the product is correctly managed and stored. Supabase can assert that only authorized persons have access to the data, and security controls are in place to prevent, detect and respond to data intrusions. This forms part of a customer’s own adherence to the SOC 2 framework and relieves part of the burden of data management and storage on the customer. In many organizations' security and risk departments require all vendors or sub-processors to be SOC 2 compliant.
**What is the security or compliance boundary?**
This defines the boundary or border between Supabase and customer responsibility for data security within the Shared Responsibility Model. Customer data stored within the Supabase product, on the Supabase side of the security boundary, is managed and secured by Supabase. Supabase ensures the safe handling and storage of data within this environment. This includes controls for preventing unauthorized access, monitoring data access, alerting, data backups and redundancy. Data on the customer side of the boundary, the data that enters and leaves the Supabase product, is the responsibility of the customer. Management and possible storage of such data outside of Supabase should be performed by the customer, and any security and compliance controls are the responsibility of the customer.
**Does SOC 2 cover health related data (HIPAA)?**
SOC 2 is non-industry specific and provides a framework for the security and privacy of data. This is however not sufficient in most cases when dealing with Protected Healthcare Information (PHI), which requires additional privacy and legal controls.
When dealing with PHI in the United States or for United States customers, HIPAA is mandatory.
## Resources
1. [System and Organization Controls: SOC Suite of Services](https://www.aicpa-cima.com/resources/landing/system-and-organization-controls-soc-suite-of-services)
2. [Shared Responsibility Model](/docs/guides/deployment/shared-responsibility-model)
@@ -210,6 +210,9 @@ basicauth_credentials:
password: password_two
```
To enable all dashboard features outside of `localhost`, update the following value in the `./docker/.env` file:
- `SUPABASE_PUBLIC_URL`: The URL or IP used to access the dashboard
You will need to [restart](#restarting-all-services) the services for the changes to take effect.
## Restarting all services
@@ -33,8 +33,7 @@ File, Folder, and Bucket names **must follow** [AWS object key naming guidelines
## Create a bucket
You can create a bucket using the Supabase Dashboard. Since the storage is interoperable with your Postgres database, you can also use SQL or our
client libraries. Here we create a bucket called "avatars":
You can create a bucket using the Supabase Dashboard. Since the storage is interoperable with your Postgres database, you can also use SQL or our client libraries. Here we create a bucket called "avatars":
<Tabs
scrollable
+63 -193
View File
@@ -4,7 +4,7 @@ title: 'Metrics'
description: 'Observability for your Supabase project'
---
In addition to the reports and charts built in to the Supabase dashboard, each project hosted on the Supabase platform comes with a [Prometheus](https://prometheus.io/)-compatible metrics endpoint, which can be used to gather insight into the health and status of your project.
In addition to the reports and charts built in to the Supabase dashboard, each project hosted on the Supabase platform comes with a [Prometheus](https://prometheus.io/)-compatible metrics endpoint, updated every minute, which can be used to gather insight into the health and status of your project.
You can use this endpoint to ingest data into your own monitoring and alerting infrastructure, as long as it is capable of scraping Prometheus-compatible endpoints, in order to set up custom rules beyond those supported by the Supabase dashboard.
@@ -28,13 +28,40 @@ Your project's metrics endpoint is accessible at `https://<project-ref>.supabase
> curl https://<project-ref>.supabase.co/customer/v1/privileged/metrics --user 'service_role:<service-role-jwt>'
```
## Configuring Prometheus
## Supabase Grafana
For self-hosted Prometheus, modify your `prometheus.yaml` file to add a Supabase job, and set the `metrics_path`, `scheme`, `basic_auth` and `targets` parameters. For example:
The pre-configured Supabase Grafana Dashboard is an advanced version of the [Dashboard's Database Reports](https://supabase.com/dashboard/project/_/reports/database). It visualizes over 200 database performance and health metrics.
![Supabase Grafana](/docs/img/guides/platform/supabase-grafana-prometheus.png)
Instructions are included in the README for deploying the repository using docker.
## Using the metrics endpoint in production
To set up monitoring for your project, you will need two things:
1. A datastore - a place to store the metrics coming from your Supabase project over time
2. A dashboard - a place to visualize the state of your Supabase project for a defined period
### Setting up a metrics datastore
One of the more well-known options is [Prometheus](https://prometheus.io/docs/introduction/overview/) and it is the tool used in this guide.
You can [self-host](https://prometheus.io/docs/prometheus/latest/installation/) Prometheus or choose a managed service to store your metrics. Some of the providers offering managed Prometheus are:
- [Digital Ocean](https://marketplace.digitalocean.com/apps/prometheus)
- [AWS](https://aws.amazon.com/prometheus/)
- [Grafana Cloud](https://grafana.com/products/cloud/metrics/)
Follow the guides for the deployment option you choose
#### Adding a scrape job to Prometheus
For Prometheus, modify your `prometheus.yaml` file to add a Supabase job, and set the `metrics_path`, `scheme`, `basic_auth` and `targets` parameters. For example:
```yaml
scrape_configs:
- job_name: "MyJob"
- job_name: "MySupabaseJob"
metrics_path: "/customer/v1/privileged/metrics"
scheme: https
basic_auth:
@@ -48,198 +75,41 @@ scrape_configs:
group: "MyGroupLabel"
```
Additionally, we [maintain a repo](https://github.com/supabase/supabase-grafana) for a scraping agent to work with Grafana Cloud.
### Setting up a dashboard
## Deploying Supabase Grafana
For this guide, we will be using [Grafana](https://grafana.com/docs/grafana/latest/introduction/).
The pre-configured Supabase Grafana Dashboard is an advanced version of the [Dashboard's Database Reports](https://supabase.com/dashboard/project/_/reports/database). It visualizes over 200 database performance and health metrics and is updated every minute.
You can [self-host](https://grafana.com/docs/grafana/latest/setup-grafana/installation/) Grafana or many providers offer managed Grafana, some of which are listed below:
![Supabase Grafana](/docs/img/guides/platform/supabase-grafana-prometheus.png)
- [DigitalOcean](https://marketplace.digitalocean.com/apps/grafana)
- [AWS](https://aws.amazon.com/grafana/)
- [Grafana Cloud](https://grafana.com/grafana/)
### Deploying locally
Follow the guides of the provider you choose to get Grafana up and running.
<Admonition type="note">
This method uses docker to deploy locally but the same steps can be used to deploy to a dedicated
server, such as [Fly](https://fly.io), [Railway](https://railway.app) or [Digital
Ocean](https://digitalocean.com)
### Adding a data source to Grafana
In the left-hand menu, select `Data sources` and click `Add new data source`.
Select `Prometheus` and enter the connection details for the Prometheus instance you have set up.
Under **Interval behavior**, set the **scraping interval** to 60s and test the data source. Once it has passed, save it.
### Adding the Supabase dashboard
In the left-hand menu, select `Dashboards` and click `New`. From the drop-down, select `Import`.
Copy the raw file from our [supabase-grafana](https://raw.githubusercontent.com/supabase/supabase-grafana/refs/heads/main/grafana/dashboard.json) repository and paste it (or upload the file).
Click `Load` and the dashboard will load from the project specified in your Prometheus job.
### Monitoring your project
You can configure alerts from Prometheus or Grafana. The `supabase-grafana` repository has a selection of [example alerts](https://github.com/supabase/supabase-grafana/blob/main/docs/example-alerts.md) that can be configured.
<Admonition type="caution">
Grafana Cloud has an unofficial integration for scraping Supabase metrics. See their
[docs](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-supabase/)
for instructions on how to configure it but note that it is not full-featured nor is it supported
by Supabase.
</Admonition>
Prerequisites:
- Must have [Docker](https://www.docker.com/products/docker-desktop/) or a Docker compatible runtime on your machine
<Admonition type="note">
Supabase Grafana only tracks metrics while it's active. For monitoring historical trends, it's better to use a dedicated server that can record data continuously.
</Admonition>
<StepHikeCompact>
<StepHikeCompact.Step step={1} >
<StepHikeCompact.Details title="Download the project">
Download the code from the [GitHub repo](https://github.com/supabase/supabase-grafana/tree/main)
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```sh
git clone https://github.com/supabase/supabase-grafana.git
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={2} >
<StepHikeCompact.Details title="Create a .env file">
copy the `example.env` into a new `.env`
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```sh
cp .env.example .env
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Add your credentials">
- To monitor a single project, add your service_role key from the [API Settings](https://supabase.com/dashboard/project/_/settings/api) and Project ID from the [General Settings](https://supabase.com/dashboard/project/_/settings/general).
- To monitor multiple projects, create an [account access token](https://supabase.com/dashboard/account/tokens) and add it to your `.env` file.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="single-project"
>
<TabPanel id="single-project" label="Monitor a single project">
- Add the values to your `.env` file:
```text .env
SUPABASE_PROJECT_REF="your_project_reference"
SUPABASE_SERVICE_ROLE_KEY="your_service_role"
```
</TabPanel>
<TabPanel id="monitor-an-org" label="monitor multiple projects">
```text .env
SUPABASE_ACCESS_TOKEN="your_access_token"
```
</TabPanel>
</Tabs>
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={4}>
<StepHikeCompact.Details title="Add a password to your .env">
<Admonition type="note" label="password manager">
For security, consider using a [password generator](https://bitwarden.com/password-generator/)
</Admonition>
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```text .env
GRAFANA_PASSWORD="your password"
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5}>
<StepHikeCompact.Details title="Deploy Grafana with Docker">
Deploy the server. Once it is active, you can go to <a href='http://localhost:8000/login'>localhost:8000/login</a> in the browser
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```text
docker compose up
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={6}>
<StepHikeCompact.Details title="Login" >
Login with your username (default is `admin`) and the password you created in step 4.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
![Grafana login](/docs/img/guides/platform/grafana-login.png)
</StepHikeCompact.Code>
</StepHikeCompact.Step>
</StepHikeCompact>
### Deploying to Grafana cloud
<Admonition type="note">
This section describes the Grafana cloud Supabase template, which is created and maintained by
Grafana.
</Admonition>
<StepHikeCompact>
<StepHikeCompact.Step step={1}>
<StepHikeCompact.Details title="Create a free Grafana Cloud account" >
Go to Grafana\'s [registration form](https://grafana.com/auth/sign-up/create-user?pg=login) and create an account
</StepHikeCompact.Details>
<StepHikeCompact.Code>
![Grafana Registration Page](/docs/img/guides/platform/grafana-registration.png)
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={2} >
<StepHikeCompact.Details title="Give a name to your Grafana project">
Grafana will prompt you to create a stack. Give it a name and continue.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
![Create Grafana Stack](/docs/img/guides/platform/create-grafana-stack.png)
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={3} >
<StepHikeCompact.Details title="Create a new Grafana Dashboard">
Create a new Dashboard
</StepHikeCompact.Details>
<StepHikeCompact.Code>
![Select Grafana Dashboard](/docs/img/guides/platform/select-grafana-dashboard.png)
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={4} >
<StepHikeCompact.Details title="Search for the Supabase template" />
<StepHikeCompact.Code>
![Select Grafana Dashboard](/docs/img/guides/platform/supabase-grafana-template.png)
</StepHikeCompact.Code>
</StepHikeCompact.Step>
<StepHikeCompact.Step step={5} >
<StepHikeCompact.Details title="Add your credentials and deploy">
Add your service_role key from the [API Settings](https://supabase.com/dashboard/project/_/settings/api) and Project ID from the [General Settings](https://supabase.com/dashboard/project/_/settings/general) to your Grafana credentials. Then install your dashboard.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
![Add credentials](/docs/img/guides/platform/add-credentials-grafana.png)
</StepHikeCompact.Code>
</StepHikeCompact.Step>
</StepHikeCompact>
@@ -149,7 +149,15 @@ You can also override the type of an individual successful response if needed:
<RefSubLayout.Examples>
```ts
const { data } = await supabase.from('countries').select().returns<MyType>()
// Partial type override allows you to only override some of the properties in your results
const { data } = await supabase.from('countries').select().overrideTypes<Array<{ id: string }>>()
// For a full replacement of the original return type use the `{ merge: false }` property as second argument
const { data } = await supabase
.from('countries')
.select()
.overrideTypes<Array<{ id: string }>, { merge: false }>()
// Use it with `maybeSingle` or `single`
const { data } = await supabase.from('countries').select().single().overrideTypes<{ id: string }>()
```
</RefSubLayout.Examples>
+93 -5
View File
@@ -1,16 +1,104 @@
'use client'
import { proxy, useSnapshot } from 'valtio'
/**
* The MDXProvider is necessary so that MDX partials will have access
* to components.
*/
import { MDXProvider } from '@mdx-js/react'
import { type PropsWithChildren } from 'react'
import { createContext, useContext, useEffect, useState, type PropsWithChildren } from 'react'
import { components } from '~/features/docs/MdxBase.shared'
import { type AnchorProviderProps, AnchorProvider } from 'ui-patterns'
const MDXProviderGuides = ({ children }: PropsWithChildren) => (
<MDXProvider components={components}>{children}</MDXProvider>
)
interface TOCHeader {
id?: string
text: string
link: string
level: number
}
export { MDXProviderGuides }
const TocAnchorsContext = createContext<AnchorProviderProps | undefined>(undefined)
const useTocAnchors = () => {
const context = useContext(TocAnchorsContext)
if (!context) {
throw new Error('useTocAnchors must be used within an TocAnchorsContext')
}
return context
}
const useTocRerenderTrigger = () => {
const { toggleRenderFlag } = useSnapshot(tocRenderSwitch)
return toggleRenderFlag
}
const tocRenderSwitch = proxy({
renderFlag: 0,
toggleRenderFlag: () => void (tocRenderSwitch.renderFlag = (tocRenderSwitch.renderFlag + 1) % 2),
})
const useSubscribeTocRerender = () => {
const { renderFlag } = useSnapshot(tocRenderSwitch)
return void renderFlag // Prevent it from being detected as unused code
}
const TocAnchorsProvider = ({ children }: PropsWithChildren) => {
const [tocList, setTocList] = useState<TOCHeader[]>([])
useSubscribeTocRerender()
const displayedList = tocList
const toc = displayedList.map((item) => ({
title: item.text,
url: item.link,
depth: item.level,
}))
useEffect(() => {
/**
* Because we're directly querying the DOM, needs the setTimeout so the DOM
* update will happen first.
*/
const timeoutHandle = setTimeout(() => {
const headings = Array.from(
document.querySelector('#sb-docs-guide-main-article')?.querySelectorAll('h2, h3') ?? []
)
const newHeadings = headings
.filter((heading) => heading.id)
.map((heading) => {
const text = heading.textContent.replace('#', '')
const link = heading.querySelector('a')?.getAttribute('href')
if (!link) return null
const level = heading.tagName === 'H2' ? 2 : 3
return { text, link, level } as Partial<TOCHeader>
})
.filter((x): x is TOCHeader => !!x && !!x.text && !!x.link && !!x.level)
setTocList(newHeadings)
})
return () => clearTimeout(timeoutHandle)
/**
* window.location.href needed to recalculate toc when page changes,
* `useSubscribeTocRerender` above will trigger the rerender
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeof window !== 'undefined' && window.location.href])
return (
<TocAnchorsContext.Provider value={{ toc }}>
<AnchorProvider toc={toc} single={false}>
{children}
</AnchorProvider>
</TocAnchorsContext.Provider>
)
}
const MDXProviderGuides = ({ children }: PropsWithChildren) => {
return <MDXProvider components={components}>{children}</MDXProvider>
}
export { MDXProviderGuides, TocAnchorsProvider, useTocAnchors, useTocRerenderTrigger }
+70 -65
View File
@@ -7,7 +7,7 @@ import { cn } from 'ui'
import Breadcrumbs from '~/components/Breadcrumbs'
import GuidesTableOfContents from '~/components/GuidesTableOfContents'
import { MDXProviderGuides } from '~/features/docs/GuidesMdx.client'
import { MDXProviderGuides, TocAnchorsProvider } from '~/features/docs/GuidesMdx.client'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import type { WithRequired } from '~/features/helpers.types'
import { type GuideFrontmatter } from '~/lib/docs'
@@ -64,72 +64,77 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT
const hideToc = meta?.hideToc || meta?.hide_table_of_contents
return (
<div className={'grid grid-cols-12 relative gap-4'}>
<div
className={cn(
'relative',
'transition-all ease-out',
'duration-100',
hideToc ? 'col-span-12' : 'col-span-12 md:col-span-9'
)}
>
<Breadcrumbs className="mb-2" />
<article
// Used to get headings for the table of contents
id="sb-docs-guide-main-article"
className="prose max-w-none"
>
<h1 className="mb-0 [&>p]:m-0">
<ReactMarkdown>{meta?.title || 'Supabase Docs'}</ReactMarkdown>
</h1>
{meta?.subtitle && (
<h2 className="mt-3 text-xl text-foreground-light">
<ReactMarkdown>{meta.subtitle}</ReactMarkdown>
</h2>
)}
<hr className="not-prose border-t-0 border-b my-8" />
<MDXProviderGuides>
{content && <MDXRemoteBase source={content} options={mdxOptions} />}
</MDXProviderGuides>
{children}
<footer className="mt-16 not-prose">
<a
href={
editLink.includesProtocol ? editLink.link : `https://github.com/${editLink.link}`
}
className={cn(
'w-fit',
'flex items-center gap-1',
'text-sm text-scale-1000 hover:text-scale-1200',
'transition-colors'
)}
target="_blank"
rel="noreferrer noopener"
<MDXProviderGuides>
<TocAnchorsProvider>
<div className={'grid grid-cols-12 relative gap-4'}>
<div
className={cn(
'relative',
'transition-all ease-out',
'duration-100',
hideToc ? 'col-span-12' : 'col-span-12 md:col-span-9'
)}
>
<Breadcrumbs className="mb-2" />
<article
// Used to get headings for the table of contents
id="sb-docs-guide-main-article"
className="prose max-w-none"
>
Edit this page on GitHub <ExternalLink size={14} strokeWidth={1.5} />
</a>
</footer>
</article>
</div>
{!hideToc && (
<GuidesTableOfContents
video={meta?.tocVideo}
className={cn(
'hidden md:flex',
'col-span-3 self-start',
'sticky',
/**
* --header-height: height of nav
* 1px: height of nav border
* 2rem: content padding
*/
'top-[calc(var(--header-height)+1px+2rem)]',
// 3rem accounts for 2rem of top padding + 1rem of extra breathing room
'max-h-[calc(100vh-var(--header-height)-3rem)]'
<h1 className="mb-0 [&>p]:m-0">
<ReactMarkdown>{meta?.title || 'Supabase Docs'}</ReactMarkdown>
</h1>
{meta?.subtitle && (
<h2 className="mt-3 text-xl text-foreground-light">
<ReactMarkdown>{meta.subtitle}</ReactMarkdown>
</h2>
)}
<hr className="not-prose border-t-0 border-b my-8" />
{content && <MDXRemoteBase source={content} options={mdxOptions} />}
{children}
<footer className="mt-16 not-prose">
<a
href={
editLink.includesProtocol
? editLink.link
: `https://github.com/${editLink.link}`
}
className={cn(
'w-fit',
'flex items-center gap-1',
'text-sm text-scale-1000 hover:text-scale-1200',
'transition-colors'
)}
target="_blank"
rel="noreferrer noopener"
>
Edit this page on GitHub <ExternalLink size={14} strokeWidth={1.5} />
</a>
</footer>
</article>
</div>
{!hideToc && (
<GuidesTableOfContents
video={meta?.tocVideo}
className={cn(
'hidden md:flex',
'col-span-3 self-start',
'sticky',
/**
* --header-height: height of nav
* 1px: height of nav border
* 2rem: content padding
*/
'top-[calc(var(--header-height)+1px+2rem)]',
// 3rem accounts for 2rem of top padding + 1rem of extra breathing room
'max-h-[calc(100vh-var(--header-height)-3rem)]'
)}
/>
)}
/>
)}
</div>
</div>
</TocAnchorsProvider>
</MDXProviderGuides>
)
}
@@ -31,6 +31,7 @@ const PUBLISHED_SECTIONS = [
'queues',
'realtime',
'resources',
'security',
'self-hosting',
'storage',
'telemetry',
+139 -43
View File
@@ -155,6 +155,69 @@ export interface CustomTypePropertyType {
type: TypeDetails | undefined
}
/**
*
* New versions of `typedoc` added the variant property, so this is a quick and
* dirty way of checking version.
*/
function isNewTypedoc(node: any) {
return 'variant' in node
}
/**
*
* The shape of a new `typedoc` comment. `typedoc` changed their Comment class
* in v0.23.0.
*/
interface TypedocComment {
summary: CommentKind[]
blockTags: CommentBlockTag[]
}
type CommentKind = CommentKindText | CommentKindCode
interface CommentKindText {
kind: 'text'
text: string
}
interface CommentKindCode {
kind: 'code'
text: string
}
interface CommentBlockTag {
/**
* An @ string, e.g., `@returns`
*/
tag: string
content: CommentKind[]
}
function normalizeComment(original: TypedocComment | Comment): Comment {
if (!original) return
if ('shortText' in original || 'text' in original) {
// This is the old comment type, just return it
return original
}
let comment: Comment = {}
if ('summary' in original) {
comment.shortText = original.summary.map((part) => part.text).join('')
}
return comment
}
// The meaning of kind flags from `typedoc`:
// https://github.com/TypeStrong/typedoc/blob/2953b0148253589448176881a7acb46090f941bd/src/lib/output/themes/default/assets/typedoc/Application.ts#L36
const KIND_CLASS = 128
const KIND_CONSTRUCTOR = 512
const KIND_METHOD = 2048
const KIND_TYPE_LITERAL = 65536
export function parseTypeSpec() {
const modules = (typeSpec.children ?? []).map(parseMod)
return modules as Array<ModuleTypes>
@@ -200,30 +263,41 @@ function parseModInternal(
) {
let updatedPath: Array<string>
switch (node.kindString) {
case 'Module':
switch ((node.kindString ?? node.variant)?.toLowerCase()) {
case 'module':
updatedPath = [...currentPath, node.name]
node.children?.forEach((child: any) => parseModInternal(child, map, updatedPath, res))
return
// Some libraries have undefined where others have Project for the same type
// of top-level node.
case 'Project':
// Some libraries have undefined where others have Project or declaration // for the same type of top-level node.
case 'project':
case undefined:
updatedPath = [...currentPath, node.name]
node.children?.forEach((child: any) => parseModInternal(child, map, updatedPath, res))
case 'Class':
return
case 'class':
updatedPath = [...currentPath, node.name]
node.children?.forEach((child: any) => parseModInternal(child, map, updatedPath, res))
return
case 'Constructor':
parseConstructor(node, map, currentPath, res)
case 'Method':
parseMethod(node, map, currentPath, res)
case 'Interface':
case 'constructor':
return parseConstructor(node, map, currentPath, res)
case 'method':
return parseMethod(node, map, currentPath, res)
case 'interface':
updatedPath = [...currentPath, node.name]
node.children?.forEach((child: any) => parseModInternal(child, map, updatedPath, res))
case 'Property':
case 'Reference':
return
case 'declaration':
if (node.kind === KIND_CLASS) {
updatedPath = [...currentPath, node.name]
node.children?.forEach((child: any) => parseModInternal(child, map, updatedPath, res))
} else if (node.kind === KIND_CONSTRUCTOR) {
parseConstructor(node, map, currentPath, res)
} else if (node.kind === KIND_METHOD) {
return parseMethod(node, map, currentPath, res)
}
return
case 'property':
case 'reference':
default:
return undefined
}
@@ -321,7 +395,7 @@ function parseSignature(
}
if (param.comment) {
res.comment = param.comment
res.comment = normalizeComment(param.comment)
}
return res
@@ -340,7 +414,7 @@ function parseSignature(
return {
params,
ret,
comment: signature.comment,
comment: normalizeComment(signature.comment),
}
}
@@ -407,8 +481,8 @@ function delegateParsing(original: any, referenced: any, map: Map<number, any>)
if (original.comment) {
dereferencedType.comment = {
...dereferencedType.comment,
...original.comment,
...normalizeComment(dereferencedType.comment),
...normalizeComment(original.comment),
}
}
@@ -424,22 +498,52 @@ function parseReferenceType(type: any, map: Map<number, any>) {
return delegateParsing(type, type.dereferenced, map)
}
if (type.package === 'typescript' && type.qualifiedName === 'Record') {
if (
type.package === 'typescript' &&
(type.name === 'Record' || type.qualifiedName === 'Record')
) {
return parseRecordType(type, map)
}
if (type.package === 'typescript' && type.qualifiedName === 'Promise') {
if (
type.package === 'typescript' &&
(type.name === 'Promise' || type.qualifiedName === 'Promise')
) {
return parsePromiseType(type, map)
}
if (type.package === 'typescript' && type.qualifiedName === 'Extract') {
if (
type.package === 'typescript' &&
(type.name === 'Extract' || type.qualifiedName === 'Extract')
) {
return parseExtractType(type, map)
}
if (type.package === 'typescript' && type.qualifiedName === 'Pick') {
if (type.package === 'typescript' && (type.name === 'Pick' || type.qualifiedName === 'Pick')) {
return parsePickType(type, map)
}
// If we still haven't produced a meaningful type, try looking up the reference
const referenced = map.get(type.id ?? type.target) // became target in newer versions of tsdoc
if (referenced) {
const maybeType =
typeof referenced.type === 'object' && 'type' in referenced.type
? /* need to go down a level */ delegateParsing(type, referenced.type, map)
: delegateParsing(type, referenced, map)
if (maybeType) {
return maybeType
} else if (isNewTypedoc(referenced) && referenced.kind === KIND_CLASS) {
// Class is too complicated to display here, just return its name
return {
type: 'nameOnly',
name: referenced.name,
}
}
}
// We produced nothing, let's just return some strings in case they're in any
// way meaningful.
if (type.package && type.qualifiedName) {
return {
type: 'nameOnly',
@@ -454,19 +558,6 @@ function parseReferenceType(type: any, map: Map<number, any>) {
return parseType(type.typeArguments[0], map)
}
// If we still haven't produced a meaningful type, try looking up the reference
const referenced = map.get(type.id)
if (referenced) {
const maybeType =
typeof referenced.type === 'object' && 'type' in referenced.type
? /* need to go down a level */ delegateParsing(type, referenced.type, map)
: delegateParsing(type, referenced, map)
if (maybeType) {
return maybeType
}
}
// Final fallback
if (type.name && type.name !== 'default' /* Not particularly meaningful */) {
return {
@@ -556,10 +647,14 @@ function parseReflectionType(type: any, map: Map<number, any>) {
if (!type.declaration) return undefined
let res: TypeDetails
switch (type.declaration.kindString) {
case 'Type literal':
switch ((type.declaration.kindString ?? type.declaration.variant).toLowerCase()) {
case 'type literal':
res = parseTypeLiteral(type, map)
break
case 'declaration':
if (type.declaration.kind === KIND_TYPE_LITERAL) {
res = parseTypeLiteral(type, map)
}
default:
break
}
@@ -590,8 +685,8 @@ function parseTypeLiteral(type: any, map: Map<number, any>): TypeDetails {
}
}
if ('indexSignature' in type.declaration) {
const signature = type.declaration.indexSignature
if ('indexSignature' in type.declaration || 'indexSignatures' in type.declaration) {
const signature = type.declaration.indexSignature ?? type.declaration.indexSignatures[0]
return {
name,
@@ -636,10 +731,11 @@ function parseInterface(type: any, map: Map<number, any>): CustomObjectType {
// properties of an interface.
function parseTypeInternals(elem: any, map: Map<number, any>) {
switch (elem.kindString) {
case 'Property':
switch ((elem.kindString || elem.variant).toLowerCase()) {
case 'property':
case 'declaration':
return parseInternalProperty(elem, map)
case 'Method':
case 'method':
if (elem.signatures?.[0]) {
const { params, ret, comment } = parseSignature(elem.signatures?.[0], map)
const res = {
@@ -651,7 +747,7 @@ function parseTypeInternals(elem: any, map: Map<number, any>) {
} as CustomFunctionType
if (elem.comment) {
res.comment = { ...res.comment, ...elem.comment }
res.comment = { ...normalizeComment(res.comment), ...normalizeComment(elem.comment) }
}
return res
@@ -675,7 +771,7 @@ function parseInternalProperty(elem: any, map: Map<number, any>) {
}
if (elem.comment) {
res.comment = elem.comment
res.comment = normalizeComment(elem.comment)
}
return res
@@ -909,6 +909,24 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
},
"@supabase/auth-js.AuthInvalidJwtError.constructor": {
"name": "@supabase/auth-js.AuthInvalidJwtError.constructor",
"params": [
{
"name": "message",
"type": {
"type": "intrinsic",
"name": "string"
}
}
],
"ret": {
"type": {
"type": "nameOnly",
"name": "AuthInvalidJwtError"
}
}
},
"@supabase/auth-js.AuthInvalidTokenResponseError.constructor": {
"name": "@supabase/auth-js.AuthInvalidTokenResponseError.constructor",
"params": [],
@@ -5735,6 +5753,212 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"shortText": "Log in an existing user by exchanging an Auth Code issued during the PKCE flow."
}
},
"@supabase/auth-js.GoTrueClient.getClaims": {
"name": "@supabase/auth-js.GoTrueClient.getClaims",
"params": [
{
"name": "jwt",
"type": {
"type": "intrinsic",
"name": "string"
},
"isOptional": true
},
{
"name": "jwks",
"type": {
"type": "object",
"properties": [
{
"name": "keys",
"type": {
"type": "array",
"elemType": {
"type": "object",
"name": "JWK",
"properties": [
{
"name": "alg",
"type": {
"type": "intrinsic",
"name": "string"
},
"isOptional": true
},
{
"name": "key_ops",
"type": {
"type": "array",
"elemType": {
"type": "intrinsic",
"name": "string"
}
}
},
{
"name": "kid",
"type": {
"type": "intrinsic",
"name": "string"
},
"isOptional": true
},
{
"name": "kty",
"type": {
"type": "union",
"subTypes": [
{
"type": "literal",
"value": "RSA"
},
{
"type": "literal",
"value": "EC"
},
{
"type": "literal",
"value": "oct"
}
]
}
}
]
}
}
}
]
}
}
],
"ret": {
"type": {
"type": "promise",
"name": "Promise",
"awaited": {
"type": "union",
"subTypes": [
{
"type": "object",
"properties": [
{
"name": "data",
"type": {
"type": "object",
"properties": [
{
"name": "claims",
"type": {
"type": "nameOnly",
"name": "JwtPayload"
}
},
{
"name": "header",
"type": {
"name": "JwtHeader",
"type": "object",
"properties": [
{
"name": "alg",
"type": {
"type": "union",
"subTypes": [
{
"type": "literal",
"value": "RS256"
},
{
"type": "literal",
"value": "ES256"
},
{
"type": "literal",
"value": "HS256"
}
]
}
},
{
"name": "kid",
"type": {
"type": "intrinsic",
"name": "string"
}
},
{
"name": "typ",
"type": {
"type": "intrinsic",
"name": "string"
}
}
]
}
},
{
"name": "signature",
"type": {
"type": "nameOnly",
"name": "Uint8Array"
}
}
]
}
},
{
"name": "error",
"type": {
"type": "literal",
"value": null
}
}
]
},
{
"type": "object",
"properties": [
{
"name": "data",
"type": {
"type": "literal",
"value": null
}
},
{
"name": "error",
"type": {
"type": "nameOnly",
"name": "AuthError"
}
}
]
},
{
"type": "object",
"properties": [
{
"name": "data",
"type": {
"type": "literal",
"value": null
}
},
{
"name": "error",
"type": {
"type": "literal",
"value": null
}
}
]
}
]
}
}
},
"comment": {}
},
"@supabase/auth-js.GoTrueClient.getSession": {
"name": "@supabase/auth-js.GoTrueClient.getSession",
"params": [],
@@ -21308,6 +21532,39 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
},
"@supabase/postgrest-js.PostgrestBuilder.overrideTypes": {
"name": "@supabase/postgrest-js.PostgrestBuilder.overrideTypes",
"params": [],
"comment": {
"shortText": "Override the type of the returned \`data\` field in the response.",
"returns": "A PostgrestBuilder instance with the new type\\n",
"tags": [
{
"tag": "example",
"text": "\\n\`\`\`typescript\\n// Merge with existing types (default behavior)\\nconst query = supabase\\n .from('users')\\n .select()\\n .overrideTypes<{ custom_field: string }>()\\n\\n// Replace existing types completely\\nconst replaceQuery = supabase\\n .from('users')\\n .select()\\n .overrideTypes<{ id: number; name: string }, { merge: false }>()\\n\`\`\`"
}
]
}
},
"@supabase/postgrest-js.PostgrestBuilder.returns": {
"name": "@supabase/postgrest-js.PostgrestBuilder.returns",
"params": [],
"ret": {
"type": {
"type": "nameOnly",
"name": "CheckMatchingArrayTypes"
}
},
"comment": {
"shortText": "Override the type of the returned \`data\`.",
"tags": [
{
"tag": "deprecated",
"text": "Use overrideTypes<yourType, { merge: false }>() method at the end of your call chain instead\\n"
}
]
}
},
"@supabase/postgrest-js.PostgrestBuilder.setHeader": {
"name": "@supabase/postgrest-js.PostgrestBuilder.setHeader",
"params": [
@@ -23903,6 +24160,20 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
]
},
"@supabase/postgrest-js.PostgrestFilterBuilder.overrideTypes": {
"name": "@supabase/postgrest-js.PostgrestFilterBuilder.overrideTypes",
"params": [],
"comment": {
"shortText": "Override the type of the returned \`data\` field in the response.",
"returns": "A PostgrestBuilder instance with the new type\\n",
"tags": [
{
"tag": "example",
"text": "\\n\`\`\`typescript\\n// Merge with existing types (default behavior)\\nconst query = supabase\\n .from('users')\\n .select()\\n .overrideTypes<{ custom_field: string }>()\\n\\n// Replace existing types completely\\nconst replaceQuery = supabase\\n .from('users')\\n .select()\\n .overrideTypes<{ id: number; name: string }, { merge: false }>()\\n\`\`\`"
}
]
}
},
"@supabase/postgrest-js.PostgrestFilterBuilder.range": {
"name": "@supabase/postgrest-js.PostgrestFilterBuilder.range",
"params": [
@@ -24325,7 +24596,13 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Override the type of the returned \`data\`."
"shortText": "Override the type of the returned \`data\`.",
"tags": [
{
"tag": "deprecated",
"text": "Use overrideTypes<yourType, { merge: false }>() method at the end of your call chain instead\\n"
}
]
}
},
"@supabase/postgrest-js.PostgrestFilterBuilder.rollback": {
@@ -25898,6 +26175,20 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
]
},
"@supabase/postgrest-js.PostgrestTransformBuilder.overrideTypes": {
"name": "@supabase/postgrest-js.PostgrestTransformBuilder.overrideTypes",
"params": [],
"comment": {
"shortText": "Override the type of the returned \`data\` field in the response.",
"returns": "A PostgrestBuilder instance with the new type\\n",
"tags": [
{
"tag": "example",
"text": "\\n\`\`\`typescript\\n// Merge with existing types (default behavior)\\nconst query = supabase\\n .from('users')\\n .select()\\n .overrideTypes<{ custom_field: string }>()\\n\\n// Replace existing types completely\\nconst replaceQuery = supabase\\n .from('users')\\n .select()\\n .overrideTypes<{ id: number; name: string }, { merge: false }>()\\n\`\`\`"
}
]
}
},
"@supabase/postgrest-js.PostgrestTransformBuilder.range": {
"name": "@supabase/postgrest-js.PostgrestTransformBuilder.range",
"params": [
@@ -25975,7 +26266,13 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Override the type of the returned \`data\`."
"shortText": "Override the type of the returned \`data\`.",
"tags": [
{
"tag": "deprecated",
"text": "Use overrideTypes<yourType, { merge: false }>() method at the end of your call chain instead\\n"
}
]
}
},
"@supabase/postgrest-js.PostgrestTransformBuilder.rollback": {
@@ -26261,6 +26558,9 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"type": {
"type": "intrinsic",
"name": "string"
},
"comment": {
"shortText": "Topic name can be any string."
}
},
{
@@ -26340,9 +26640,19 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
{
"name": "socket"
"name": "socket",
"type": {
"type": "nameOnly",
"name": "RealtimeClient"
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
}
},
"@supabase/realtime-js.RealtimeChannel.on": {
"name": "@supabase/realtime-js.RealtimeChannel.on",
@@ -26383,6 +26693,12 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
},
@@ -26464,7 +26780,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
}
},
{
"params": [
@@ -26543,7 +26868,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
}
},
{
"params": [
@@ -26616,9 +26950,18 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"type": "union",
"name": "RealtimePostgresChangesPayload",
"subTypes": [
null,
null,
null
{
"type": "nameOnly",
"name": "RealtimePostgresInsertPayload"
},
{
"type": "nameOnly",
"name": "RealtimePostgresUpdatePayload"
},
{
"type": "nameOnly",
"name": "RealtimePostgresDeletePayload"
}
]
}
}
@@ -26631,7 +26974,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
}
},
{
"params": [
@@ -26714,7 +27066,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
}
},
{
"params": [
@@ -26797,7 +27158,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
}
},
{
"params": [
@@ -26880,7 +27250,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
}
},
{
"params": [
@@ -26948,10 +27327,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Function to be invoked when event handler is triggered.\\n"
"shortText": "Function to be invoked when event handler is triggered."
}
}
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "The following is placed here to display on supabase.com/docs/reference/javascript/subscribe."
}
@@ -27023,7 +27408,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
}
},
{
"params": [
@@ -27058,7 +27452,16 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Creates an event handler that listens to changes."
}
}
]
},
@@ -27155,7 +27558,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Options to be used during the send process\\n"
"shortText": "Options to be used during the send process"
}
}
],
@@ -27228,6 +27631,12 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
}
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
},
"comment": {
"shortText": "Subscribe registers your client with the server"
}
@@ -27324,8 +27733,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Leaves the channel.",
"text": "Unsubscribes from server events, and instructs channel to terminate on server.\\nTriggers onClose() hooks.\\n\\nTo receive leave acknowledgements, use the a \`receive\` hook to bind to the server ack, ie:\\nchannel.unsubscribe().receive(\\"ok\\", () => alert(\\"left!\\") )\\n"
"shortText": "Leaves the channel.\\n\\nUnsubscribes from server events, and instructs channel to terminate on server.\\nTriggers onClose() hooks.\\n\\nTo receive leave acknowledgements, use the a \`receive\` hook to bind to the server ack, ie:\\nchannel.unsubscribe().receive(\\"ok\\", () => alert(\\"left!\\") )"
}
},
"@supabase/realtime-js.RealtimeChannel.untrack": {
@@ -27415,6 +27823,33 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"name": "RealtimeClientOptions",
"type": "object",
"properties": [
{
"name": "accessToken",
"type": {
"type": "function",
"params": [],
"ret": {
"type": {
"type": "promise",
"name": "Promise",
"awaited": {
"type": "union",
"subTypes": [
{
"type": "intrinsic",
"name": "string"
},
{
"type": "literal",
"value": null
}
]
}
}
}
},
"isOptional": true
},
{
"name": "decode",
"type": {
@@ -27465,25 +27900,8 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
{
"name": "log_level",
"type": {
"type": "union",
"subTypes": [
{
"type": "literal",
"value": "info"
},
{
"type": "literal",
"value": "debug"
},
{
"type": "literal",
"value": "warn"
},
{
"type": "literal",
"value": "error"
}
]
"type": "nameOnly",
"name": "LogLevel"
},
"isOptional": true
},
@@ -27495,6 +27913,14 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
},
"isOptional": true
},
{
"name": "logLevel",
"type": {
"type": "nameOnly",
"name": "LogLevel"
},
"isOptional": true
},
{
"name": "params",
"type": {
@@ -27549,65 +27975,20 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"name": "string"
},
"isOptional": true
},
{
"type": "function",
"name": "accessToken",
"params": [],
"ret": {
"type": {
"type": "promise",
"name": "Promise",
"awaited": {
"type": "union",
"subTypes": [
{
"type": "literal",
"value": null
},
{
"type": "intrinsic",
"name": "string"
}
]
}
}
}
}
]
},
"isOptional": true
}
],
"comment": {
"shortText": "Initializes the Socket."
}
},
"@supabase/realtime-js.RealtimeClient._workerObjectUrl": {
"name": "@supabase/realtime-js.RealtimeClient._workerObjectUrl",
"params": [
{
"name": "url",
"type": {
"type": "union",
"subTypes": [
{
"type": "intrinsic",
"name": "undefined"
},
{
"type": "intrinsic",
"name": "string"
}
]
}
}
],
"ret": {
"type": {
"type": "intrinsic",
"name": "string"
"type": "nameOnly",
"name": "RealtimeClient"
}
},
"comment": {
"shortText": "Initializes the Socket."
}
},
"@supabase/realtime-js.RealtimeClient.channel": {
@@ -27696,7 +28077,13 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
]
}
}
]
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
}
},
"@supabase/realtime-js.RealtimeClient.connect": {
"name": "@supabase/realtime-js.RealtimeClient.connect",
@@ -27746,7 +28133,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
},
"isOptional": true,
"comment": {
"shortText": "A custom reason for the disconnect.\\n"
"shortText": "A custom reason for the disconnect."
}
}
],
@@ -27770,8 +28157,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Returns the URL of the websocket.",
"returns": "string The URL of the websocket.\\n"
"shortText": "Returns the URL of the websocket."
}
},
"@supabase/realtime-js.RealtimeClient.flushSendBuffer": {
@@ -27792,7 +28178,11 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"params": [],
"ret": {
"type": {
"type": "array"
"type": "array",
"elemType": {
"type": "nameOnly",
"name": "RealtimeChannel"
}
}
},
"comment": {
@@ -27845,8 +28235,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Logs the message.",
"text": "For customized logging, \`this.logger\` can be overridden.\\n"
"shortText": "Logs the message.\\n\\nFor customized logging, \`this.logger\` can be overridden."
}
},
"@supabase/realtime-js.RealtimeClient.push": {
@@ -27905,8 +28294,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Push out a message if the socket is connected.",
"text": "If the socket is not connected, the message gets enqueued within a local buffer, and sent out when a connection is next established.\\n"
"shortText": "Push out a message if the socket is connected.\\n\\nIf the socket is not connected, the message gets enqueued within a local buffer, and sent out when a connection is next established."
}
},
"@supabase/realtime-js.RealtimeClient.removeAllChannels": {
@@ -27948,8 +28336,12 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"params": [
{
"name": "channel",
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
},
"comment": {
"shortText": "A RealtimeChannel instance\\n"
"shortText": "A RealtimeChannel instance"
}
}
],
@@ -28017,7 +28409,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
]
},
"comment": {
"shortText": "A JWT string to override the token set on the client.\\n"
"shortText": "A JWT string to override the token set on the client."
}
}
],
@@ -28032,8 +28424,7 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
}
},
"comment": {
"shortText": "Sets the JWT access token used for channel subscription authorization and Realtime RLS.",
"text": "If param is null it will use the \`accessToken\` callback function or the token set on the client.\\n\\nOn callback used, it will set the value of the token internal to the client.\\n"
"shortText": "Sets the JWT access token used for channel subscription authorization and Realtime RLS.\\n\\nIf param is null it will use the \`accessToken\` callback function or the token set on the client.\\n\\nOn callback used, it will set the value of the token internal to the client."
}
},
"@supabase/realtime-js.RealtimePresence.constructor": {
@@ -28041,6 +28432,10 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"params": [
{
"name": "channel",
"type": {
"type": "nameOnly",
"name": "RealtimeChannel"
},
"comment": {
"shortText": "The RealtimeChannel"
}
@@ -28049,14 +28444,20 @@ exports[`TS type spec parsing > matches snapshot 1`] = `
"name": "opts",
"type": {
"type": "nameOnly",
"name": "@types/phoenix.PresenceOpts"
"name": "PresenceOpts"
},
"isOptional": true,
"comment": {
"shortText": "The options,\\n for example \`{events: {state: 'state', diff: 'diff'}}\`\\n"
"shortText": "The options,\\n for example \`{events: {state: 'state', diff: 'diff'}}\`"
}
}
],
"ret": {
"type": {
"type": "nameOnly",
"name": "RealtimePresence"
}
},
"comment": {
"shortText": "Initializes the Presence."
}
+1 -1
View File
@@ -3,7 +3,7 @@
import { useCallback, type ComponentPropsWithoutRef, type PropsWithChildren } from 'react'
import { Tabs as TabsPrimitive, type TabsProps } from 'ui'
import { withQueryParams, withSticky, type QueryParamsProps } from 'ui-patterns/ComplexTabs'
import { useTocRerenderTrigger } from '~/components/GuidesTableOfContents'
import { useTocRerenderTrigger } from '~/features/docs/GuidesMdx.client'
const TabsWithStickyAndQueryParams = withSticky<PropsWithChildren<TabsProps & QueryParamsProps>>(
withQueryParams(TabsPrimitive)
-2
View File
@@ -1,5 +1,4 @@
import { FC } from 'react'
import { FooterHelpCalloutType } from '~/components/FooterHelpCallout'
import { type MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
import GuideLayout from './guides'
@@ -9,7 +8,6 @@ interface Props {
description?: string
hide_table_of_contents?: boolean
video?: string
footerHelpType?: FooterHelpCalloutType
}
children: any
toc?: any
+6 -4
View File
@@ -6,11 +6,11 @@ import { memo, useEffect, type PropsWithChildren, type ReactNode } from 'react'
import { cn } from 'ui'
import { type NavMenuSection } from '~/components/Navigation/Navigation.types'
import DefaultNavigationMenu, {
MenuId,
} from '~/components/Navigation/NavigationMenu/NavigationMenu'
import { getMenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu.utils'
import { type NavMenuSection } from '~/components/Navigation/Navigation.types'
import TopNavBar from '~/components/Navigation/NavigationMenu/TopNavBar'
import { DOCS_CONTENT_CONTAINER_ID } from '~/features/ui/helpers.constants'
import { menuState, useMenuMobileOpen } from '~/hooks/useMenuState'
@@ -78,6 +78,10 @@ const levelsData = {
icon: 'reference-cli',
name: 'Local Development',
},
security: {
icon: 'platform',
name: 'Security',
},
platform: {
icon: 'platform',
name: 'Platform',
@@ -381,8 +385,6 @@ function SidebarSkeleton({
const mobileMenuOpen = useMenuMobileOpen()
console.log('menuName', menuName, menuId)
return (
<div className={cn('flex flex-row h-full relative', className)}>
{!hideSideNav && (
@@ -425,4 +427,4 @@ function SidebarSkeleton({
)
}
export { TopNavSkeleton, SidebarSkeleton }
export { SidebarSkeleton, TopNavSkeleton }
+2 -2
View File
@@ -74,7 +74,7 @@ const nextConfig = {
headers: [
{
key: 'Strict-Transport-Security',
value: process.env.VERCEL === '1' ? 'max-age=1200; includeSubDomains' : '',
value: process.env.VERCEL === '1' ? 'max-age=31536000; includeSubDomains; preload' : '',
},
{
key: 'X-Robots-Tag',
@@ -97,7 +97,7 @@ const nextConfig = {
headers: [
{
key: 'Strict-Transport-Security',
value: process.env.VERCEL === '1' ? 'max-age=1200; includeSubDomains' : '',
value: process.env.VERCEL === '1' ? 'max-age=31536000; includeSubDomains; preload' : '',
},
{
key: 'X-Robots-Tag',
+3 -3
View File
@@ -38,9 +38,9 @@
"@mdx-js/react": "^2.3.0",
"@next/bundle-analyzer": "^14.2.3",
"@next/mdx": "^14.2.3",
"@octokit/auth-app": "^6.0.3",
"@octokit/core": "^5.1.0",
"@octokit/graphql": "^7.0.2",
"@octokit/auth-app": "^7.0.0",
"@octokit/core": "^6.0.0",
"@octokit/graphql": "^8.0.0",
"@octokit/plugin-paginate-graphql": "^4.0.0",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-collapsible": "^1.0.3",
+189 -1
View File
@@ -1651,6 +1651,128 @@
"security": [{ "bearer": [] }]
}
},
"/v1/projects/{ref}/config/auth/signing-keys": {
"post": {
"operationId": "createSigningKeyForProject",
"summary": "[Alpha] Create a new signing key for the project in standby status",
"parameters": [
{ "name": "ref", "required": true, "in": "path", "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateSigningKeyBody" }
}
}
},
"responses": {
"201": {
"description": "[Alpha] Create a new signing key for the project in standby status",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateSigningKeyBody" }
}
}
},
"403": { "description": "" }
},
"tags": ["Auth"],
"security": [{ "bearer": [] }]
},
"get": {
"operationId": "listSigningKeysForProject",
"summary": "[Alpha] List all signing keys for the project",
"parameters": [
{ "name": "ref", "required": true, "in": "path", "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SigningKeysResponse" }
}
}
},
"403": { "description": "" }
},
"tags": ["Auth"],
"security": [{ "bearer": [] }]
}
},
"/v1/projects/{ref}/config/auth/signing-keys/{id}": {
"get": {
"operationId": "getSigningKeyForProject",
"summary": "[Alpha] Get information about a signing key",
"parameters": [
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "ref", "required": true, "in": "path", "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SigningKeyResponse" }
}
}
}
},
"tags": ["Auth"],
"security": [{ "bearer": [] }]
},
"delete": {
"operationId": "deleteSigningKey",
"summary": "[Alpha] Remove a signing key from a project, where the status is previously_used",
"parameters": [
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "ref", "required": true, "in": "path", "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SigningKeyResponse" }
}
}
},
"403": { "description": "" }
},
"tags": ["Auth"],
"security": [{ "bearer": [] }]
},
"patch": {
"operationId": "patchSigningKey",
"summary": "[Alpha] Update a signing key, mainly its status",
"parameters": [
{ "name": "id", "required": true, "in": "path", "schema": { "type": "string" } },
{ "name": "ref", "required": true, "in": "path", "schema": { "type": "string" } }
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UpdateSigningKeyBody" }
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/SigningKeyResponse" }
}
}
},
"403": { "description": "" }
},
"tags": ["Auth"],
"security": [{ "bearer": [] }]
}
},
"/v1/projects/{ref}/config/storage": {
"get": {
"operationId": "v1-get-storage-config",
@@ -3569,7 +3691,11 @@
},
"SecretResponse": {
"type": "object",
"properties": { "name": { "type": "string" }, "value": { "type": "string" } },
"properties": {
"name": { "type": "string" },
"value": { "type": "string" },
"updated_at": { "type": "string" }
},
"required": ["name", "value"]
},
"CreateSecretBody": {
@@ -3806,6 +3932,68 @@
},
"required": ["name", "healthy", "status"]
},
"CreateSigningKeyBody": {
"type": "object",
"properties": {
"algorithm": { "type": "string", "enum": ["EdDSA", "ES256", "RS256", "HS256"] },
"status": { "type": "string", "enum": ["in_use", "standby"] }
},
"required": ["algorithm"],
"additionalProperties": false
},
"SigningKeysResponse": {
"type": "object",
"properties": {
"keys": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"algorithm": { "type": "string", "enum": ["EdDSA", "ES256", "RS256", "HS256"] },
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
},
"public_jwk": { "nullable": true },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }
},
"required": ["id", "algorithm", "status", "created_at", "updated_at"],
"additionalProperties": false
}
}
},
"required": ["keys"],
"additionalProperties": false
},
"SigningKeyResponse": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"algorithm": { "type": "string", "enum": ["EdDSA", "ES256", "RS256", "HS256"] },
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
},
"public_jwk": { "nullable": true },
"created_at": { "type": "string", "format": "date-time" },
"updated_at": { "type": "string", "format": "date-time" }
},
"required": ["id", "algorithm", "status", "created_at", "updated_at"],
"additionalProperties": false
},
"UpdateSigningKeyBody": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
}
},
"required": ["status"],
"additionalProperties": false
},
"StorageFeatureImageTransformation": {
"type": "object",
"properties": { "enabled": { "type": "boolean" } },
@@ -376,6 +376,14 @@
"parent": "modifiers",
"type": "function"
},
{
"id": "overrideTypes",
"title": "Partially override or replace type of successful response",
"slug": "db-overrideTypes",
"product": "database",
"parent": "modifiers",
"type": "function"
},
{
"id": "explain",
"title": "Using explain",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+25
View File
@@ -1219,6 +1219,31 @@
}
}
},
"/object/list-v2/{bucketName}": {
"post": {
"summary": "Search for objects under a prefix",
"tags": ["object"],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"prefix": { "type": "string", "examples": ["folder/subfolder"] },
"limit": { "type": "integer", "minimum": 1, "examples": [10] },
"cursor": { "type": "string" },
"with_delimiter": { "type": "boolean" }
}
}
}
}
},
"parameters": [
{ "schema": { "type": "string" }, "in": "path", "name": "bucketName", "required": true }
],
"responses": { "200": { "description": "Default Response" } }
}
},
"/object/list/{bucketName}": {
"post": {
"summary": "Search for objects under a prefix",
+85
View File
@@ -6593,6 +6593,8 @@ functions:
- id: returns
$ref: '@supabase/postgrest-js.PostgrestTransformBuilder.returns'
notes: |
- Deprecated: use overrideTypes method instead
title: returns()
examples:
- id: override-type-of-successful-response
@@ -6602,6 +6604,22 @@ functions:
const { data } = await supabase
.from('countries')
.select()
.returns<Array<MyType>>()
```
response: |
```
let x: typeof data // MyType[]
```
hideCodeBlock: true
isSpotlight: true
- id: override-type-of-object-response
name: Override type of object response
code: |
```ts
const { data } = await supabase
.from('countries')
.select()
.maybeSingle()
.returns<MyType>()
```
response: |
@@ -6611,6 +6629,73 @@ functions:
hideCodeBlock: true
isSpotlight: true
- id: overrideTypes
$ref: '@supabase/postgrest-js.PostgrestBuilder.overrideTypes'
title: overrideTypes()
examples:
- id: complete-override-type-of-successful-response
name: Complete Override type of successful response
code: |
```ts
const { data } = await supabase
.from('countries')
.select()
.overrideTypes<Array<MyType>, { merge: false }>()
```
response: |
```ts
let x: typeof data // MyType[]
```
hideCodeBlock: true
isSpotlight: true
- id: complete-override-type-of-object-response
name: Complete Override type of object response
code: |
```ts
const { data } = await supabase
.from('countries')
.select()
.maybeSingle()
.overrideTypes<MyType, { merge: false }>()
```
response: |
```ts
let x: typeof data // MyType | null
```
hideCodeBlock: true
isSpotlight: true
- id: partial-override-type-of-successful-response
name: Partial Override type of successful response
code: |
```ts
const { data } = await supabase
.from('countries')
.select()
.overrideTypes<Array<{ status: "A" | "B" }>>()
```
response: |
```ts
let x: typeof data // Array<CountryRowProperties & { status: "A" | "B" }>
```
hideCodeBlock: true
isSpotlight: true
- id: partial-override-type-of-object-response
name: Partial Override type of object response
code: |
```ts
const { data } = await supabase
.from('countries')
.select()
.maybeSingle()
.overrideTypes<{ status: "A" | "B" }>()
```
response: |
```ts
let x: typeof data // CountryRowProperties & { status: "A" | "B" } | null
```
hideCodeBlock: true
isSpotlight: true
- id: explain
$ref: '@supabase/postgrest-js.PostgrestTransformBuilder.explain'
title: Using Explain
+4 -4
View File
@@ -26,7 +26,7 @@ functions:
code: |
```swift
import Supabase
let client = SupabaseClient(supabaseURL: URL(string: "https://xyzcompany.supabase.co")!, supabaseKey: "public-anon-key")
```
- id: initialize-client-custom-options
@@ -34,7 +34,7 @@ functions:
code: |
```swift
import Supabase
let client = SupabaseClient(
supabaseURL: URL(string: "https://xyzcompany.supabase.co")!,
supabaseKey: "public-anon-key",
@@ -58,7 +58,7 @@ functions:
code: |
```swift
import Supabase
struct AppLogger: SupabaseLogger {
func log(message: SupabaseLogMessage) {
print(message.description)
@@ -80,7 +80,7 @@ functions:
code: |
```swift
import Supabase
let supabase = SupabaseClient(
supabaseURL: URL(string: "https://xyzcompany.supabase.co")!,
supabaseKey: "public-anon-key",
@@ -3592,6 +3592,9 @@
},
"value": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"required": ["name", "value"]
@@ -4756,6 +4759,376 @@
]
}
},
"/v1/projects/{ref}/config/auth/signing-keys": {
"post": {
"operationId": "createSigningKeyForProject",
"summary": "[Alpha] Create a new signing key for the project in standby status",
"parameters": [
{
"name": "ref",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "standby"]
}
},
"required": ["algorithm"],
"additionalProperties": false
}
}
}
},
"responses": {
"201": {
"description": "[Alpha] Create a new signing key for the project in standby status",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "standby"]
}
},
"required": ["algorithm"],
"additionalProperties": false
}
}
}
},
"403": {
"description": ""
}
},
"tags": ["Auth"],
"security": [
{
"bearer": []
}
]
},
"get": {
"operationId": "listSigningKeysForProject",
"summary": "[Alpha] List all signing keys for the project",
"parameters": [
{
"name": "ref",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"keys": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
},
"public_jwk": {
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
},
"required": ["id", "algorithm", "status", "created_at", "updated_at"],
"additionalProperties": false
}
}
},
"required": ["keys"],
"additionalProperties": false
}
}
}
},
"403": {
"description": ""
}
},
"tags": ["Auth"],
"security": [
{
"bearer": []
}
]
}
},
"/v1/projects/{ref}/config/auth/signing-keys/{id}": {
"get": {
"operationId": "getSigningKeyForProject",
"summary": "[Alpha] Get information about a signing key",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "ref",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
},
"public_jwk": {
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
},
"required": ["id", "algorithm", "status", "created_at", "updated_at"],
"additionalProperties": false
}
}
}
}
},
"tags": ["Auth"],
"security": [
{
"bearer": []
}
]
},
"delete": {
"operationId": "deleteSigningKey",
"summary": "[Alpha] Remove a signing key from a project, where the status is previously_used",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "ref",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
},
"public_jwk": {
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
},
"required": ["id", "algorithm", "status", "created_at", "updated_at"],
"additionalProperties": false
}
}
}
},
"403": {
"description": ""
}
},
"tags": ["Auth"],
"security": [
{
"bearer": []
}
]
},
"patch": {
"operationId": "patchSigningKey",
"summary": "[Alpha] Update a signing key, mainly its status",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "ref",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
}
},
"required": ["status"],
"additionalProperties": false
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
},
"public_jwk": {
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
},
"required": ["id", "algorithm", "status", "created_at", "updated_at"],
"additionalProperties": false
}
}
}
},
"403": {
"description": ""
}
},
"tags": ["Auth"],
"security": [
{
"bearer": []
}
]
}
},
"/v1/projects/{ref}/config/storage": {
"get": {
"operationId": "v1-get-storage-config",
@@ -12284,6 +12657,12 @@
},
"value": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"updated_at": {
"type": "string"
}
},
"required": ["name", "value"]
@@ -12733,6 +13112,102 @@
},
"required": ["name", "healthy", "status"]
},
"CreateSigningKeyBody": {
"type": "object",
"properties": {
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "standby"]
}
},
"required": ["algorithm"],
"additionalProperties": false
},
"SigningKeysResponse": {
"type": "object",
"properties": {
"keys": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
},
"public_jwk": {
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
},
"required": ["id", "algorithm", "status", "created_at", "updated_at"],
"additionalProperties": false
}
}
},
"required": ["keys"],
"additionalProperties": false
},
"SigningKeyResponse": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"algorithm": {
"type": "string",
"enum": ["EdDSA", "ES256", "RS256", "HS256"]
},
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
},
"public_jwk": {
"nullable": true
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
},
"required": ["id", "algorithm", "status", "created_at", "updated_at"],
"additionalProperties": false
},
"UpdateSigningKeyBody": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["in_use", "previously_used", "revoked", "standby"]
}
},
"required": ["status"],
"additionalProperties": false
},
"StorageFeatureImageTransformation": {
"type": "object",
"properties": {
@@ -1874,6 +1874,53 @@
}
}
},
"/object/list-v2/{bucketName}": {
"post": {
"summary": "Search for objects under a prefix",
"tags": ["object"],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"prefix": {
"type": "string",
"examples": ["folder/subfolder"]
},
"limit": {
"type": "integer",
"minimum": 1,
"examples": [10]
},
"cursor": {
"type": "string"
},
"with_delimiter": {
"type": "boolean"
}
}
}
}
}
},
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "bucketName",
"required": true
}
],
"responses": {
"200": {
"description": "Default Response"
}
}
}
},
"/object/list/{bucketName}": {
"post": {
"summary": "Search for objects under a prefix",
+3 -3
View File
@@ -166,9 +166,9 @@ pre[class*='language-'] {
}
// ToC styles
.toc__menu-item--active {
color: hsl(var(--brand-default)) !important;
}
// .toc__menu-item--active {
// color: hsl(var(--brand-default)) !important;
// }
.video-container {
position: relative;
@@ -1,15 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect, useMemo } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import { StringNumberOrNull } from 'components/ui/Forms/Form.constants'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
@@ -22,15 +19,19 @@ import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
PrePostTab,
WarningIcon,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const formId = 'auth-config-advanced-form'
import { StringNumberOrNull } from 'components/ui/Forms/Form.constants'
const FormSchema = z.object({
API_MAX_REQUEST_DURATION: z.coerce
@@ -46,69 +47,110 @@ export const AdvancedAuthSettingsForm = () => {
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const [isUpdatingRequestDurationForm, setIsUpdatingRequestDurationForm] = useState(false)
const [isUpdatingDatabaseForm, setIsUpdatingDatabaseForm] = useState(false)
const {
data: authConfig,
error: authConfigError,
isLoading,
isError,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({
orgSlug: organization?.slug,
})
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const defaultValues = useMemo(
() => ({
API_MAX_REQUEST_DURATION: authConfig?.API_MAX_REQUEST_DURATION ?? undefined,
DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE ?? '',
}),
[authConfig]
)
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: defaultValues as any,
})
const { isDirty } = form.formState
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
const isTeamsEnterprisePlan =
isSuccessSubscription && subscription.plan.id !== 'free' && subscription.plan.id !== 'pro'
isSuccessSubscription && subscription?.plan.id !== 'free' && subscription?.plan.id !== 'pro'
const promptTeamsEnterpriseUpgrade =
IS_PLATFORM && isSuccessSubscription && !isTeamsEnterprisePlan
const resetForm = () => {
form.reset(defaultValues as any)
}
const requestDurationForm = useForm({
resolver: zodResolver(
z.object({
API_MAX_REQUEST_DURATION: FormSchema.shape.API_MAX_REQUEST_DURATION,
})
),
defaultValues: {
API_MAX_REQUEST_DURATION: 10,
},
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = (data) => {
if (!projectRef) return console.error('Project ref is required')
const databaseForm = useForm({
resolver: zodResolver(
z.object({
DB_MAX_POOL_SIZE: FormSchema.shape.DB_MAX_POOL_SIZE,
})
),
defaultValues: {
DB_MAX_POOL_SIZE: '',
},
})
const { API_MAX_REQUEST_DURATION, DB_MAX_POOL_SIZE, ...config } = data
const payload = {
...config,
...(isTeamsEnterprisePlan ? { API_MAX_REQUEST_DURATION } : {}),
DB_MAX_POOL_SIZE,
useEffect(() => {
if (authConfig) {
if (!isUpdatingRequestDurationForm) {
requestDurationForm.reset({
API_MAX_REQUEST_DURATION: authConfig?.API_MAX_REQUEST_DURATION ?? 10,
})
}
if (!isUpdatingDatabaseForm) {
databaseForm.reset({
DB_MAX_POOL_SIZE:
authConfig?.DB_MAX_POOL_SIZE !== null ? String(authConfig?.DB_MAX_POOL_SIZE) : '',
})
}
}
}, [authConfig, isUpdatingRequestDurationForm, isUpdatingDatabaseForm])
const onSubmitRequestDurationForm = (values: any) => {
if (!projectRef) return console.error('Project ref is required')
if (!isTeamsEnterprisePlan) return
setIsUpdatingRequestDurationForm(true)
updateAuthConfig(
// @ts-expect-error
{ projectRef: projectRef, config: payload },
{ projectRef: projectRef, config: values },
{
onError: (error) => {
toast.error(`Failed to update request duration settings: ${error?.message}`)
setIsUpdatingRequestDurationForm(false)
},
onSuccess: () => {
toast.success('Successfully updated settings')
form.reset({ ...defaultValues, ...data })
toast.success('Successfully updated request duration settings')
setIsUpdatingRequestDurationForm(false)
},
}
)
}
useEffect(() => {
// reset form with the current values
if (isSuccess) resetForm()
}, [defaultValues, isSuccess])
const onSubmitDatabaseForm = (values: any) => {
if (!projectRef) return console.error('Project ref is required')
setIsUpdatingDatabaseForm(true)
const config = {
DB_MAX_POOL_SIZE: values.DB_MAX_POOL_SIZE,
}
updateAuthConfig(
{ projectRef: projectRef, config },
{
onError: (error) => {
toast.error(`Failed to update database connection settings: ${error?.message}`)
setIsUpdatingDatabaseForm(false)
},
onSuccess: () => {
toast.success('Successfully updated database connection settings')
setIsUpdatingDatabaseForm(false)
},
}
)
}
if (isError) {
return (
@@ -127,88 +169,126 @@ export const AdvancedAuthSettingsForm = () => {
return (
<>
{promptTeamsEnterpriseUpgrade && (
<UpgradeToPro
primaryText="Upgrade to Team or Enterprise"
secondaryText="Advanced Auth server settings are only available on the Team Plan and up."
buttonText="Upgrade to Team"
/>
<div className="my-4">
<UpgradeToPro
primaryText="Upgrade to Team or Enterprise"
secondaryText="Advanced Auth server settings are only available on the Team Plan and up."
buttonText="Upgrade to Team"
/>
</div>
)}
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={isDirty}
handleReset={() => resetForm()}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Request Duration</ScaffoldSectionTitle>
<Form_Shadcn_ {...requestDurationForm}>
<form
onSubmit={requestDurationForm.handleSubmit(onSubmitRequestDurationForm)}
className="space-y-4"
>
<FormSection header={<FormSectionLabel>Max Request Duration</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<Card>
<CardContent className="pt-6">
<FormField_Shadcn_
control={form.control}
control={requestDurationForm.control}
name="API_MAX_REQUEST_DURATION"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Maximum time allowed for an Auth request to last"
description="Number of seconds to wait for an Auth request to complete before canceling it. In certain high-load situations setting a larger or smaller value can be used to control load-shedding. Recommended: 10 seconds."
>
<FormControl_Shadcn_>
<div className="relative">
<Input_Shadcn_
type="number"
disabled={!canUpdateConfig || promptTeamsEnterpriseUpgrade}
placeholder="10"
{...field}
/>
<span className="text-sm text-foreground-lighter absolute top-1.5 right-3">
seconds
</span>
<PrePostTab postTab="seconds">
<Input_Shadcn_
type="number"
min={5}
max={30}
{...field}
disabled={!canUpdateConfig || promptTeamsEnterpriseUpgrade}
/>
</PrePostTab>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
</CardContent>
<FormSection header={<FormSectionLabel>Auth Database Connections</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<CardFooter className="justify-end space-x-2">
{requestDurationForm.formState.isDirty && (
<Button type="default" onClick={() => requestDurationForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig ||
isUpdatingRequestDurationForm ||
!requestDurationForm.formState.isDirty ||
promptTeamsEnterpriseUpgrade
}
loading={isUpdatingRequestDurationForm}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Auth Database Connections</ScaffoldSectionTitle>
<Form_Shadcn_ {...databaseForm}>
<form onSubmit={databaseForm.handleSubmit(onSubmitDatabaseForm)} className="space-y-4">
<Card>
<CardContent className="pt-6">
<FormField_Shadcn_
control={form.control}
control={databaseForm.control}
name="DB_MAX_POOL_SIZE"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Max Direct Auth Connections"
description="Auth will take up no more than this number of connections from the total number of available connections to serve requests. These connections are not reserved, so when unused they are released. Defaults to 10 connections."
>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
disabled={!canUpdateConfig || promptTeamsEnterpriseUpgrade}
placeholder="10"
{...field}
value={field.value || ''}
disabled={!canUpdateConfig || promptTeamsEnterpriseUpgrade}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</FormSectionContent>
</FormSection>
</FormPanel>
</form>
</Form_Shadcn_>
</CardContent>
<CardFooter className="justify-end space-x-2">
{databaseForm.formState.isDirty && (
<Button type="default" onClick={() => databaseForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig || isUpdatingDatabaseForm || !databaseForm.formState.isDirty
}
loading={isUpdatingDatabaseForm}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
</>
)
}
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Check } from 'lucide-react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { toast } from 'sonner'
@@ -15,6 +15,7 @@ import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { BASE_PATH } from 'lib/constants'
import { useQueryState } from 'nuqs'
import { Button, Form, Input, Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from 'ui'
import { Admonition } from 'ui-patterns'
import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
@@ -29,8 +30,10 @@ export interface ProviderFormProps {
}
export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) => {
const { ref: projectRef } = useParams()
const [urlProvider, setUrlProvider] = useQueryState('provider', { defaultValue: '' })
const [open, setOpen] = useState(false)
const { ref: projectRef, provider: urlProvider } = useParams()
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const doubleNegativeKeys = ['MAILER_AUTOCONFIRM', 'SMS_AUTOCONFIRM']
@@ -113,10 +116,24 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps)
)
}
// Handle clicking on a provider in the list
const handleProviderClick = () => setUrlProvider(provider.title)
const handleOpenChange = (isOpen: boolean) => {
// Remove provider query param from URL when closed
if (!isOpen) setUrlProvider(null)
}
// Open or close the form based on the query parameter
useEffect(() => {
const isProviderInQuery = urlProvider.toLowerCase() === provider.title.toLowerCase()
setOpen(isProviderInQuery)
}, [urlProvider, provider.title])
return (
<>
<ResourceItem
onClick={() => setOpen(true)}
onClick={handleProviderClick}
media={
<img
src={`${BASE_PATH}/img/icons/${provider.misc.iconKey}.svg`}
@@ -143,7 +160,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps)
{provider.title}
</ResourceItem>
<Sheet open={open} onOpenChange={setOpen}>
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent className="flex flex-col gap-0">
<SheetHeader className="shrink-0 flex items-center gap-4">
<img
@@ -6,6 +6,8 @@ import { FormPanel } from 'components/ui/Forms/FormPanel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import {
Card,
CardContent,
ScrollArea,
Tabs_Shadcn_,
TabsContent_Shadcn_,
@@ -37,20 +39,20 @@ const EmailTemplates = () => {
<AlertError error={authConfigError} subject="Failed to retrieve auth configuration" />
)}
{isLoading && (
<div className="w-[854px]">
<div className="w-[854px] mt-12">
<GenericSkeletonLoader />
</div>
)}
{isSuccess && (
<>
<div className="my-12">
{builtInSMTP ? (
<div className="mb-4">
<EmailRateLimitsAlert />
</div>
) : null}
<FormPanel>
<Card>
<Tabs_Shadcn_ defaultValue={TEMPLATES_SCHEMAS[0].title.trim().replace(/\s+/g, '-')}>
<TabsList_Shadcn_ className="px-4 md:px-8 pt-2 gap-5 overflow-x-scroll no-scrollbar">
<TabsList_Shadcn_ className="pt-2 px-6 gap-5 mb-0 overflow-x-scroll no-scrollbar mb-4">
{TEMPLATES_SCHEMAS.map((template) => {
return (
<TabsTrigger_Shadcn_ value={template.title.trim().replace(/\s+/g, '-')}>
@@ -59,18 +61,17 @@ const EmailTemplates = () => {
)
})}
</TabsList_Shadcn_>
{TEMPLATES_SCHEMAS.map((template) => {
const panelId = template.title.trim().replace(/\s+/g, '-')
return (
<TabsContent_Shadcn_ value={panelId} key={panelId}>
<TabsContent_Shadcn_ value={panelId} key={panelId} className="mt-0">
<TemplateEditor key={template.title} template={template} />
</TabsContent_Shadcn_>
)
})}
</Tabs_Shadcn_>
</FormPanel>
</>
</Card>
</div>
)}
</div>
)
@@ -1,17 +1,7 @@
import { Markdown } from 'components/interfaces/Markdown'
import InformationBox from 'components/ui/InformationBox'
import { ValidateSpamResponse } from 'data/auth/validate-spam-mutation'
import {
cn,
Separator,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
WarningIcon,
} from 'ui'
import { Check, MailWarning } from 'lucide-react'
import { Separator, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
interface SpamValidationProps {
validationResult?: ValidateSpamResponse
@@ -26,23 +16,23 @@ export const SpamValidation = ({ validationResult }: SpamValidationProps) => {
const hasSpamWarning = spamRules.length > 0
return (
<InformationBox
className={cn('mb-2', hasSpamWarning && '!bg-alternative/75', '!px-0', 'rounded-t-none')}
icon={hasSpamWarning && <WarningIcon />}
title={
hasSpamWarning
? 'Email has a high probability of being marked as spam and deliverability may be affected'
: 'Email content is unlikely to be marked as spam'
}
description={
hasSpamWarning ? (
<div className="flex items-start gap-2">
<div className="mt-0.5">
{hasSpamWarning ? (
<MailWarning size={16} strokeWidth={1.5} className="text-warning" />
) : (
<Check size={16} strokeWidth={1.5} className="text-brand" />
)}
</div>
<div className="flex flex-col gap-2">
<h5 className="text-sm">
{hasSpamWarning
? 'Email has a high probability of being marked as spam - review issues below to improve deliverability.'
: 'Email content is unlikely to be marked as spam'}
</h5>
{hasSpamWarning && (
<>
<div className="flex flex-col gap-y-3">
<p>
{hasSpamWarning
? ` Rectify the following issues to improve your email's deliverability in order of priority:`
: ` Address the following issues to improve your email's deliverability:`}
</p>
<Table>
<TableHeader className="font-mono uppercase text-xs [&_th]:h-auto [&_th]:py-2">
<TableRow className="hover:bg-transparent">
@@ -68,8 +58,8 @@ export const SpamValidation = ({ validationResult }: SpamValidationProps) => {
content="Spam validation is powered by [SpamAssassin](https://spamassassin.apache.org/doc.html). Full list of all available warnings can be found [here](https://gist.github.com/ychaouche/a2faff159c2a1fea16019156972c7f8b)."
/>
</>
) : null
}
/>
)}
</div>
</div>
)
}
@@ -1,28 +1,39 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Code, Monitor } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { editor } from 'monaco-editor'
import { useEffect, useMemo, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import { toast } from 'sonner'
import { useForm } from 'react-hook-form'
import { useParams } from 'common'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import InformationBox from 'components/ui/InformationBox'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useValidateSpamMutation, ValidateSpamResponse } from 'data/auth/validate-spam-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import type { FormSchema } from 'types'
import {
Form,
Input,
Badge,
Button,
Card,
CardContent,
CardFooter,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
Tabs_Shadcn_,
TabsContent_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { SpamValidation } from './SpamValidation'
interface TemplateEditorProps {
@@ -33,6 +44,9 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
const { ref: projectRef } = useParams()
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
// Add a ref to the code editor
const editorRef = useRef<editor.IStandaloneCodeEditor>()
// [Joshen] Error state is handled in the parent
const { data: authConfig, isSuccess } = useAuthConfigQuery({ projectRef })
@@ -49,15 +63,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
const { id, properties } = template
const formId = `auth-config-email-templates-${id}`
const INITIAL_VALUES = useMemo(() => {
const result: { [x: string]: string } = {}
Object.keys(properties).forEach((key) => {
result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
return result
}, [authConfig, properties])
const messageSlug = `MAILER_TEMPLATES_${id}_CONTENT` as keyof typeof authConfig
const messageProperty = properties[messageSlug]
const builtInSMTP =
@@ -74,7 +79,33 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
const spamRules = (validationResult?.rules ?? []).filter((rule) => rule.score > 0)
const preventSaveFromSpamCheck = builtInSMTP && spamRules.length > 0
const onSubmit = (values: any, { resetForm }: any) => {
// Create form values
const INITIAL_VALUES = useMemo(() => {
const result: { [x: string]: string } = {}
Object.keys(properties).forEach((key) => {
result[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
return result
}, [authConfig, properties])
// Setup React Hook Form
const form = useForm({
defaultValues: INITIAL_VALUES,
})
// Update form values when authConfig changes
useEffect(() => {
if (authConfig) {
const values: { [key: string]: string } = {}
Object.keys(properties).forEach((key) => {
values[key] = ((authConfig && authConfig[key as keyof typeof authConfig]) ?? '') as string
})
form.reset(values)
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
}
}, [authConfig, properties, messageSlug, form])
const onSubmit = (values: any) => {
if (!projectRef) return console.error('Project ref is required')
setIsSavingTemplate(true)
@@ -112,10 +143,6 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
onSuccess: () => {
setIsSavingTemplate(false)
toast.success('Successfully updated settings')
resetForm({
values: values,
initialValues: values,
})
setHasUnsavedChanges(false) // Reset the unsaved changes state
},
}
@@ -157,140 +184,202 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
return (
<Form id={formId} className="!border-t-0" initialValues={INITIAL_VALUES} onSubmit={onSubmit}>
{({ resetForm, values, initialValues }: any) => {
const message = (authConfig && authConfig[messageSlug]) ?? ''
const hasChanges =
JSON.stringify(values) !== JSON.stringify(initialValues) || message !== bodyValue
// Single useMemo hook to parse and prepare message variables
const messageVariables = useMemo(() => {
if (!messageProperty?.description) return []
return (
// Parse bullet point format: - `{{ .Variable }}` : Description
const lines = messageProperty.description.split('\n')
const variables: { variable: string; description: string }[] = []
for (const line of lines) {
// Match lines that start with a bullet point followed by a variable in the format {{ .Variable }}
// Handle variations in formatting (with or without backticks, different spacing)
const match = line.match(/-\s*`?({{\s*\.\w+\s*}})`?\s*(?::|-)?\s*(.+)/)
if (match && match[1] && match[2]) {
variables.push({
variable: match[1].replace(/`/g, '').trim(),
description: match[2].trim(),
})
}
}
return variables
}, [messageProperty?.description])
// Check if form values have changed
const formValues = form.watch()
const hasFormChanges = JSON.stringify(formValues) !== JSON.stringify(INITIAL_VALUES)
const hasChanges = hasFormChanges || ((authConfig && authConfig[messageSlug]) ?? '') !== bodyValue
// Function to insert text at cursor position
const insertTextAtCursor = (text: string) => {
if (!editorRef.current) return
const editor = editorRef.current
const selection = editor.getSelection()
if (selection) {
const range = {
startLineNumber: selection.startLineNumber,
startColumn: selection.startColumn,
endLineNumber: selection.endLineNumber,
endColumn: selection.endColumn,
}
editor.executeEdits('insert-variable', [
{
range,
text,
forceMoveMarkers: true,
},
])
// Focus the editor after insertion
editor.focus()
}
}
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent>
{Object.keys(properties).map((x: string) => {
const property = properties[x]
if (property.type === 'string' && x !== messageSlug) {
return (
<FormField_Shadcn_
key={x}
control={form.control}
name={x}
render={({ field }) => (
<FormItemLayout
label={property.title}
description={
property.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.description}
</ReactMarkdown>
) : null
}
labelOptional={
property.descriptionOptional ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.descriptionOptional}
</ReactMarkdown>
) : null
}
>
<FormControl_Shadcn_>
<Input_Shadcn_ id={x} {...field} disabled={!canUpdateConfig} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)
}
return null
})}
</CardContent>
{messageProperty && (
<>
<FormSection className="!border-t-0 pb-8 !pt-4">
<FormSectionContent loading={false}>
{Object.keys(properties).map((x: string) => {
const property = properties[x]
if (property.type === 'string') {
return (
<div key={x} className="space-y-3">
<label className="col-span-12 text-sm text-foreground lg:col-span-5">
{property.title}
</label>
<Input
size="small"
layout="vertical"
id={x}
key={x}
name={x}
descriptionText={
property.description ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.description}
</ReactMarkdown>
) : null
}
labelOptional={
property.descriptionOptional ? (
<ReactMarkdown unwrapDisallowed disallowedElements={['p']}>
{property.descriptionOptional}
</ReactMarkdown>
) : null
}
disabled={!canUpdateConfig}
/>
</div>
)
}
})}
</FormSectionContent>
</FormSection>
<FormSection className="!mt-0 grid-cols-12 !border-t-0 !pt-0 pb-7">
<FormSectionContent fullWidth loading={false}>
{messageProperty && (
<>
<div className="space-y-3">
<FormSectionLabel>
<span>{messageProperty.title}</span>
</FormSectionLabel>
<InformationBox
defaultVisibility
title="Message variables"
hideCollapse={false}
description={
messageProperty.description && (
<ReactMarkdown>{messageProperty.description}</ReactMarkdown>
)
}
/>
</div>
<Tabs_Shadcn_ defaultValue="source">
<TabsList_Shadcn_ className="gap-3">
<TabsTrigger_Shadcn_ value="source" className="gap-2">
<Code size={14} />
Source
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="preview" className="gap-2">
<Monitor size={14} />
Preview
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="source" className="-space-y-px">
<CodeEditor
id="code-id"
language="html"
isReadOnly={!canUpdateConfig}
className="!mb-0 relative h-96 overflow-hidden rounded border rounded-b-none"
onInputChange={(e: string | undefined) => {
setBodyValue(e ?? '')
if (bodyValue !== e) setHasUnsavedChanges(true)
}}
options={{ wordWrap: 'on', contextmenu: false }}
value={bodyValue}
/>
<SpamValidation validationResult={validationResult} />
</TabsContent_Shadcn_>
<TabsContent_Shadcn_ value="preview">
<Admonition
type="default"
title="The preview may differ slightly from the actual rendering in the email client"
/>
<iframe
className="!mb-0 overflow-hidden h-96 w-full rounded border"
title={id}
srcDoc={bodyValue}
/>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
</>
)}
<div className="col-span-12 flex w-full">
<FormActions
handleReset={() => {
resetForm({
values: authConfig,
initialValues: authConfig,
})
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
<CardContent className="p-0">
<Label_Shadcn_ className="p-6 pb-4 block">Message body</Label_Shadcn_>
<Tabs_Shadcn_ defaultValue="source">
<TabsList_Shadcn_ className="gap-3 px-6">
<TabsTrigger_Shadcn_ value="source" className="gap-2">
<Code size={14} />
Source
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="preview" className="gap-2">
<Monitor size={14} />
Preview
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="source" className="p-0 mt-0">
<CodeEditor
id="code-id"
language="html"
isReadOnly={!canUpdateConfig}
className="!mb-0 relative h-96 overflow-hidden p-0"
onInputChange={(e: string | undefined) => {
setBodyValue(e ?? '')
if (bodyValue !== e) setHasUnsavedChanges(true)
}}
form={formId}
isSubmitting={isSavingTemplate}
hasChanges={hasChanges}
disabled={!canUpdateConfig}
helper={
preventSaveFromSpamCheck
? 'Please rectify all spam warnings before saving while using the built-in email service'
: !canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
options={{ wordWrap: 'on', contextmenu: false, padding: { top: 16 } }}
value={bodyValue}
editorRef={editorRef}
/>
</div>
</FormSectionContent>
</FormSection>
{messageVariables.length > 0 && (
<div className="px-6 py-3 border-t bg-surface-200">
<div className="flex flex-wrap gap-1">
{messageVariables.map(({ variable, description }) => (
<Tooltip key={variable}>
<TooltipTrigger asChild>
<Button
type="outline"
size="tiny"
className="rounded-full"
onClick={() => insertTextAtCursor(variable)}
>
{variable}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{description || 'Variable description not available'}</p>
</TooltipContent>
</Tooltip>
))}
</div>
</div>
)}
</TabsContent_Shadcn_>
<TabsContent_Shadcn_ value="preview" className="pt-0 mt-0">
<iframe
className="!mb-0 mt-0 overflow-hidden h-96 w-full"
title={id}
srcDoc={bodyValue}
/>
<Admonition
type="default"
title="The preview may differ slightly from the actual rendering in the email client"
className="rounded-none border-0 mb-0"
/>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
</CardContent>
<CardContent>
<SpamValidation validationResult={validationResult} />
</CardContent>
<CardFooter className="justify-end space-x-2">
{hasChanges && (
<Button
type="default"
onClick={() => {
form.reset(INITIAL_VALUES)
setBodyValue((authConfig && authConfig[messageSlug]) ?? '')
setHasUnsavedChanges(false)
}}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig || isSavingTemplate || !hasChanges || preventSaveFromSpamCheck
}
loading={isSavingTemplate}
>
Save changes
</Button>
</CardFooter>
</>
)
}}
</Form>
)}
</form>
</Form_Shadcn_>
)
}
@@ -16,6 +16,7 @@ import { CreateHookSheet } from './CreateHookSheet'
import { HookCard } from './HookCard'
import { HOOKS_DEFINITIONS, HOOK_DEFINITION_TITLE, Hook } from './hooks.constants'
import { extractMethod, getRevokePermissionStatements, isValidHook } from './hooks.utils'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
export const HooksListing = () => {
const { ref: projectRef } = useParams()
@@ -68,8 +69,11 @@ export const HooksListing = () => {
}
return (
<div className="pb-4">
<FormHeader title="All hooks" actions={<AddHookDropdown onSelectHook={setSelectedHook} />} />
<ScaffoldSection isFullWidth>
<div className="flex justify-between items-center mb-4">
<ScaffoldSectionTitle>All hooks</ScaffoldSectionTitle>
<AddHookDropdown onSelectHook={setSelectedHook} />
</div>
{hooks.filter((h) => isValidHook(h)).length === 0 && (
<div
@@ -155,6 +159,6 @@ export const HooksListing = () => {
)}
</div>
</ConfirmationModal>
</div>
</ScaffoldSection>
)
}
@@ -1,13 +1,12 @@
import { yupResolver } from '@hookform/resolvers/yup'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { number, object, string } from 'yup'
import { useParams } from 'common'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
@@ -16,28 +15,27 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import FormField from '../AuthProvidersForm/FormField'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Form,
InputNumber,
Button,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
WarningIcon,
PrePostTab,
} from 'ui'
const schema = object({
MFA_PHONE_OTP_LENGTH: number()
.min(6, 'Must be a value 6 or larger')
.max(30, 'must be a value no greater than 30'),
MFA_PHONE_TEMPLATE: string().required('SMS template is required.'),
MFA_MAX_ENROLLED_FACTORS: number()
.min(0, 'Must be a value 0 or larger')
.max(30, 'Must be a value no greater than 30'),
MFA_TOTP: string().required(),
MFA_PHONE: string().required(),
})
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
function determineMFAStatus(verifyEnabled: boolean, enrollEnabled: boolean) {
return verifyEnabled ? (enrollEnabled ? 'Enabled' : 'Verify Enabled') : 'Disabled'
@@ -66,6 +64,21 @@ const MfaStatusToState = (status: (typeof MFAFactorSelectionOptions)[number]['va
: { verifyEnabled: false, enrollEnabled: false }
}
const totpSchema = object({
MFA_TOTP: string().required(),
MFA_MAX_ENROLLED_FACTORS: number()
.min(0, 'Must be a value 0 or larger')
.max(30, 'Must be a value no greater than 30'),
})
const phoneSchema = object({
MFA_PHONE: string().required(),
MFA_PHONE_OTP_LENGTH: number()
.min(6, 'Must be a value 6 or larger')
.max(30, 'must be a value no greater than 30'),
MFA_PHONE_TEMPLATE: string().required('SMS template is required.'),
})
const MfaAuthSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
@@ -73,11 +86,13 @@ const MfaAuthSettingsForm = () => {
error: authConfigError,
isLoading,
isError,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
// Separate loading states for each form
const [isUpdatingTotpForm, setIsUpdatingTotpForm] = useState(false)
const [isUpdatingPhoneForm, setIsUpdatingPhoneForm] = useState(false)
const formId = 'auth-config-advanced-form'
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
@@ -94,34 +109,87 @@ const MfaAuthSettingsForm = () => {
(addon) => addon.type === 'auth_mfa_phone'
)
const INITIAL_VALUES = {
MFA_PHONE_OTP_LENGTH: authConfig?.MFA_PHONE_OTP_LENGTH || 6,
MFA_PHONE_TEMPLATE: authConfig?.MFA_PHONE_TEMPLATE || 'Your code is {{ .Code }}',
MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS ?? 10,
MFA_TOTP:
determineMFAStatus(
authConfig?.MFA_TOTP_VERIFY_ENABLED ?? true,
authConfig?.MFA_TOTP_ENROLL_ENABLED ?? true
) || 'Enabled',
MFA_PHONE:
determineMFAStatus(
authConfig?.MFA_PHONE_VERIFY_ENABLED || false,
authConfig?.MFA_PHONE_ENROLL_ENABLED || false
) || 'Disabled',
}
// For now, we support Twilio and Vonage. Twilio Verify is not supported and the remaining providers are community maintained.
const sendSMSHookIsEnabled =
authConfig?.HOOK_SEND_SMS_URI !== null && authConfig?.HOOK_SEND_SMS_ENABLED === true
const hasValidMFAPhoneProvider = authConfig?.EXTERNAL_PHONE_ENABLED === true
const hasValidMFAProvider = hasValidMFAPhoneProvider || sendSMSHookIsEnabled
const onSubmit = (values: any, { resetForm }: any) => {
let payload = { ...values }
const totpForm = useForm({
resolver: yupResolver(totpSchema),
defaultValues: {
MFA_TOTP: 'Enabled',
MFA_MAX_ENROLLED_FACTORS: 10,
},
})
const phoneForm = useForm({
resolver: yupResolver(phoneSchema),
defaultValues: {
MFA_PHONE: 'Disabled',
MFA_PHONE_OTP_LENGTH: 6,
MFA_PHONE_TEMPLATE: 'Your code is {{ .Code }}',
},
})
useEffect(() => {
if (authConfig) {
if (!isUpdatingTotpForm) {
totpForm.reset({
MFA_TOTP:
determineMFAStatus(
authConfig?.MFA_TOTP_VERIFY_ENABLED ?? true,
authConfig?.MFA_TOTP_ENROLL_ENABLED ?? true
) || 'Enabled',
MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS ?? 10,
})
}
if (!isUpdatingPhoneForm) {
phoneForm.reset({
MFA_PHONE:
determineMFAStatus(
authConfig?.MFA_PHONE_VERIFY_ENABLED || false,
authConfig?.MFA_PHONE_ENROLL_ENABLED || false
) || 'Disabled',
MFA_PHONE_OTP_LENGTH: authConfig?.MFA_PHONE_OTP_LENGTH || 6,
MFA_PHONE_TEMPLATE: authConfig?.MFA_PHONE_TEMPLATE || 'Your code is {{ .Code }}',
})
}
}
}, [authConfig, isUpdatingTotpForm, isUpdatingPhoneForm])
const onSubmitTotpForm = (values: any) => {
const { verifyEnabled: MFA_TOTP_VERIFY_ENABLED, enrollEnabled: MFA_TOTP_ENROLL_ENABLED } =
MfaStatusToState(values.MFA_TOTP)
// MFA (Phone) is only available on Pro Plans and up. We translate the UI state, MFA_PHONE and MFA_TOTP into the underlying
// Auth config state - MFA_PHONE_*_ENABLED and MFA_TOTP_*_ENABLED.
const payload = {
...values,
MFA_TOTP_ENROLL_ENABLED,
MFA_TOTP_VERIFY_ENABLED,
}
delete payload.MFA_TOTP
setIsUpdatingTotpForm(true)
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
toast.error(`Failed to update TOTP settings: ${error?.message}`)
setIsUpdatingTotpForm(false)
},
onSuccess: () => {
toast.success('Successfully updated TOTP settings')
setIsUpdatingTotpForm(false)
},
}
)
}
const onSubmitPhoneForm = (values: any) => {
let payload = { ...values }
if (isProPlanAndUp) {
const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } =
MfaStatusToState(values.MFA_PHONE)
@@ -131,23 +199,20 @@ const MfaAuthSettingsForm = () => {
MFA_PHONE_VERIFY_ENABLED,
}
}
payload = {
...payload,
MFA_TOTP_ENROLL_ENABLED,
MFA_TOTP_VERIFY_ENABLED,
}
delete payload.MFA_TOTP
delete payload.MFA_PHONE
setIsUpdatingPhoneForm(true)
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error?.message}`)
toast.error(`Failed to update phone MFA settings: ${error?.message}`)
setIsUpdatingPhoneForm(false)
},
onSuccess: () => {
toast.success('Successfully updated settings')
resetForm({ values: values, initialValues: values })
toast.success('Successfully updated phone MFA settings')
setIsUpdatingPhoneForm(false)
},
}
)
@@ -167,134 +232,240 @@ const MfaAuthSettingsForm = () => {
return <NoPermission resourceText="view auth configuration settings" />
}
const phoneMFAIsEnabled =
phoneForm.watch('MFA_PHONE') === 'Enabled' || phoneForm.watch('MFA_PHONE') === 'Verify Enabled'
const hasUpgradedPhoneMFA = authConfig?.MFA_PHONE_VERIFY_ENABLED === false && phoneMFAIsEnabled
return (
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
{({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
<>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">
Multi-Factor Authentication (MFA)
</ScaffoldSectionTitle>
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
<Form_Shadcn_ {...totpForm}>
<form onSubmit={totpForm.handleSubmit(onSubmitTotpForm)} className="space-y-4">
<Card>
<CardContent className="pt-6">
<FormField_Shadcn_
control={totpForm.control}
name="MFA_TOTP"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="TOTP (App Authenticator)"
description="Control use of TOTP (App Authenticator) factors"
>
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
disabled={!canUpdateConfig}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select status" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{MFAFactorSelectionOptions.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
const hasUpgradedPhoneMFA =
INITIAL_VALUES.MFA_PHONE === 'Disabled' && values.MFA_PHONE !== INITIAL_VALUES.MFA_PHONE
const phoneMFAIsEnabled =
values.MFA_PHONE === 'Enabled' || values.MFA_PHONE === 'Verify Enabled'
<CardContent>
<FormField_Shadcn_
control={totpForm.control}
name="MFA_MAX_ENROLLED_FACTORS"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Maximum number of per-user MFA factors"
description="How many MFA factors can be enrolled at once per user."
>
<FormControl_Shadcn_>
<PrePostTab postTab="factors">
<Input_Shadcn_
type="number"
min={0}
max={30}
{...field}
disabled={!canUpdateConfig}
/>
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection
header={<FormSectionLabel>Multi-Factor Authentication (MFA)</FormSectionLabel>}
>
<FormSectionContent loading={isLoading}>
<FormField
name="MFA_TOTP"
properties={{
type: 'select',
title: 'TOTP (App Authenticator)',
description: 'Control use of TOTP (App Authenticator) factors',
enum: MFAFactorSelectionOptions,
}}
formValues={values}
disabled={!canUpdateConfig}
setFieldValue={setFieldValue}
/>
<CardFooter className="justify-end space-x-2">
{totpForm.formState.isDirty && (
<Button type="default" onClick={() => totpForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isUpdatingTotpForm || !totpForm.formState.isDirty}
loading={isUpdatingTotpForm}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
<InputNumber
id="MFA_MAX_ENROLLED_FACTORS"
size="small"
label="Maximum number of per-user MFA factors"
descriptionText="How many MFA factors can be enrolled at once per user."
actions={<span className="mr-3 text-foreground-lighter">factors</span>}
disabled={!canUpdateConfig}
/>
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>SMS MFA</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
{promptProPlanUpgrade && (
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">SMS MFA</ScaffoldSectionTitle>
<Form_Shadcn_ {...phoneForm}>
<form onSubmit={phoneForm.handleSubmit(onSubmitPhoneForm)} className="space-y-4">
<Card>
<CardContent className="pt-6">
{promptProPlanUpgrade && (
<div className="mb-4">
<UpgradeToPro
primaryText="Upgrade to Pro"
secondaryText="Advanced MFA requires the Pro Plan"
/>
)}
<FormField
name="MFA_PHONE"
properties={{
type: 'select',
title: 'Phone',
description: 'Control use of phone factors',
enum: MFAFactorSelectionOptions,
}}
formValues={values}
disabled={!canUpdateConfig || !isProPlanAndUp}
setFieldValue={setFieldValue}
/>
{!hasValidMFAProvider && phoneMFAIsEnabled && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
)}
</div>
)}
<InputNumber
id="MFA_PHONE_OTP_LENGTH"
size="small"
label="Phone OTP Length"
descriptionText="Number of digits in OTP"
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<FormField
name="MFA_PHONE_TEMPLATE"
properties={{
title: 'Phone verification message',
type: 'multiline-string',
description: 'To format the OTP code use `{{ .Code }}`',
}}
formValues={values}
disabled={!canUpdateConfig || !isProPlanAndUp}
setFieldValue={setFieldValue}
/>
{hasUpgradedPhoneMFA && (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Enabling advanced MFA with phone will result in an additional charge of $75
per month for the first project in the organization and an additional $10
per month for additional projects.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
<FormField_Shadcn_
control={phoneForm.control}
name="MFA_PHONE"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Phone"
description="Control use of phone factors"
>
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
disabled={!canUpdateConfig || !isProPlanAndUp}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select status" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{MFAFactorSelectionOptions.map((option) => (
<SelectItem_Shadcn_ key={option.value} value={option.value}>
{option.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
/>
{!hasValidMFAProvider && phoneMFAIsEnabled && (
<Alert_Shadcn_ variant="warning" className="mt-3">
<WarningIcon />
<AlertTitle_Shadcn_>
To use MFA with Phone you should set up a Phone provider or Send SMS Hook.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
)}
</CardContent>
<CardContent>
<FormField_Shadcn_
control={phoneForm.control}
name="MFA_PHONE_OTP_LENGTH"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Phone OTP Length"
description="Number of digits in OTP"
>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
min={6}
max={30}
{...field}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={phoneForm.control}
name="MFA_PHONE_TEMPLATE"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Phone verification message"
description="To format the OTP code use `{{ .Code }}`"
>
<FormControl_Shadcn_>
<Input_Shadcn_
type="text"
{...field}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
{hasUpgradedPhoneMFA && (
<CardContent>
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Enabling advanced MFA with phone will result in an additional charge of $75
per month for the first project in the organization and an additional $10 per
month for additional projects.
</AlertTitle_Shadcn_>
</Alert_Shadcn_>
</CardContent>
)}
<CardFooter className="justify-end space-x-2">
{phoneForm.formState.isDirty && (
<Button type="default" onClick={() => phoneForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig ||
isUpdatingPhoneForm ||
!phoneForm.formState.isDirty ||
!isProPlanAndUp
}
loading={isUpdatingPhoneForm}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
</>
)
}
@@ -1,7 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { noop } from 'lodash'
import { Lock, Unlock } from 'lucide-react'
import { useQueryState } from 'nuqs'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
@@ -42,7 +41,6 @@ const PolicyTableRowHeader = ({
const isRealtimeSchema = table.schema === 'realtime'
const isRealtimeMessagesTable = isRealtimeSchema && table.name === 'messages'
const isTableLocked = isRealtimeSchema ? !isRealtimeMessagesTable : isLocked
const [_, setEditView] = useQueryState('view', { defaultValue: '' })
return (
<div id={table.id.toString()} className="flex w-full items-center justify-between">
@@ -1,30 +1,40 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Eye, EyeOff } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { boolean, number, object, string } from 'yup'
import { yupResolver } from '@hookform/resolvers/yup'
import { useParams } from 'common'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Form,
Input,
Toggle,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Switch,
WarningIcon,
PrePostTab,
} from 'ui'
import { NO_REQUIRED_CHARACTERS } from '../Auth.constants'
import FormField from '../AuthProvidersForm/FormField'
const schema = object({
DISABLE_SIGNUP: boolean().required(),
@@ -51,8 +61,6 @@ const schema = object({
PASSWORD_HIBP_ENABLED: boolean(),
})
const formId = 'auth-config-protection-settings'
const ProtectionAuthSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
@@ -60,32 +68,57 @@ const ProtectionAuthSettingsForm = () => {
error: authConfigError,
isLoading,
isError,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const [isUpdatingProtection, setIsUpdatingProtection] = useState(false)
const [hidden, setHidden] = useState(true)
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const INITIAL_VALUES = {
DISABLE_SIGNUP: !authConfig?.DISABLE_SIGNUP,
EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig?.EXTERNAL_ANONYMOUS_USERS_ENABLED,
SECURITY_MANUAL_LINKING_ENABLED: authConfig?.SECURITY_MANUAL_LINKING_ENABLED || false,
SITE_URL: authConfig?.SITE_URL,
SECURITY_CAPTCHA_ENABLED: authConfig?.SECURITY_CAPTCHA_ENABLED || false,
SECURITY_CAPTCHA_SECRET: authConfig?.SECURITY_CAPTCHA_SECRET || '',
SECURITY_CAPTCHA_PROVIDER: authConfig?.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
SESSIONS_TIMEBOX: authConfig?.SESSIONS_TIMEBOX || 0,
SESSIONS_INACTIVITY_TIMEOUT: authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0,
SESSIONS_SINGLE_PER_USER: authConfig?.SESSIONS_SINGLE_PER_USER || false,
PASSWORD_MIN_LENGTH: authConfig?.PASSWORD_MIN_LENGTH || 6,
PASSWORD_REQUIRED_CHARACTERS:
authConfig?.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
PASSWORD_HIBP_ENABLED: authConfig?.PASSWORD_HIBP_ENABLED || false,
}
const protectionForm = useForm({
resolver: yupResolver(schema),
defaultValues: {
DISABLE_SIGNUP: true,
EXTERNAL_ANONYMOUS_USERS_ENABLED: false,
SECURITY_MANUAL_LINKING_ENABLED: false,
SITE_URL: '',
SECURITY_CAPTCHA_ENABLED: false,
SECURITY_CAPTCHA_SECRET: '',
SECURITY_CAPTCHA_PROVIDER: 'hcaptcha',
SESSIONS_TIMEBOX: 0,
SESSIONS_INACTIVITY_TIMEOUT: 0,
SESSIONS_SINGLE_PER_USER: false,
PASSWORD_MIN_LENGTH: 6,
PASSWORD_REQUIRED_CHARACTERS: NO_REQUIRED_CHARACTERS,
PASSWORD_HIBP_ENABLED: false,
},
})
useEffect(() => {
if (authConfig && !isUpdatingProtection) {
protectionForm.reset({
DISABLE_SIGNUP: !authConfig.DISABLE_SIGNUP,
EXTERNAL_ANONYMOUS_USERS_ENABLED: authConfig.EXTERNAL_ANONYMOUS_USERS_ENABLED || false,
SECURITY_MANUAL_LINKING_ENABLED: authConfig.SECURITY_MANUAL_LINKING_ENABLED || false,
SITE_URL: authConfig.SITE_URL || '',
SECURITY_CAPTCHA_ENABLED: authConfig.SECURITY_CAPTCHA_ENABLED || false,
SECURITY_CAPTCHA_SECRET: authConfig.SECURITY_CAPTCHA_SECRET || '',
SECURITY_CAPTCHA_PROVIDER: authConfig.SECURITY_CAPTCHA_PROVIDER || 'hcaptcha',
SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
PASSWORD_MIN_LENGTH: authConfig.PASSWORD_MIN_LENGTH || 6,
PASSWORD_REQUIRED_CHARACTERS:
authConfig.PASSWORD_REQUIRED_CHARACTERS || NO_REQUIRED_CHARACTERS,
PASSWORD_HIBP_ENABLED: authConfig.PASSWORD_HIBP_ENABLED || false,
})
}
}, [authConfig, isUpdatingProtection])
const onSubmitProtection = (values: any) => {
setIsUpdatingProtection(true)
const onSubmit = (values: any, { resetForm }: any) => {
const payload = { ...values }
payload.DISABLE_SIGNUP = !values.DISABLE_SIGNUP
// The backend uses empty string to represent no required characters in the password
@@ -97,11 +130,12 @@ const ProtectionAuthSettingsForm = () => {
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error?.message}`)
toast.error(`Failed to update settings: ${error?.message}`)
setIsUpdatingProtection(false)
},
onSuccess: () => {
toast.success(`Successfully updated settings`)
resetForm({ values: values, initialValues: values })
toast.success('Successfully updated settings')
setIsUpdatingProtection(false)
},
}
)
@@ -122,106 +156,122 @@ const ProtectionAuthSettingsForm = () => {
}
return (
<div>
<FormHeader
title="Bot and Abuse Protection"
description="Configure protection settings for your application."
/>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Bot and Abuse Protection</ScaffoldSectionTitle>
<Form
id={formId}
initialValues={INITIAL_VALUES}
onSubmit={onSubmit}
validationSchema={schema}
>
{({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
<Form_Shadcn_ {...protectionForm}>
<form onSubmit={protectionForm.handleSubmit(onSubmitProtection)} className="space-y-4">
<Card>
<CardContent>
<FormField_Shadcn_
control={protectionForm.control}
name="SECURITY_CAPTCHA_ENABLED"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable Captcha protection"
description="Protect authentication endpoints from bots and abuse."
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Bot and Abuse Protection</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<Toggle
id="SECURITY_CAPTCHA_ENABLED"
size="small"
label="Enable Captcha protection"
layout="flex"
descriptionText="Protect authentication endpoints from bots and abuse."
disabled={!canUpdateConfig}
/>
{values.SECURITY_CAPTCHA_ENABLED && (
<>
<FormField
name="SECURITY_CAPTCHA_PROVIDER"
properties={{
type: 'select',
title: 'Choose Captcha Provider',
description: '',
enum: [
{
label: 'hCaptcha',
value: 'hcaptcha',
icon: 'hcaptcha-icon.png',
},
{
label: 'Turnstile by Cloudflare',
value: 'turnstile',
icon: 'cloudflare-icon.png',
},
],
}}
formValues={values}
setFieldValue={setFieldValue}
/>
<Input
id="SECURITY_CAPTCHA_SECRET"
type={hidden ? 'password' : 'text'}
size="small"
label="Captcha secret"
descriptionText="Obtain this secret from the provider."
disabled={!canUpdateConfig}
actions={
<Button
icon={hidden ? <Eye /> : <EyeOff />}
type="default"
onClick={() => setHidden(!hidden)}
/>
}
/>
</>
{protectionForm.watch('SECURITY_CAPTCHA_ENABLED') && (
<>
<CardContent>
<FormField_Shadcn_
control={protectionForm.control}
name="SECURITY_CAPTCHA_PROVIDER"
render={({ field }) => (
<FormItemLayout layout="flex-row-reverse" label="Choose Captcha Provider">
<FormControl_Shadcn_>
<Select_Shadcn_
value={field.value}
onValueChange={field.onChange}
disabled={!canUpdateConfig}
>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select provider" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectItem_Shadcn_ value="hcaptcha">hCaptcha</SelectItem_Shadcn_>
<SelectItem_Shadcn_ value="turnstile">
Turnstile by Cloudflare
</SelectItem_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
</div>
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={protectionForm.control}
name="SECURITY_CAPTCHA_SECRET"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Captcha secret"
description="Obtain this secret from the provider."
>
<FormControl_Shadcn_>
<div className="flex items-center gap-2">
<PrePostTab
postTab={
<Button
type="text"
className="p-0"
onClick={() => setHidden(!hidden)}
icon={hidden ? <Eye /> : <EyeOff />}
/>
}
>
<Input_Shadcn_
{...field}
type={hidden ? 'password' : 'text'}
disabled={!canUpdateConfig}
/>
</PrePostTab>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
</>
)}
<CardFooter className="justify-end space-x-2">
{protectionForm.formState.isDirty && (
<Button type="default" onClick={() => protectionForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig || isUpdatingProtection || !protectionForm.formState.isDirty
}
loading={isUpdatingProtection}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
)
}
@@ -8,10 +8,7 @@ import * as z from 'zod'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
@@ -21,20 +18,26 @@ import {
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormDescription_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
WarningIcon,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { isSmtpEnabled } from '../SmtpForm/SmtpForm.utils'
import { ScaffoldSection } from 'components/layouts/Scaffold'
const RateLimits = () => {
const formId = 'auth-rate-limits-form'
const { ref: projectRef } = useParams()
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const {
data: authConfig,
@@ -90,6 +93,7 @@ const RateLimits = () => {
RATE_LIMIT_VERIFY: 0,
RATE_LIMIT_EMAIL_SENT: 0,
RATE_LIMIT_SMS_SENT: 0,
RATE_LIMIT_ANONYMOUS_USERS: 0,
RATE_LIMIT_OTP: 0,
},
})
@@ -127,314 +131,325 @@ const RateLimits = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess])
if (isError) {
return <AlertError subject="Failed to retrieve auth config rate limits" error={error} />
}
if (!canReadConfig) {
return <NoPermission resourceText="view auth configuration settings" />
}
if (isLoading) {
return <GenericSkeletonLoader />
}
return (
<div>
{isError && <AlertError subject="Failed to retrieve auth config rate limits" error={error} />}
{isLoading && <GenericSkeletonLoader />}
{isSuccess && (
<Form_Shadcn_ {...form}>
<form id={formId} className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={form.formState.isDirty}
handleReset={() => form.reset()}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection
id="email-sent"
header={
<FormSectionLabel
description={
<p className="text-foreground-light text-sm">
Number of emails that can be sent per hour from your project
</p>
}
<ScaffoldSection isFullWidth>
<Form_Shadcn_ {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent className="pt-6">
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_EMAIL_SENT"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Rate limit for sending emails"
description="Number of emails that can be sent per hour from your project"
>
Rate limit for sending emails
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_EMAIL_SENT"
render={({ field }) => (
<FormItem_Shadcn_ className="flex flex-col gap-y-2">
<Tooltip>
<TooltipTrigger asChild>
<FormControl_Shadcn_>
<Input_Shadcn_
disabled={!canUpdateConfig || !canUpdateEmailLimit}
type="number"
className="w-24"
min={0}
{...field}
disabled={!canUpdateConfig || !canUpdateEmailLimit}
/>
</FormControl_Shadcn_>
{!authConfig.EXTERNAL_EMAIL_ENABLED ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Email-based logins are not enabled for your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-3">
<p className="!leading-tight">
</TooltipTrigger>
{!canUpdateConfig || !canUpdateEmailLimit ? (
<TooltipContent side="left" className="w-80 p-4">
{!authConfig.EXTERNAL_EMAIL_ENABLED ? (
<>
<p className="font-medium">
Email-based logins are not enabled for your project
</p>
<p className="mt-1">
Enable email-based logins to update this rate limit
</p>
<Button asChild type="default" className="w-min">
<Link href={`/project/${projectRef}/auth/providers`}>
View auth providers
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : !isSmtpEnabled(authConfig) ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Custom SMTP provider is required to update this configuration
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-3">
<p className="!leading-tight">
<div className="mt-3">
<Button asChild type="default" size="tiny">
<Link href={`/project/${projectRef}/auth/providers`}>
View auth providers
</Link>
</Button>
</div>
</>
) : (
<>
<p className="font-medium">
Custom SMTP provider is required to update this configuration
</p>
<p className="mt-1">
The built-in email service has a fixed rate limit. You will need to
set up your own custom SMTP provider to update your email rate limit
</p>
<Button asChild type="default" className="w-min">
<Link href={`/project/${projectRef}/settings/auth`}>
View SMTP settings
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : null}
</FormItem_Shadcn_>
)}
/>
</FormSectionContent>
</FormSection>
<div className="mt-3">
<Button asChild type="default" size="tiny">
<Link href={`/project/${projectRef}/settings/auth`}>
View SMTP settings
</Link>
</Button>
</div>
</>
)}
</TooltipContent>
) : null}
</Tooltip>
</FormItemLayout>
)}
/>
</CardContent>
<FormSection
id="sms-sent"
header={
<FormSectionLabel
description={
<p className="text-foreground-light text-sm">
Number of SMS messages that can be sent per hour from your project
</p>
}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_SMS_SENT"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Rate limit for sending SMS messages"
description="Number of SMS messages that can be sent per hour from your project"
>
Rate limit for sending SMS messages
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_SMS_SENT"
render={({ field }) => (
<FormItem_Shadcn_ className="flex flex-col gap-y-2">
<Tooltip>
<TooltipTrigger asChild>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
className="w-24"
min={0}
{...field}
disabled={!canUpdateConfig || !canUpdateSMSRateLimit}
type="number"
{...field}
/>
</FormControl_Shadcn_>
{!canUpdateSMSRateLimit && (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Phone-based logins are not enabled for your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-3">
<p className="!leading-tight">
Enable phone-based logins to update this rate limit
</p>
<Button asChild type="default" className="w-min">
<Link href={`/project/${projectRef}/auth/providers`}>
View auth providers
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</FormItem_Shadcn_>
)}
/>
</FormSectionContent>
</FormSection>
<FormSection
id="token-refresh"
header={
<FormSectionLabel
description={
<p className="text-foreground-light text-sm">
Number of sessions that can be refreshed in a 5 minute interval per IP
address.
</p>
}
>
Rate limit for token refreshes
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_TOKEN_REFRESH"
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ type="number" {...field} disabled={!canUpdateConfig} />
</FormControl_Shadcn_>
{field.value > 0 && (
<>
<p className="text-foreground-lighter text-sm">
This is equivalent to {field.value * 12} requests per hour
</p>
</>
)}
</FormItem_Shadcn_>
)}
/>
</FormSectionContent>
</FormSection>
<FormSection
id="verify"
header={
<FormSectionLabel
description={
<p className="text-foreground-light text-sm">
Number of OTP/Magic link verifications that can be made in a 5 minute
interval per IP address.
</p>
}
>
Rate limit for token verifications
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_VERIFY"
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ type="number" {...field} disabled={!canUpdateConfig} />
</FormControl_Shadcn_>
{field.value > 0 && (
<p className="text-foreground-lighter text-sm">
This is equivalent to {field.value * 12} requests per hour
</TooltipTrigger>
{!canUpdateConfig || !canUpdateSMSRateLimit ? (
<TooltipContent side="left" className="w-80 p-4">
<p className="font-medium">
Phone-based logins are not enabled for your project
</p>
)}
</FormItem_Shadcn_>
)}
/>
</FormSectionContent>
</FormSection>
<p className="mt-1">
Enable phone-based logins to update this rate limit
</p>
<div className="mt-3">
<Button asChild type="default" size="tiny">
<Link href={`/project/${projectRef}/auth/providers`}>
View auth providers
</Link>
</Button>
</div>
</TooltipContent>
) : null}
</Tooltip>
</FormItemLayout>
)}
/>
</CardContent>
<FormSection
id="anonymous-users"
header={
<FormSectionLabel
description={
<p className="text-foreground-light text-sm">
Number of anonymous sign-ins that can be made per hour per IP address.
</p>
}
<CardContent>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_TOKEN_REFRESH"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Rate limit for token refreshes"
description="Number of sessions that can be refreshed in a 5 minute interval per IP address"
>
Rate limit for anonymous users
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_ANONYMOUS_USERS"
render={({ field }) => (
<FormItem_Shadcn_ className="flex flex-col gap-y-2">
<Tooltip>
<TooltipTrigger asChild>
<FormControl_Shadcn_>
<Input_Shadcn_
disabled={!canUpdateConfig || !canUpdateAnonymousUsersRateLimit}
type="number"
className="w-24"
min={0}
{...field}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
{!canUpdateAnonymousUsersRateLimit && (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Anonymous logins are not enabled for your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-3">
<p className="!leading-tight">
Enable anonymous logins to update this rate limit
</p>
<Button asChild type="default" className="w-min">
<Link href={`/project/${projectRef}/settings/auth`}>
View auth settings
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
</FormItem_Shadcn_>
)}
/>
</FormSectionContent>
</FormSection>
<FormSection
id="otp"
header={
<FormSectionLabel
description={
<p className="text-foreground-light text-sm">
Number of sign up and sign-in requests that can be made in a 5 minute
interval per IP address (excludes anonymous users).
</TooltipTrigger>
{!canUpdateConfig && (
<TooltipContent side="left" className="w-80 p-4">
<p className="font-medium">
You don't have permission to update this setting
</p>
<p className="mt-1">
You need additional permissions to update auth configuration settings
</p>
</TooltipContent>
)}
</Tooltip>
{form.watch('RATE_LIMIT_TOKEN_REFRESH') > 0 && (
<p className="text-foreground-lighter text-sm mt-2">
{form.watch('RATE_LIMIT_TOKEN_REFRESH') * 12} requests per hour
</p>
}
>
Rate limit for sign ups and sign ins
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_OTP"
render={({ field }) => (
<FormItem_Shadcn_>
<FormControl_Shadcn_>
<Input_Shadcn_ type="number" {...field} disabled={!canUpdateConfig} />
</FormControl_Shadcn_>
{field.value > 0 && (
<FormDescription_Shadcn_ className="text-foreground-lighter">
This is equivalent to {field.value * 12} requests per hour
</FormDescription_Shadcn_>
)}
</FormItem_Shadcn_>
)}
/>
</FormSectionContent>
</FormSection>
</FormPanel>
</form>
</Form_Shadcn_>
)}
</div>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_VERIFY"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Rate limit for token verifications"
description="Number of OTP/Magic link verifications that can be made in a 5 minute interval per IP address"
>
<Tooltip>
<TooltipTrigger asChild>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
className="w-24"
min={0}
{...field}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</TooltipTrigger>
{!canUpdateConfig && (
<TooltipContent side="left" className="w-80 p-4">
<p className="font-medium">
You don't have permission to update this setting
</p>
<p className="mt-1">
You need additional permissions to update auth configuration settings
</p>
</TooltipContent>
)}
</Tooltip>
{form.watch('RATE_LIMIT_VERIFY') > 0 && (
<p className="text-foreground-lighter text-sm mt-2">
{form.watch('RATE_LIMIT_VERIFY') * 12} requests per hour
</p>
)}
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_ANONYMOUS_USERS"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Rate limit for anonymous users"
description="Number of anonymous sign-ins that can be made per hour per IP address"
>
<Tooltip>
<TooltipTrigger asChild>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
className="w-24"
min={0}
{...field}
disabled={!canUpdateConfig || !canUpdateAnonymousUsersRateLimit}
/>
</FormControl_Shadcn_>
</TooltipTrigger>
{!canUpdateConfig || !canUpdateAnonymousUsersRateLimit ? (
<TooltipContent side="left" className="w-80 p-4">
<p className="font-medium">
Anonymous logins are not enabled for your project
</p>
<p className="mt-1">Enable anonymous logins to update this rate limit</p>
<div className="mt-3">
<Button asChild type="default" size="tiny">
<Link href={`/project/${projectRef}/settings/auth`}>
View auth settings
</Link>
</Button>
</div>
</TooltipContent>
) : null}
</Tooltip>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="RATE_LIMIT_OTP"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Rate limit for sign ups and sign ins"
description="Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users)"
>
<Tooltip>
<TooltipTrigger asChild>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
className="w-24"
min={0}
{...field}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</TooltipTrigger>
{!canUpdateConfig && (
<TooltipContent side="left" className="w-80 p-4">
<p className="font-medium">
You don't have permission to update this setting
</p>
<p className="mt-1">
You need additional permissions to update auth configuration settings
</p>
</TooltipContent>
)}
</Tooltip>
{form.watch('RATE_LIMIT_OTP') > 0 && (
<p className="text-foreground-lighter text-sm mt-2">
{form.watch('RATE_LIMIT_OTP') * 12} requests per hour
</p>
)}
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isUpdatingConfig || !form.formState.isDirty}
loading={isUpdatingConfig}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
)
}
@@ -2,8 +2,12 @@ import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import {
ScaffoldDescription,
ScaffoldSection,
ScaffoldSectionTitle,
} from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers/Shimmers'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
@@ -69,15 +73,18 @@ export const RedirectUrls = () => {
}
return (
<div>
<ScaffoldSection isFullWidth>
<div className="flex items-center justify-between mb-6">
<FormHeader
className="mb-0"
title="Redirect URLs"
description="URLs that auth providers are permitted to redirect to post authentication. Wildcards are allowed, for example, https://*.domain.com"
/>
<div>
<ScaffoldSectionTitle>Redirect URLs</ScaffoldSectionTitle>
<ScaffoldDescription>
URLs that auth providers are permitted to redirect to post authentication. Wildcards are
allowed, for example, https://*.domain.com
</ScaffoldDescription>
</div>
<DocsButton href="https://supabase.com/docs/guides/auth/concepts/redirect-urls" />
</div>
{isLoading && (
<>
<ValueContainer>
@@ -88,6 +95,7 @@ export const RedirectUrls = () => {
</ValueContainer>
</>
)}
{isError && (
<Alert_Shadcn_ variant="destructive">
<WarningIcon />
@@ -95,6 +103,7 @@ export const RedirectUrls = () => {
<AlertDescription_Shadcn_>{authConfigError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{isSuccess && (
<RedirectUrlList
allowList={URI_ALLOW_LIST_ARRAY}
@@ -166,6 +175,6 @@ export const RedirectUrls = () => {
</Button>
</Modal.Content>
</Modal>
</div>
</ScaffoldSection>
)
}
@@ -1,13 +1,12 @@
import { yupResolver } from '@hookform/resolvers/yup'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { boolean, number, object } from 'yup'
import { useParams } from 'common'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
@@ -16,16 +15,23 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { IS_PLATFORM } from 'lib/constants'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Form,
InputNumber,
Toggle,
Button,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
PrePostTab,
Switch,
WarningIcon,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
function HoursOrNeverText({ value }: { value: number }) {
if (value === 0) {
@@ -37,11 +43,14 @@ function HoursOrNeverText({ value }: { value: number }) {
}
}
const schema = object({
const refreshTokenSchema = object({
REFRESH_TOKEN_ROTATION_ENABLED: boolean().required(),
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: number()
.min(0, 'Must be a value more than 0')
.required('Must have a Reuse Interval value'),
})
const userSessionsSchema = object({
SESSIONS_TIMEBOX: number().min(0, 'Must be a positive number'),
SESSIONS_INACTIVITY_TIMEOUT: number().min(0, 'Must be a positive number'),
SESSIONS_SINGLE_PER_USER: boolean(),
@@ -49,16 +58,13 @@ const schema = object({
const SessionsAuthSettingsForm = () => {
const { ref: projectRef } = useParams()
const {
data: authConfig,
error: authConfigError,
isLoading,
isError,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { data: authConfig, error: authConfigError, isError } = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const formId = 'auth-config-sessions-form'
// Separate loading states for each form
const [isUpdatingRefreshTokens, setIsUpdatingRefreshTokens] = useState(false)
const [isUpdatingUserSessions, setIsUpdatingUserSessions] = useState(false)
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
@@ -70,26 +76,76 @@ const SessionsAuthSettingsForm = () => {
const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free'
const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp
const INITIAL_VALUES = {
REFRESH_TOKEN_ROTATION_ENABLED: authConfig?.REFRESH_TOKEN_ROTATION_ENABLED || false,
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
SESSIONS_TIMEBOX: authConfig?.SESSIONS_TIMEBOX || 0,
SESSIONS_INACTIVITY_TIMEOUT: authConfig?.SESSIONS_INACTIVITY_TIMEOUT || 0,
SESSIONS_SINGLE_PER_USER: authConfig?.SESSIONS_SINGLE_PER_USER || false,
}
const refreshTokenForm = useForm({
resolver: yupResolver(refreshTokenSchema),
defaultValues: {
REFRESH_TOKEN_ROTATION_ENABLED: false,
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 0,
},
})
const onSubmit = (values: any, { resetForm }: any) => {
const userSessionsForm = useForm({
resolver: yupResolver(userSessionsSchema),
defaultValues: {
SESSIONS_TIMEBOX: 0,
SESSIONS_INACTIVITY_TIMEOUT: 0,
SESSIONS_SINGLE_PER_USER: false,
},
})
useEffect(() => {
if (authConfig) {
// Only reset forms if they're not currently being updated
if (!isUpdatingRefreshTokens) {
refreshTokenForm.reset({
REFRESH_TOKEN_ROTATION_ENABLED: authConfig.REFRESH_TOKEN_ROTATION_ENABLED || false,
SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL,
})
}
if (!isUpdatingUserSessions) {
userSessionsForm.reset({
SESSIONS_TIMEBOX: authConfig.SESSIONS_TIMEBOX || 0,
SESSIONS_INACTIVITY_TIMEOUT: authConfig.SESSIONS_INACTIVITY_TIMEOUT || 0,
SESSIONS_SINGLE_PER_USER: authConfig.SESSIONS_SINGLE_PER_USER || false,
})
}
}
}, [authConfig, isUpdatingRefreshTokens, isUpdatingUserSessions])
const onSubmitRefreshTokens = (values: any) => {
const payload = { ...values }
setIsUpdatingRefreshTokens(true)
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error?.message}`)
toast.error(`Failed to update refresh token settings: ${error?.message}`)
setIsUpdatingRefreshTokens(false)
},
onSuccess: () => {
toast.success('Successfully updated settings')
resetForm({ values: values, initialValues: values })
toast.success('Successfully updated refresh token settings')
setIsUpdatingRefreshTokens(false)
},
}
)
}
const onSubmitUserSessions = (values: any) => {
const payload = { ...values }
setIsUpdatingUserSessions(true)
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{
onError: (error) => {
toast.error(`Failed to update user session settings: ${error?.message}`)
setIsUpdatingUserSessions(false)
},
onSuccess: () => {
toast.success('Successfully updated user session settings')
setIsUpdatingUserSessions(false)
},
}
)
@@ -110,108 +166,204 @@ const SessionsAuthSettingsForm = () => {
}
return (
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
{({ handleReset, resetForm, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
<>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Refresh Tokens</ScaffoldSectionTitle>
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}, [isSuccess])
return (
<>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Refresh Tokens</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
<Toggle
id="REFRESH_TOKEN_ROTATION_ENABLED"
size="small"
label="Detect and revoke potentially compromised refresh tokens"
layout="flex"
descriptionText="Prevent replay attacks from potentially compromised refresh tokens."
disabled={!canUpdateConfig}
/>
{values.REFRESH_TOKEN_ROTATION_ENABLED && (
<InputNumber
id="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
size="small"
min={0}
label="Refresh token reuse interval"
descriptionText="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
actions={<span className="mr-3 text-foreground-lighter">seconds</span>}
disabled={!canUpdateConfig}
/>
<Form_Shadcn_ {...refreshTokenForm}>
<form
onSubmit={refreshTokenForm.handleSubmit(onSubmitRefreshTokens)}
className="space-y-4"
>
<Card>
<CardContent className="pt-6">
<FormField_Shadcn_
control={refreshTokenForm.control}
name="REFRESH_TOKEN_ROTATION_ENABLED"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Detect and revoke potentially compromised refresh tokens"
description="Prevent replay attacks from potentially compromised refresh tokens."
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
</FormSectionContent>
</FormSection>
/>
</CardContent>
{refreshTokenForm.watch('REFRESH_TOKEN_ROTATION_ENABLED') && (
<CardContent>
<FormField_Shadcn_
control={refreshTokenForm.control}
name="SECURITY_REFRESH_TOKEN_REUSE_INTERVAL"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Refresh token reuse interval"
description="Time interval where the same refresh token can be used multiple times to request for an access token. Recommendation: 10 seconds."
>
<FormControl_Shadcn_>
<PrePostTab postTab="seconds">
<Input_Shadcn_
type="number"
min={0}
{...field}
disabled={!canUpdateConfig}
/>
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
)}
<CardFooter className="justify-end space-x-2">
{refreshTokenForm.formState.isDirty && (
<Button type="default" onClick={() => refreshTokenForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig ||
isUpdatingRefreshTokens ||
!refreshTokenForm.formState.isDirty
}
loading={isUpdatingRefreshTokens}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
<FormSection header={<FormSectionLabel>User Sessions</FormSectionLabel>}>
<FormSectionContent loading={isLoading}>
{promptProPlanUpgrade && (
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">User Sessions</ScaffoldSectionTitle>
<Form_Shadcn_ {...userSessionsForm}>
<form
onSubmit={userSessionsForm.handleSubmit(onSubmitUserSessions)}
className="space-y-4"
>
<Card>
<CardContent>
{promptProPlanUpgrade && (
<div className="mb-4">
<UpgradeToPro
primaryText="Upgrade to Pro"
secondaryText="Configuring user sessions requires the Pro Plan."
/>
</div>
)}
<FormField_Shadcn_
control={userSessionsForm.control}
name="SESSIONS_SINGLE_PER_USER"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enforce single session per user"
description="If enabled, all but a user's most recently active session will be terminated."
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
<Toggle
id="SESSIONS_SINGLE_PER_USER"
size="small"
label="Enforce single session per user"
layout="flex"
descriptionText="If enabled, all but a user's most recently active session will be terminated."
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<InputNumber
id="SESSIONS_TIMEBOX"
size="small"
label="Time-box user sessions"
descriptionText="The amount of time before a user is forced to sign in again. Use 0 for never"
actions={
<span className="mr-3 text-foreground-lighter">
<HoursOrNeverText value={values.SESSIONS_TIMEBOX} />
</span>
}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
<InputNumber
id="SESSIONS_INACTIVITY_TIMEOUT"
size="small"
label="Inactivity timeout"
descriptionText="The amount of time a user needs to be inactive to be forced to sign in again. Use 0 for never."
actions={
<span className="mr-3 text-foreground-lighter">
<HoursOrNeverText value={values.SESSIONS_INACTIVITY_TIMEOUT} />
</span>
}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={userSessionsForm.control}
name="SESSIONS_TIMEBOX"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Time-box user sessions"
description="The amount of time before a user is forced to sign in again. Use 0 for never."
>
<div className="flex items-center">
<FormControl_Shadcn_>
<PrePostTab postTab={<HoursOrNeverText value={field.value || 0} />}>
<Input_Shadcn_
type="number"
min={0}
{...field}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</PrePostTab>
</FormControl_Shadcn_>
</div>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={userSessionsForm.control}
name="SESSIONS_INACTIVITY_TIMEOUT"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Inactivity timeout"
description="The amount of time a user needs to be inactive to be forced to sign in again. Use 0 for never."
>
<div className="flex items-center">
<FormControl_Shadcn_>
<PrePostTab postTab={<HoursOrNeverText value={field.value || 0} />}>
<Input_Shadcn_
type="number"
min={0}
{...field}
disabled={!canUpdateConfig || !isProPlanAndUp}
/>
</PrePostTab>
</FormControl_Shadcn_>
</div>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end space-x-2">
{userSessionsForm.formState.isDirty && (
<Button type="default" onClick={() => userSessionsForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!canUpdateConfig ||
isUpdatingUserSessions ||
!userSessionsForm.formState.isDirty
}
loading={isUpdatingUserSessions}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
</>
)
}
@@ -1,17 +1,29 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { object, string } from 'yup'
import { yupResolver } from '@hookform/resolvers/yup'
import { useParams } from 'common'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Form, Input } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
} from 'ui'
import { AlertCircle } from 'lucide-react'
const schema = object({
@@ -20,31 +32,40 @@ const schema = object({
const SiteUrl = () => {
const { ref: projectRef } = useParams()
const {
data: authConfig,
error: authConfigError,
isLoading,
isError,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation()
const { data: authConfig, error: authConfigError, isError } = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation()
const [isUpdatingSiteUrl, setIsUpdatingSiteUrl] = useState(false)
const formId = 'auth-config-general-form'
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
const INITIAL_VALUES = { SITE_URL: authConfig?.SITE_URL }
const siteUrlForm = useForm({
resolver: yupResolver(schema),
defaultValues: {
SITE_URL: '',
},
})
useEffect(() => {
if (authConfig && !isUpdatingSiteUrl) {
siteUrlForm.reset({
SITE_URL: authConfig.SITE_URL || '',
})
}
}, [authConfig, isUpdatingSiteUrl])
const onSubmitSiteUrl = (values: any) => {
setIsUpdatingSiteUrl(true)
const onSubmit = (values: any, { resetForm }: any) => {
const payload = { ...values }
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{ projectRef: projectRef!, config: values },
{
onError: () => {
toast.error('Failed to update settings')
onError: (error) => {
toast.error(`Failed to update site URL: ${error?.message}`)
setIsUpdatingSiteUrl(false)
},
onSuccess: () => {
toast.success('Successfully updated settings')
resetForm({ values: values, initialValues: values })
toast.success('Successfully updated site URL')
setIsUpdatingSiteUrl(false)
},
}
)
@@ -61,53 +82,49 @@ const SiteUrl = () => {
}
return (
<Form id={formId} initialValues={INITIAL_VALUES} onSubmit={onSubmit} validationSchema={schema}>
{({ handleReset, resetForm, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Site URL</ScaffoldSectionTitle>
// Form is reset once remote data is loaded in store
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) {
resetForm({ values: INITIAL_VALUES, initialValues: INITIAL_VALUES })
}
}, [isSuccess])
<Form_Shadcn_ {...siteUrlForm}>
<form onSubmit={siteUrlForm.handleSubmit(onSubmitSiteUrl)} className="space-y-4">
<Card>
<CardContent className="pt-6">
<FormField_Shadcn_
control={siteUrlForm.control}
name="SITE_URL"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Site URL"
description="Configure the default redirect URL used when a redirect URL is not specified or doesn't match one from the allow list. This value is also exposed as a template variable in the email templates section. Wildcards cannot be used here."
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} disabled={!canUpdateConfig} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
return (
<>
<FormHeader
title="Site URL"
description="Configure the default redirect URL used when a redirect URL is not specified or doesn't match one from the allow list. This value is also exposed as a template variable in the email templates section. Wildcards cannot be used here."
/>
<FormPanel
disabled={true}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={handleReset}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection>
<FormSectionContent loading={isLoading}>
<Input id="SITE_URL" size="small" label="Site URL" disabled={!canUpdateConfig} />
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
<CardFooter className="justify-end space-x-2">
{siteUrlForm.formState.isDirty && (
<Button type="default" onClick={() => siteUrlForm.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isUpdatingSiteUrl || !siteUrlForm.formState.isDirty}
loading={isUpdatingSiteUrl}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
)
}
@@ -1,14 +1,16 @@
import { yupResolver } from '@hookform/resolvers/yup'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { number, object, string } from 'yup'
import * as yup from 'yup'
import { Markdown } from 'components/interfaces/Markdown'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import {
ScaffoldContainer,
ScaffoldSection,
ScaffoldSectionTitle,
} from 'components/layouts/Scaffold'
import NoPermission from 'components/ui/NoPermission'
import { useAuthConfigQuery } from 'data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation'
@@ -18,19 +20,35 @@ import {
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
Form,
Input,
InputNumber,
Toggle,
Card,
CardContent,
CardFooter,
FormControl_Shadcn_,
FormField_Shadcn_,
Form_Shadcn_,
Input_Shadcn_,
PrePostTab,
Switch,
WarningIcon,
} from 'ui'
import EmailRateLimitsAlert from '../EmailRateLimitsAlert'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { urlRegex } from './../Auth.constants'
import { defaultDisabledSmtpFormValues } from './SmtpForm.constants'
import { generateFormValues, isSmtpEnabled } from './SmtpForm.utils'
import { AlertTriangle, Eye, EyeOff } from 'lucide-react'
import Link from 'next/link'
interface SmtpFormValues {
SMTP_ADMIN_EMAIL?: string
SMTP_SENDER_NAME?: string
SMTP_HOST?: string
SMTP_PORT?: number
SMTP_MAX_FREQUENCY?: number
SMTP_USER?: string
SMTP_PASS?: string
ENABLE_SMTP: boolean
}
const SmtpForm = () => {
const { ref: projectRef } = useParams()
const {
@@ -45,46 +63,30 @@ const SmtpForm = () => {
const [enableSmtp, setEnableSmtp] = useState(false)
const [hidden, setHidden] = useState(true)
const formId = 'auth-config-smtp-form'
const initialValues = generateFormValues(authConfig)
const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue')
useEffect(() => {
if (isSuccess && isSmtpEnabled(authConfig)) {
setEnableSmtp(true)
}
}, [isSuccess, authConfig])
const schema = object({
SMTP_ADMIN_EMAIL: string().when([], {
is: () => {
return enableSmtp
},
const smtpSchema = yup.object({
SMTP_ADMIN_EMAIL: yup.string().when('ENABLE_SMTP', {
is: true,
then: (schema) => schema.email('Must be a valid email').required('Sender email is required'),
otherwise: (schema) => schema,
}),
SMTP_SENDER_NAME: string().when([], {
is: () => {
return enableSmtp
},
SMTP_SENDER_NAME: yup.string().when('ENABLE_SMTP', {
is: true,
then: (schema) => schema.required('Sender name is required'),
otherwise: (schema) => schema,
}),
SMTP_HOST: string().when([], {
is: () => {
return enableSmtp
},
SMTP_HOST: yup.string().when('ENABLE_SMTP', {
is: true,
then: (schema) =>
schema
.matches(urlRegex({ excludeSimpleDomains: false }), 'Must be a valid URL or IP address')
.required('Host URL is required.'),
otherwise: (schema) => schema,
}),
SMTP_PORT: number().when([], {
is: () => {
return enableSmtp
},
SMTP_PORT: yup.number().when('ENABLE_SMTP', {
is: true,
then: (schema) =>
schema
.required('Port number is required.')
@@ -92,10 +94,8 @@ const SmtpForm = () => {
.max(65535, 'Must be a valid port number no more than 65535'),
otherwise: (schema) => schema,
}),
SMTP_MAX_FREQUENCY: number().when([], {
is: () => {
return enableSmtp
},
SMTP_MAX_FREQUENCY: yup.number().when('ENABLE_SMTP', {
is: true,
then: (schema) =>
schema
.required('Rate limit is required.')
@@ -103,28 +103,63 @@ const SmtpForm = () => {
.max(32767, 'Must not be more than 32,767 an hour'),
otherwise: (schema) => schema,
}),
SMTP_USER: string().when([], {
is: () => {
return enableSmtp
},
SMTP_USER: yup.string().when('ENABLE_SMTP', {
is: true,
then: (schema) => schema.required('SMTP Username is required'),
otherwise: (schema) => schema,
}),
SMTP_PASS: string().when([], {
is: () => {
return enableSmtp && authConfig?.SMTP_PASS === null
},
then: (schema) => schema.required('SMTP password is required'),
otherwise: (schema) => schema,
}),
SMTP_PASS: yup.string(),
ENABLE_SMTP: yup.boolean().required(),
})
const onSubmit = (values: any, { resetForm }: any) => {
const payload = enableSmtp ? values : defaultDisabledSmtpFormValues
const form = useForm<SmtpFormValues>({
resolver: yupResolver(smtpSchema),
defaultValues: {
SMTP_ADMIN_EMAIL: '',
SMTP_SENDER_NAME: '',
SMTP_HOST: '',
SMTP_PORT: undefined,
SMTP_MAX_FREQUENCY: undefined,
SMTP_USER: '',
SMTP_PASS: '',
ENABLE_SMTP: false,
},
})
// Format payload: Remove redundant value + convert port to string
delete payload.ENABLE_SMTP
payload.SMTP_PORT = payload.SMTP_PORT ? payload.SMTP_PORT.toString() : payload.SMTP_PORT
// Update form values when auth config is loaded
useEffect(() => {
if (authConfig) {
const formValues = generateFormValues(authConfig)
// Convert SMTP_PORT from string to number if it exists
if (formValues.SMTP_PORT) {
formValues.SMTP_PORT = Number(formValues.SMTP_PORT) as any
}
form.reset({
...formValues,
ENABLE_SMTP: isSmtpEnabled(authConfig),
} as SmtpFormValues)
setEnableSmtp(isSmtpEnabled(authConfig))
}
}, [authConfig, form])
// Update enableSmtp state when the form field changes
useEffect(() => {
const subscription = form.watch((value, { name }) => {
if (name === 'ENABLE_SMTP') {
setEnableSmtp(value.ENABLE_SMTP as boolean)
}
})
return () => subscription.unsubscribe()
}, [form])
const onSubmit = (values: SmtpFormValues) => {
const { ENABLE_SMTP, ...rest } = values
const payload = ENABLE_SMTP ? rest : defaultDisabledSmtpFormValues
// Format payload: Convert port to string
if (payload.SMTP_PORT) {
payload.SMTP_PORT = payload.SMTP_PORT.toString() as any
}
// the SMTP_PASS is write-only, it's never shown. If we don't delete it from the payload, it will replace the
// previously saved value with an empty one
@@ -133,7 +168,7 @@ const SmtpForm = () => {
}
updateAuthConfig(
{ projectRef: projectRef!, config: payload },
{ projectRef: projectRef!, config: payload as any },
{
onError: (error) => {
toast.error(`Failed to update settings: ${error.message}`)
@@ -141,8 +176,6 @@ const SmtpForm = () => {
onSuccess: () => {
setHidden(true)
toast.success('Successfully updated settings')
const updatedFormValues = generateFormValues(payload)
resetForm({ values: updatedFormValues, initialValues: updatedFormValues })
},
}
)
@@ -163,206 +196,284 @@ const SmtpForm = () => {
}
return (
<Form id={formId} initialValues={initialValues} onSubmit={onSubmit} validationSchema={schema}>
{({ resetForm, values }: any) => {
const isValidSmtpConfig = isSmtpEnabled(values)
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isSuccess) {
const formValues = generateFormValues(authConfig)
resetForm({ values: formValues, initialValues: formValues })
}
}, [isSuccess, authConfig])
const onResetForm = () => {
setEnableSmtp(isSmtpEnabled(initialValues))
resetForm({ values: initialValues })
}
return (
<>
<FormPanel
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdatingConfig}
hasChanges={hasChanges}
handleReset={onResetForm}
disabled={!canUpdateConfig}
helper={
!canUpdateConfig
? 'You need additional permissions to update authentication settings'
: undefined
}
/>
</div>
}
>
<FormSection>
<FormSectionContent className="!col-span-12 !gap-y-2" loading={isLoading}>
<Toggle
size="small"
layout="flex"
name="ENABLE_SMTP"
label="Enable Custom SMTP"
checked={enableSmtp}
disabled={!canUpdateConfig}
// @ts-ignore
onChange={(value: boolean) => setEnableSmtp(value)}
descriptionText={
<p className="max-w-full prose text-sm text-foreground-lighter">
Emails will be sent using your custom SMTP provider. Email rate limits can
be adjusted{' '}
<Link
className="underline"
href={`/project/${projectRef}/auth/rate-limits`}
>
here
</Link>
.
</p>
}
/>
{enableSmtp && !isValidSmtpConfig && !isValidSmtpConfig && (
<div className="">
<Alert_Shadcn_ variant="warning">
<AlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>All fields below must be filled</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
The following fields must be filled before custom SMTP can be properly
enabled
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
<ScaffoldContainer>
<ScaffoldSection isFullWidth>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="ENABLE_SMTP"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable Custom SMTP"
description={
<p className="max-w-full prose text-sm text-foreground-lighter">
Emails will be sent using your custom SMTP provider. Email rate limits can
be adjusted{' '}
<Link
className="underline"
href={`/project/${projectRef}/auth/rate-limits`}
>
here
</Link>
.
</p>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
</FormSectionContent>
</FormSection>
/>
<FormSection
visible={enableSmtp}
header={<FormSectionLabel>Sender details</FormSectionLabel>}
disabled={!enableSmtp}
>
<FormSectionContent loading={isLoading}>
<Input
name="SMTP_ADMIN_EMAIL"
id="SMTP_ADMIN_EMAIL"
label="Sender email"
descriptionText="This is the email address the emails are sent from"
placeholder="noreply@yourdomain.com"
disabled={!canUpdateConfig}
/>
<Input
name="SMTP_SENDER_NAME"
id="SMTP_SENDER_NAME"
label="Sender name"
descriptionText="Name displayed in the recipient's inbox"
placeholder="The name shown on the email"
disabled={!canUpdateConfig}
/>
</FormSectionContent>
</FormSection>
<FormSection
visible={enableSmtp}
disabled={!enableSmtp}
header={
<FormSectionLabel>
<span>SMTP Provider Settings</span>
<p className="my-4 text-foreground-lighter">
Your SMTP Credentials will always be encrypted in our database.
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={isLoading}>
{values['SMTP_HOST'] && values['SMTP_HOST'].endsWith('.gmail.com') && (
{enableSmtp && !isSmtpEnabled(form.getValues() as any) && (
<div className="mt-4">
<Alert_Shadcn_ variant="warning">
<AlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>Check your SMTP provider</AlertTitle_Shadcn_>
<AlertTitle_Shadcn_>All fields below must be filled</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Not all SMTP providers are designed for the email sending required by
Supabase Auth. It looks like the SMTP provider you entered is designed for
sending personal email messages and not for sending transactional messages.
Although you can ignore this warning, email deliverability may be impacted.
The following fields must be filled before custom SMTP can be properly
enabled
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<Input
name="SMTP_HOST"
placeholder="your.smtp.host.com"
id="SMTP_HOST"
label="Host"
descriptionText="Hostname or IP address of your SMTP server."
disabled={!canUpdateConfig}
/>
<InputNumber
name="SMTP_PORT"
id="SMTP_PORT"
placeholder="587"
label="Port number"
descriptionText={
<>
<span className="block">
Port used by your SMTP server. Common ports include 25, 465, and 587.{' '}
</span>
<span className="mt-2 block">
Avoid using port 25 as modern SMTP email clients shouldn't use this port,
it is traditionally blocked by residential ISPs and Cloud Hosting
Providers, to curb the amount of spam.
</span>
</>
}
disabled={!canUpdateConfig}
/>
<InputNumber
id="SMTP_MAX_FREQUENCY"
name="SMTP_MAX_FREQUENCY"
label="Minimum interval between emails being sent"
descriptionText="How long between each email can a new email be sent via your SMTP server."
actions={<span className="mr-3 text-foreground-lighter">seconds</span>}
disabled={!canUpdateConfig}
/>
<Input
name="SMTP_USER"
id="SMTP_USER"
label="Username"
placeholder="SMTP Username"
disabled={!canUpdateConfig}
/>
<Input
name="SMTP_PASS"
id="SMTP_PASS"
type={hidden ? 'password' : 'text'}
label="Password"
placeholder={authConfig?.SMTP_PASS === null ? 'SMTP Password' : '••••••••'}
actions={
<Button
icon={hidden ? <Eye /> : <EyeOff />}
type="default"
onClick={() => setHidden(!hidden)}
/>
}
disabled={!canUpdateConfig}
descriptionText={
<span>
For security reasons, the password is write-only. Once saved, it cannot be
retrieved or displayed.
</span>
}
/>
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
</div>
)}
</CardContent>
{enableSmtp && (
<>
<CardContent className="py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-4">
<h3 className="text-sm mb-1">Sender details</h3>
<p className="text-sm text-foreground-lighter">
Configure the sender information for your emails.
</p>
</div>
<div className="col-span-8 space-y-4">
<FormField_Shadcn_
control={form.control}
name="SMTP_ADMIN_EMAIL"
render={({ field }) => (
<FormItemLayout
label="Sender email"
description="This is the email address the emails are sent from"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="noreply@yourdomain.com"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_SENDER_NAME"
render={({ field }) => (
<FormItemLayout
label="Sender name"
description="Name displayed in the recipient's inbox"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="The name shown on the email"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
<CardContent className="py-6">
<div className="grid grid-cols-12 gap-6">
<div className="col-span-4">
<h3 className="text-sm mb-1">SMTP Provider Settings</h3>
<p className="text-sm text-foreground-lighter">
Your SMTP Credentials will always be encrypted in our database.
</p>
</div>
<div className="col-span-8 space-y-4">
{form.watch('SMTP_HOST')?.endsWith('.gmail.com') && (
<Alert_Shadcn_ variant="warning" className="mb-4">
<AlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>Check your SMTP provider</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Not all SMTP providers are designed for the email sending required by
Supabase Auth. It looks like the SMTP provider you entered is designed
for sending personal email messages and not for sending transactional
messages. Although you can ignore this warning, email deliverability
may be impacted.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<FormField_Shadcn_
control={form.control}
name="SMTP_HOST"
render={({ field }) => (
<FormItemLayout
label="Host"
description="Hostname or IP address of your SMTP server."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="your.smtp.host.com"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_PORT"
render={({ field }) => (
<FormItemLayout
label="Port number"
description={
<>
<span className="block">
Port used by your SMTP server. Common ports include 25, 465, and
587.{' '}
</span>
<span className="mt-2 block">
Avoid using port 25 as modern SMTP email clients shouldn't use
this port, it is traditionally blocked by residential ISPs and
Cloud Hosting Providers, to curb the amount of spam.
</span>
</>
}
>
<FormControl_Shadcn_>
<Input_Shadcn_
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
placeholder="587"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_MAX_FREQUENCY"
render={({ field }) => (
<FormItemLayout
label="Minimum interval between emails being sent"
description="How long between each email can a new email be sent via your SMTP server."
>
<FormControl_Shadcn_>
<PrePostTab postTab="seconds">
<Input_Shadcn_
type="number"
value={field.value}
onChange={(e) => field.onChange(Number(e.target.value))}
disabled={!canUpdateConfig}
/>
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_USER"
render={({ field }) => (
<FormItemLayout
label="Username"
description="Username for your SMTP server"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
placeholder="SMTP Username"
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="SMTP_PASS"
render={({ field }) => (
<FormItemLayout
label="Password"
description="For security reasons, the password is write-only. Once saved, it cannot be retrieved or displayed."
>
<FormControl_Shadcn_>
<PrePostTab
postTab={
<Button
type="text"
className="p-0"
onClick={() => setHidden(!hidden)}
icon={hidden ? <Eye /> : <EyeOff />}
/>
}
>
<Input_Shadcn_
{...field}
type={hidden ? 'password' : 'text'}
placeholder={
authConfig?.SMTP_PASS === null ? 'SMTP Password' : '••••••••'
}
disabled={!canUpdateConfig}
/>
</PrePostTab>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</div>
</CardContent>
</>
)}
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdatingConfig}
disabled={!canUpdateConfig || !form.formState.isDirty}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
</ScaffoldContainer>
)
}
@@ -377,7 +377,7 @@ export const DatabaseConnectionString = () => {
]}
onCopyCallback={() => handleCopy(selectedTab, 'transaction_pooler')}
/>
{ipv4Addon && (
{ipv4Addon && !isPgBouncerEnabled && (
<Admonition
type="warning"
title="Highly recommended to not use Session Pooler"
@@ -4,10 +4,12 @@ import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { DocsButton } from 'components/ui/DocsButton'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useDatabaseExtensionEnableMutation } from 'data/database-extensions/database-extension-enable-mutation'
import { useSchemasQuery } from 'data/database/schemas-query'
import { executeSql } from 'data/sql/execute-sql-query'
import { useIsOrioleDb } from 'hooks/misc/useSelectedProject'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
@@ -20,8 +22,6 @@ import {
WarningIcon,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { DocsButton } from 'components/ui/DocsButton'
import { useIsOrioleDb } from 'hooks/misc/useSelectedProject'
const orioleExtCallOuts = ['vector', 'postgis']
@@ -43,7 +43,7 @@ const EnableExtensionModal = ({ visible, extension, onCancel }: EnableExtensionM
})
const { mutate: enableExtension, isLoading: isEnabling } = useDatabaseExtensionEnableMutation({
onSuccess: () => {
toast.success(`${extension.name} is on.`)
toast.success(`Extension "${extension.name}" is now enabled`)
onCancel()
},
onError: (error) => {
@@ -45,8 +45,10 @@ export const WrapperOverviewTab = () => {
const isWrappersExtensionInstalled = !!wrappersExtension?.installed_version
const hasRequiredVersion =
(wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '')
// [Joshen] Default version is what's on the DB, so if the installed version is already the default version
// but still doesnt meet the minimum extension version, then DB upgrade is required
const databaseNeedsUpgrading =
wrappersExtension?.installed_version !== wrappersExtension?.default_version
wrappersExtension?.installed_version === wrappersExtension?.default_version
return (
<IntegrationOverviewTab
@@ -17,6 +17,8 @@ export const WRAPPER_HANDLERS = {
SNOWFLAKE: 'wasm_fdw_handler',
CAL: 'wasm_fdw_handler',
CALENDLY: 'wasm_fdw_handler',
CLERK: 'wasm_fdw_handler',
NOTION: 'wasm_fdw_handler',
}
export const WRAPPERS: WrapperMeta[] = [
@@ -2530,4 +2532,497 @@ export const WRAPPERS: WrapperMeta[] = [
},
],
},
{
name: 'clerk_wrapper',
description: 'User Management Platform',
handlerName: WRAPPER_HANDLERS.CLERK,
validatorName: 'wasm_fdw_validator',
icon: `${BASE_PATH}/img/icons/clerk-icon.svg`,
extensionName: 'clerkFdw',
label: 'Clerk',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/clerk',
minimumExtensionVersion: '0.4.0',
server: {
options: [
{
name: 'fdw_package_url',
label: 'FDW Package URL',
required: true,
encrypted: false,
secureEntry: false,
defaultValue:
'https://github.com/supabase/wrappers/releases/download/wasm_clerk_fdw_v0.1.0/clerk_fdw.wasm',
hidden: true,
},
{
name: 'fdw_package_name',
label: 'FDW Package Name',
required: true,
encrypted: false,
secureEntry: false,
defaultValue: 'supabase:clerk-fdw',
hidden: true,
},
{
name: 'fdw_package_version',
label: 'FDW Package Version',
required: true,
encrypted: false,
secureEntry: false,
defaultValue: '0.1.0',
hidden: true,
},
{
name: 'fdw_package_checksum',
label: 'FDW Package Checksum',
required: true,
encrypted: false,
secureEntry: false,
defaultValue: '613be26b59fa4c074e0b93f0db617fcd7b468d4d02edece0b1f85fdb683ebdc4',
hidden: true,
},
{
name: 'api_url',
label: 'Clerk API URL',
required: false,
encrypted: false,
secureEntry: false,
defaultValue: 'https://api.clerk.com/v1',
},
{
name: 'api_key_id',
label: 'Clerk API Key',
required: true,
encrypted: true,
secureEntry: true,
},
],
},
tables: [
{
label: 'Allow List',
description: 'List of all identifiers allowed to sign up to an instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'invitation_id', type: 'text' },
{ name: 'identifier', type: 'text' },
{ name: 'identifier_type', type: 'text' },
{ name: 'instance_id', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'allowlist_identifiers',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Block List',
description: 'List of all identifiers which are not allowed to access an instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'identifier', type: 'text' },
{ name: 'identifier_type', type: 'text' },
{ name: 'instance_id', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'blocklist_identifiers',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Domains',
description: 'List of all domains for an instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'name', type: 'text' },
{ name: 'is_satellite', type: 'boolean' },
{ name: 'frontend_api_url', type: 'text' },
{ name: 'accounts_portal_url', type: 'text' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'domains',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Invitations',
description: 'List of all non-revoked invitations for your application',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'email_address', type: 'text' },
{ name: 'url', type: 'text' },
{ name: 'revoked', type: 'boolean' },
{ name: 'status', type: 'text' },
{ name: 'expires_at', type: 'timestamp' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'invitations',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'JWT Templates',
description: 'List of all JWT templates',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'name', type: 'text' },
{ name: 'lifetime', type: 'bigint' },
{ name: 'allowed_clock_skew', type: 'bigint' },
{ name: 'custom_signing_key', type: 'boolean' },
{ name: 'signing_algorithm', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'jwt_templates',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'OAuth Applications',
description: 'List of OAuth applications for an instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'name', type: 'text' },
{ name: 'instance_id', type: 'text' },
{ name: 'client_id', type: 'text' },
{ name: 'public', type: 'boolean' },
{ name: 'scopes', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'oauth_applications',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Organizations',
description: 'List of organizations for an instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'name', type: 'text' },
{ name: 'slug', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'organizations',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Organization Invitations',
description: 'List of organization invitations for an instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'email_address', type: 'text' },
{ name: 'role', type: 'text' },
{ name: 'role_name', type: 'text' },
{ name: 'organization_id', type: 'text' },
{ name: 'status', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'organization_invitations',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Organization Memberships',
description: 'List of organization user memberships for an instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'role', type: 'text' },
{ name: 'role_name', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'organization_memberships',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Redirect URLs',
description: 'List of all whitelisted redirect urls for the instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'url', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'redirect_urls',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'SAML Connections',
description: 'List of SAML Connections for an instance',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'name', type: 'text' },
{ name: 'domain', type: 'text' },
{ name: 'active', type: 'boolean' },
{ name: 'provider', type: 'text' },
{ name: 'user_count', type: 'bigint' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'saml_connections',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Users',
description: 'List of all users',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'external_id', type: 'text' },
{ name: 'username', type: 'text' },
{ name: 'first_name', type: 'text' },
{ name: 'last_name', type: 'text' },
{ name: 'created_at', type: 'timestamp' },
{ name: 'updated_at', type: 'timestamp' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'users',
editable: false,
required: true,
type: 'text',
},
],
},
],
},
{
name: 'notion_wrapper',
description: 'Notion provides a versatile, ready-to-use solution for managing your data.',
handlerName: WRAPPER_HANDLERS.NOTION,
validatorName: 'wasm_fdw_validator',
icon: `${BASE_PATH}/img/icons/notion-icon.svg`,
extensionName: 'NotionFdw',
label: 'Notion',
docsUrl: 'https://fdw.dev/catalog/notion/',
minimumExtensionVersion: '0.4.0',
server: {
options: [
{
name: 'fdw_package_url',
label: 'FDW Package URL',
required: true,
encrypted: false,
secureEntry: false,
defaultValue:
'https://github.com/supabase/wrappers/releases/download/wasm_notion_fdw_v0.1.1/notion_fdw.wasm',
hidden: true,
},
{
name: 'fdw_package_name',
label: 'FDW Package Name',
required: true,
encrypted: false,
secureEntry: false,
defaultValue: 'supabase:notion-fdw',
hidden: true,
},
{
name: 'fdw_package_version',
label: 'FDW Package Version',
required: true,
encrypted: false,
secureEntry: false,
defaultValue: '0.1.1',
hidden: true,
},
{
name: 'fdw_package_checksum',
label: 'FDW Package Checksum',
required: true,
encrypted: false,
secureEntry: false,
defaultValue: '6dea3014f462aafd0c051c37d163fe326e7650c26a7eb5d8017a30634b5a46de',
hidden: true,
},
{
name: 'api_url',
label: 'API URL',
required: false,
encrypted: false,
secureEntry: false,
defaultValue: 'https://api.notion.com/v1',
},
{
name: 'api_key_id',
label: 'Notion API Key',
required: true,
encrypted: true,
secureEntry: true,
},
],
},
tables: [
{
label: 'Blocks',
description: 'Notion Block content',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'page_id', type: 'text' },
{ name: 'type', type: 'text' },
{ name: 'created_time', type: 'timestamp' },
{ name: 'last_edited_time', type: 'timestamp' },
{ name: 'archived', type: 'boolean' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'block',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Pages',
description: 'Notion Pages',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'url', type: 'text' },
{ name: 'created_time', type: 'timestamp' },
{ name: 'last_edited_time', type: 'timestamp' },
{ name: 'archived', type: 'boolean' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'page',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Databases',
description: 'Notion Databases',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'url', type: 'text' },
{ name: 'created_time', type: 'timestamp' },
{ name: 'last_edited_time', type: 'timestamp' },
{ name: 'archived', type: 'boolean' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'database',
editable: false,
required: true,
type: 'text',
},
],
},
{
label: 'Users',
description: 'Notion Users',
availableColumns: [
{ name: 'id', type: 'text' },
{ name: 'name', type: 'text' },
{ name: 'type', type: 'text' },
{ name: 'avatar_url', type: 'text' },
{ name: 'attrs', type: 'jsonb' },
],
options: [
{
name: 'object',
defaultValue: 'user',
editable: false,
required: true,
type: 'text',
},
],
},
],
},
]
@@ -0,0 +1,348 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import { ScaffoldSection } from 'components/layouts/Scaffold'
import { convertFromBytes } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils'
import AlertError from 'components/ui/AlertError'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { useRealtimeConfigurationUpdateMutation } from 'data/realtime/realtime-config-mutation'
import {
REALTIME_DEFAULT_CONFIG,
useRealtimeConfigurationQuery,
} from 'data/realtime/realtime-config-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Button,
Card,
CardContent,
CardFooter,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
Switch,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const formId = 'realtime-configuration-form'
const FormSchema = z.object({
private_only: z.boolean(),
connection_pool: z.coerce.number().min(1).max(100),
max_concurrent_users: z.coerce.number().min(1).max(50000),
max_events_per_second: z.coerce.number().min(1).max(50000),
max_bytes_per_second: z.coerce.number().min(1).max(10000000),
max_channels_per_client: z.coerce.number().min(1).max(10000),
max_joins_per_second: z.coerce.number().min(1).max(5000),
})
export const RealtimeSettings = () => {
const { ref: projectRef } = useParams()
const canUpdateConfig = useCheckPermissions(PermissionAction.REALTIME_ADMIN_READ, '*')
const { data, error, isLoading, isSuccess, isError } = useRealtimeConfigurationQuery({
projectRef,
})
const { mutate: updateRealtimeConfig, isLoading: isUpdatingConfig } =
useRealtimeConfigurationUpdateMutation({
onSuccess: () => {
form.reset(form.getValues())
toast.success('Successfully updated realtime settings')
},
})
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: REALTIME_DEFAULT_CONFIG,
})
const onSubmit: SubmitHandler<z.infer<typeof FormSchema>> = (data) => {
if (!projectRef) return console.error('Project ref is required')
updateRealtimeConfig({ ref: projectRef, ...data })
}
useEffect(() => {
// [Joshen] Temp typed with any - API typing marks all the properties as nullable,
// but checked with Filipe that they're not supposed to
if (data) form.reset(data as any)
}, [isSuccess])
return (
<ScaffoldSection isFullWidth>
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
{isError ? (
<AlertError error={error} subject="Failed to retrieve realtime settings" />
) : (
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="private_only"
render={({ field }) => (
<FormSection
className="!p-0 !pt-2"
header={<FormSectionLabel>Channel restrictions</FormSectionLabel>}
>
<FormSectionContent loading={isLoading} className="!gap-y-2">
<FormItemLayout
layout="flex"
label="Private channels only"
description="If this is enabled, only private channels will be allowed"
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
</FormSectionContent>
</FormSection>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="connection_pool"
render={({ field }) => (
<FormSection
className="!p-0 !py-2"
header={
<FormSectionLabel
description={
<p className="text-foreground-lighter text-sm !mt-1">
Sets connection pool size for Realtime Authorization
</p>
}
>
Connection pool size
</FormSectionLabel>
}
>
<FormSectionContent loading={isLoading} className="!gap-y-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormSectionContent>
</FormSection>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="max_concurrent_users"
render={({ field }) => (
<FormSection
className="!p-0 !py-2"
header={
<FormSectionLabel
description={
<p className="text-foreground-lighter text-sm !mt-1">
Sets maximum number of concurrent users rate limit
</p>
}
>
Max concurrent users
</FormSectionLabel>
}
>
<FormSectionContent loading={isLoading} className="!gap-y-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormSectionContent>
</FormSection>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="max_events_per_second"
render={({ field }) => (
<FormSection
className="!p-0 !py-2"
header={
<FormSectionLabel
description={
<p className="text-foreground-lighter text-sm !mt-1">
Sets maximum number of events per second rate per channel limit
</p>
}
>
Max events per second
</FormSectionLabel>
}
>
<FormSectionContent loading={isLoading} className="!gap-y-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormSectionContent>
</FormSection>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="max_bytes_per_second"
render={({ field }) => {
const { value, unit } = convertFromBytes(field.value ?? 0)
return (
<FormSection
className="!p-0 !py-2"
header={
<FormSectionLabel
description={
<p className="text-foreground-lighter text-sm !mt-1">
Sets maximum number of bytes per second rate per channel limit
</p>
}
>
Max bytes per second
</FormSectionLabel>
}
>
<FormSectionContent loading={isLoading} className="!gap-y-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
{...form.register('max_bytes_per_second', { valueAsNumber: true })}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
{!!field.value ? (
<span className="text-sm text-foreground-lighter">
This is equivalent to {value.toFixed(2)} {unit}
</span>
) : null}
</FormSectionContent>
</FormSection>
)
}}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="max_channels_per_client"
render={({ field }) => (
<FormSection
className="!p-0 !py-2"
header={
<FormSectionLabel
description={
<p className="text-foreground-lighter text-sm !mt-1">
Sets maximum number of channels per client rate limit
</p>
}
>
Max channels per client
</FormSectionLabel>
}
>
<FormSectionContent loading={isLoading} className="!gap-y-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
{...form.register('max_channels_per_client', { valueAsNumber: true })}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormSectionContent>
</FormSection>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="max_joins_per_second"
render={({ field }) => (
<FormSection
className="!p-0 !py-2"
header={
<FormSectionLabel
description={
<p className="text-foreground-lighter text-sm !mt-1">
Sets maximum number of joins per second rate limit
</p>
}
>
Max joins per second
</FormSectionLabel>
}
>
<FormSectionContent loading={isLoading} className="!gap-y-2">
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
disabled={!canUpdateConfig}
value={field.value || ''}
{...form.register('max_joins_per_second', { valueAsNumber: true })}
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormSectionContent>
</FormSection>
)}
/>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset(data as any)}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || isUpdatingConfig || !form.formState.isDirty}
loading={isUpdatingConfig}
>
Save changes
</Button>
</CardFooter>
</Card>
)}
</form>
</Form_Shadcn_>
</ScaffoldSection>
)
}
@@ -108,6 +108,21 @@ export const ChartConfig = ({
const resultToRender = config.cumulative ? cumulativeResults : results.rows
const getDateFormat = (key: any) => {
const value = resultToRender?.[0]?.[key] || ''
if (typeof value === 'number') return 'number'
if (dayjs(value).isValid()) return 'date'
return 'string'
}
const xKeyDateFormat = getDateFormat(config.xKey)
const onFlip = () => {
const newY = config.xKey
const newX = config.yKey
onConfigChange({ ...config, xKey: newX, yKey: newY })
}
if (!resultKeys.length) {
return (
<div className="p-2">
@@ -119,70 +134,45 @@ export const ChartConfig = ({
)
}
const getDateFormat = (key: any) => {
const value = resultToRender?.[0]?.[key] || ''
if (typeof value === 'number') return 'number'
if (dayjs(value).isValid()) return 'date'
return 'string'
}
const xKeyDateFormat = getDateFormat(config.xKey)
const ChartPanel = () => {
if (!hasConfig) {
return (
<ResizablePanel className="p-4 h-full" defaultSize={75}>
<NoDataPlaceholder
size="normal"
title="Configure your chart"
description="Select your X and Y axis in the chart options panel"
/>
</ResizablePanel>
)
}
if (config.type === 'bar') {
return (
<BarChart
showLegend
size="normal"
xAxisIsDate={xKeyDateFormat === 'date'}
data={resultToRender}
xAxisKey={config.xKey}
yAxisKey={config.yKey}
showGrid={config.showGrid}
XAxisProps={{
angle: 0,
interval: 'preserveStart',
hide: !config.showLabels,
tickFormatter: (idx: string) => {
const value = resultToRender[+idx][config.xKey]
if (xKeyDateFormat === 'date') {
return dayjs(value).format('MMM D YYYY HH:mm')
}
return value
},
}}
YAxisProps={{
tickFormatter: (value: number) => value.toLocaleString(),
hide: !config.showLabels,
domain: [0, 'dataMax'],
}}
/>
)
}
}
const onFlip = () => {
const newY = config.xKey
const newX = config.yKey
onConfigChange({ ...config, xKey: newX, yKey: newY })
}
return (
<ResizablePanelGroup direction="horizontal" className="flex-grow h-full">
<ResizablePanel className="p-4 h-full" defaultSize={75}>
<ChartPanel />
{!hasConfig ? (
<ResizablePanel className="p-4 h-full" defaultSize={75}>
<NoDataPlaceholder
size="normal"
title="Configure your chart"
description="Select your X and Y axis in the chart options panel"
/>
</ResizablePanel>
) : config.type === 'bar' ? (
<BarChart
showLegend
size="normal"
xAxisIsDate={xKeyDateFormat === 'date'}
data={resultToRender}
xAxisKey={config.xKey}
yAxisKey={config.yKey}
showGrid={config.showGrid}
XAxisProps={{
angle: 0,
interval: 'preserveStart',
hide: !config.showLabels,
tickFormatter: (idx: string) => {
const value = resultToRender[+idx][config.xKey]
if (xKeyDateFormat === 'date') {
return dayjs(value).format('MMM D YYYY HH:mm')
}
return value
},
}}
YAxisProps={{
tickFormatter: (value: number) => value.toLocaleString(),
hide: !config.showLabels,
domain: [0, 'dataMax'],
}}
/>
) : null}
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
@@ -281,7 +281,6 @@ export const ConnectionPooling = () => {
{
ref: projectRef,
default_pool_size,
pool_mode: pool_mode as 'transaction' | 'session',
},
{
onSuccess: (data) => {
@@ -524,100 +523,102 @@ export const ConnectionPooling = () => {
/>
)}
<FormField_Shadcn_
control={form.control}
name="pool_mode"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Pool Mode"
description={
<>
{disablePoolModeSelection && (
<Alert_Shadcn_ className="mt-0">
<AlertTitle_Shadcn_ className="text-foreground">
Pool mode is permanently set to Transaction on port 6543
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
You can use Session mode by connecting to the pooler on port 5432
instead
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{showPoolModeWarning && (
<>
{field.value === 'transaction' ? (
<Admonition
type="warning"
title="Pool mode will be set to transaction permanently on port 6543"
description="This will take into effect once saved. If you are using Session mode with port 6543 in your applications, please update to use port 5432 instead before saving."
/>
) : (
<>
<Panel.Notice
layout="vertical"
className="border rounded-lg"
title="Deprecating Session Mode on Port 6543"
description="On February 28, 2025, Supavisor is deprecating Session Mode on port 6543. Please update your application/database clients to use port 5432 for Session Mode."
href="https://github.com/orgs/supabase/discussions/32755"
buttonText="Read the announcement"
/>
{type === 'PgBouncer' && (
<FormField_Shadcn_
control={form.control}
name="pool_mode"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="Pool Mode"
description={
<>
{disablePoolModeSelection && (
<Alert_Shadcn_ className="mt-0">
<AlertTitle_Shadcn_ className="text-foreground">
Pool mode is permanently set to Transaction on port 6543
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
You can use Session mode by connecting to the pooler on port
5432 instead
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{showPoolModeWarning && (
<>
{field.value === 'transaction' ? (
<Admonition
className="mt-2"
showIcon={false}
type="default"
title="Set to transaction mode to use both pooling modes concurrently"
description="Session mode can be used concurrently with transaction mode by
type="warning"
title="Pool mode will be set to transaction permanently on port 6543"
description="This will take into effect once saved. If you are using Session mode with port 6543 in your applications, please update to use port 5432 instead before saving."
/>
) : (
<>
<Panel.Notice
layout="vertical"
className="border rounded-lg"
title="Deprecating Session Mode on Port 6543"
description="On February 28, 2025, Supavisor is deprecating Session Mode on port 6543. Please update your application/database clients to use port 5432 for Session Mode."
href="https://github.com/orgs/supabase/discussions/32755"
buttonText="Read the announcement"
/>
<Admonition
className="mt-2"
showIcon={false}
type="default"
title="Set to transaction mode to use both pooling modes concurrently"
description="Session mode can be used concurrently with transaction mode by
using 5432 for session and 6543 for transaction. However, by
configuring the pooler mode to session here, you will not be able
to use transaction mode at the same time."
/>
</>
)}
</>
)}
<p className="mt-2">
Specify when a connection can be returned to the pool.{' '}
<span
tabIndex={0}
onClick={() => snap.setShowPoolingModeHelper(true)}
className="transition cursor-pointer underline underline-offset-2 decoration-foreground-lighter hover:decoration-foreground text-foreground"
>
Learn more about pool modes
</span>
.
</p>
</>
}
>
<FormControl_Shadcn_>
<Listbox
disabled={disablePoolModeSelection}
value={field.value}
className="w-full"
onChange={(value) => field.onChange(value)}
>
<Listbox.Option
key="transaction"
label="Transaction"
value="transaction"
/>
</>
)}
</>
)}
<p className="mt-2">
Specify when a connection can be returned to the pool.{' '}
<span
tabIndex={0}
onClick={() => snap.setShowPoolingModeHelper(true)}
className="transition cursor-pointer underline underline-offset-2 decoration-foreground-lighter hover:decoration-foreground text-foreground"
>
Learn more about pool modes
</span>
.
</p>
</>
}
>
<FormControl_Shadcn_>
<Listbox
disabled={disablePoolModeSelection}
value={field.value}
className="w-full"
onChange={(value) => field.onChange(value)}
>
<p>Transaction mode</p>
<p className="text-xs text-foreground-lighter">
{TRANSACTION_MODE_DESCRIPTION}
</p>
</Listbox.Option>
<Listbox.Option key="session" label="Session" value="session">
<p>Session mode</p>
<p className="text-xs text-foreground-lighter">
{SESSION_MODE_DESCRIPTION}
</p>
</Listbox.Option>
</Listbox>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Listbox.Option
key="transaction"
label="Transaction"
value="transaction"
>
<p>Transaction mode</p>
<p className="text-xs text-foreground-lighter">
{TRANSACTION_MODE_DESCRIPTION}
</p>
</Listbox.Option>
<Listbox.Option key="session" label="Session" value="session">
<p>Session mode</p>
<p className="text-xs text-foreground-lighter">
{SESSION_MODE_DESCRIPTION}
</p>
</Listbox.Option>
</Listbox>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
)}
<FormField_Shadcn_
control={form.control}
@@ -14,7 +14,6 @@ import { useDatabasePoliciesQuery } from 'data/database-policies/database-polici
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import {
Entity,
isTableLike,
@@ -23,8 +22,10 @@ import {
isView as isTableLikeView,
} from 'data/table-editor/table-editor-types'
import { useTableUpdateMutation } from 'data/tables/table-update-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import {
Button,
@@ -40,7 +41,6 @@ import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { RoleImpersonationPopover } from '../RoleImpersonationSelector'
import ViewEntityAutofixSecurityModal from './ViewEntityAutofixSecurityModal'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
export interface GridHeaderActionsProps {
table: Entity
@@ -53,9 +53,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => {
const org = useSelectedOrganization()
// need project lints to get security status for views
const { data: lints = [] } = useProjectLintsQuery({
projectRef: project?.ref,
})
const { data: lints = [] } = useProjectLintsQuery({ projectRef: project?.ref })
const isTable = isTableLike(table)
const isForeignTable = isTableLikeForeignTable(table)
@@ -74,7 +72,6 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => {
},
})
const [open, setOpen] = useState(false)
const [showEnableRealtime, setShowEnableRealtime] = useState(false)
const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false)
const [isAutofixViewSecurityModalOpen, setIsAutofixViewSecurityModalOpen] = useState(false)
@@ -260,7 +257,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => {
)}
</>
) : (
<Popover_Shadcn_ open={open} onOpenChange={() => setOpen(!open)} modal={false}>
<Popover_Shadcn_ modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button type="warning" icon={<Lock strokeWidth={1.5} />}>
RLS disabled
@@ -295,7 +292,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => {
)
) : null}
{isView && viewHasLints && (
<Popover_Shadcn_ open={open} onOpenChange={() => setOpen(!open)} modal={false}>
<Popover_Shadcn_ modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button type="warning" icon={<Unlock strokeWidth={1.5} />}>
Security Definer view
@@ -340,7 +337,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => {
</Popover_Shadcn_>
)}
{isMaterializedView && materializedViewHasLints && (
<Popover_Shadcn_ open={open} onOpenChange={() => setOpen(!open)} modal={false}>
<Popover_Shadcn_ modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button type="warning" icon={<Unlock strokeWidth={1.5} />}>
Security Definer view
@@ -377,7 +374,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => {
</Popover_Shadcn_>
)}
{isForeignTable && table.schema === 'public' && (
<Popover_Shadcn_ open={open} onOpenChange={() => setOpen(!open)} modal={false}>
<Popover_Shadcn_ modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button type="warning" icon={<Unlock strokeWidth={1.5} />}>
Foreign table is accessible via your project's APIs
@@ -1,3 +1,4 @@
import { NullValue } from 'components/grid/components/common/NullValue'
import { COLUMN_MIN_WIDTH } from 'components/grid/constants'
import type { SupaRow, SupaTable } from 'components/grid/types'
import {
@@ -34,16 +35,24 @@ const columnRender = (name: string, isPrimaryKey = false) => {
)
}
// TODO: move this formatter out to a common component
const formatter = ({ column, format, row }: { column: string; format: string; row: SupaRow }) => {
const formattedValue =
format === 'bytea'
? convertByteaToHex(row[column])
: typeof row[column] === 'object'
? JSON.stringify(row[column])
: row[column]
: row[column] === null
? null
: typeof row[column] === 'object'
? JSON.stringify(row[column])
: row[column]
return (
<div className="group sb-grid-select-cell__formatter overflow-hidden">
<span className="text-sm truncate">{formattedValue}</span>
{formattedValue === null ? (
<NullValue />
) : (
<span className="text-sm truncate">{formattedValue}</span>
)}
</div>
)
}
@@ -266,5 +266,10 @@ export const isValueTruncated = (value: string | null | undefined) => {
}
export const convertByteaToHex = (value: { type: 'Buffer'; data: number[] }) => {
// [Alaister] this is just a safeguard to catch sneaky null values
if (!value) {
return value
}
return `\\x${Buffer.from(value.data).toString('hex')}`
}
@@ -1,12 +1,14 @@
import { useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useViewDefinitionQuery } from 'data/database/view-definition-query'
import { lintKeys } from 'data/lint/keys'
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { Entity } from 'data/table-editor/table-editor-types'
import { toast } from 'sonner'
import { Entity, isViewLike } from 'data/table-editor/table-editor-types'
import { ScrollArea, SimpleCodeBlock } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { lintKeys } from '../../../data/lint/keys'
interface ViewEntityAutofixSecurityModalProps {
table: Entity
@@ -21,11 +23,16 @@ export default function ViewEntityAutofixSecurityModal({
}: ViewEntityAutofixSecurityModalProps) {
const { project } = useProjectContext()
const queryClient = useQueryClient()
const viewDefinition = useViewDefinitionQuery({
id: table?.id,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { isSuccess, isLoading, data } = useViewDefinitionQuery(
{
id: table?.id,
projectRef: project?.ref,
connectionString: project?.connectionString,
},
{
enabled: isAutofixViewSecurityModalOpen && isViewLike(table),
}
)
const { mutate: execute } = useExecuteSqlMutation({
onSuccess: async () => {
@@ -49,6 +56,10 @@ export default function ViewEntityAutofixSecurityModal({
})
}
if (!isViewLike(table)) {
return null
}
return (
<ConfirmationModal
visible={isAutofixViewSecurityModalOpen}
@@ -56,9 +67,7 @@ export default function ViewEntityAutofixSecurityModal({
title="Confirm autofixing view security"
confirmLabel="Confirm"
onCancel={() => setIsAutofixViewSecurityModalOpen(false)}
onConfirm={() => {
handleConfirm()
}}
onConfirm={() => handleConfirm()}
>
<p className="text-sm text-foreground-light">
Setting <code>security_invoker=on</code> ensures the View runs with the permissions of the
@@ -66,22 +75,24 @@ export default function ViewEntityAutofixSecurityModal({
</p>
<div className="flex items-center gap-8 mt-8">
<div className=" border rounded-md w-1/2">
<div className="p-4 bg-200 font-mono text-sm font-semibold">Existing query</div>
<div className="p-4 pb-0 bg-200 font-mono text-sm font-semibold">Existing query</div>
<ScrollArea className="h-[225px] px-4 py-2">
{viewDefinition.data && (
{isLoading && <GenericSkeletonLoader />}
{isSuccess && (
<SimpleCodeBlock>
{`create view ${table.schema}.${table.name} as\n ${viewDefinition.data}`}
{`create view ${table.schema}.${table.name} as\n ${data}`}
</SimpleCodeBlock>
)}
</ScrollArea>
</div>
<div className=" border rounded-md w-1/2">
<div className="p-4 bg-200 font-mono text-sm font-semibold">Updated query</div>
<div className="p-4 pb-0 bg-200 font-mono text-sm font-semibold">Updated query</div>
<ScrollArea className="h-[225px] px-4 py-2">
{viewDefinition.data && (
{isLoading && <GenericSkeletonLoader />}
{isSuccess && (
<SimpleCodeBlock>
{`create view ${table.schema}.${table.name} with (security_invoker = on) as\n ${viewDefinition.data}`}
{`create view ${table.schema}.${table.name} with (security_invoker = on) as\n ${data}`}
</SimpleCodeBlock>
)}
</ScrollArea>
@@ -0,0 +1,39 @@
import { ReactNode } from 'react'
import { useParams } from 'common'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import AuthLayout from './AuthLayout'
interface AuthEmailsLayoutProps {
children: ReactNode
}
export const AuthEmailsLayout = ({ children }: AuthEmailsLayoutProps) => {
const { ref } = useParams()
const navItems = [
{
label: 'Templates',
href: `/project/${ref}/auth/templates`,
},
{
label: 'SMTP Settings',
href: `/project/${ref}/auth/smtp`,
},
]
return (
<DefaultLayout>
<AuthLayout>
<PageLayout
title="Emails"
subtitle="Configure what emails your users receive and how they are sent"
navigationItems={navItems}
>
{children}
</PageLayout>
</AuthLayout>
</DefaultLayout>
)
}
@@ -14,6 +14,7 @@ export interface RealtimeLayoutProps {
const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutProps>) => {
const project = useSelectedProject()
const enableRealtimeSettings = useFlag('enableRealtimeSettings')
const router = useRouter()
const page = router.pathname.split('/')[4]
@@ -22,7 +23,12 @@ const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutPro
<ProjectLayout
title={title}
product="Realtime"
productMenu={<ProductMenu page={page} menu={generateRealtimeMenu(project!)} />}
productMenu={
<ProductMenu
page={page}
menu={generateRealtimeMenu(project!, { enableRealtimeSettings })}
/>
}
>
{children}
</ProjectLayout>
@@ -1,8 +1,13 @@
import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
import type { Project } from 'data/projects/project-detail-query'
import { IS_PLATFORM } from 'lib/constants'
export const generateRealtimeMenu = (project: Project): ProductMenuGroup[] => {
export const generateRealtimeMenu = (
project: Project,
flags?: { enableRealtimeSettings: boolean }
): ProductMenuGroup[] => {
const ref = project?.ref ?? 'default'
const { enableRealtimeSettings } = flags || {}
return [
{
@@ -25,6 +30,16 @@ export const generateRealtimeMenu = (project: Project): ProductMenuGroup[] => {
url: `/project/${ref}/realtime/policies`,
items: [],
},
...(IS_PLATFORM && enableRealtimeSettings
? [
{
name: 'Settings',
key: 'settings',
url: `/project/${ref}/realtime/settings`,
items: [],
},
]
: []),
],
},
]
@@ -11,6 +11,7 @@ interface Props {
disabled?: boolean
}
/** @deprecated Use Card instead, refer to BasicAuthSettingsForm.tsx for reference */
const FormPanel = ({ children, header, footer }: Props) => (
<FormPanelContainer>
{header && <FormPanelHeader>{header}</FormPanelHeader>}
@@ -66,4 +67,4 @@ const FormPanelFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement
FormPanelFooter.displayName = FormPanelFooter.displayName
export { FormPanel, FormPanelContainer, FormPanelContent, FormPanelHeader, FormPanelFooter }
export { FormPanel, FormPanelContainer, FormPanelContent, FormPanelFooter, FormPanelHeader }
@@ -12,14 +12,17 @@ type SupavisorConfigurationUpdateVariables = {
export async function updateSupavisorConfiguration({
ref,
pool_mode,
default_pool_size,
}: SupavisorConfigurationUpdateVariables) {
if (!ref) return console.error('Project ref is required')
const { data, error } = await patch('/platform/projects/{ref}/config/supavisor', {
params: { path: { ref } },
body: { default_pool_size, pool_mode },
// [Joshen] Manually setting pool mode to transaction here as there's a bug on API end
// whereby updating pool_mode on pgbouncer affects the pool_mode that gets returned on
// GET config/supavisor. Which means if I'm on PgBouncer Session, and I swap to Supavisor
// then Supavisor's pool_mode will be "session" (when it's meant to persist as transaction permanently)
body: { default_pool_size, pool_mode: 'transaction' },
})
if (error) handleError(error)
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more