mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 22:29:11 +03:00
Merge branch 'master' into fix/sign-in-wrapper-not-expected-in-client
This commit is contained in:
219 files changed
+62285
-92065
No files matched your search
+2
-2
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,5 @@
|
||||
import Layout from '~/layouts/guides'
|
||||
|
||||
export default async function SecurityLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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'):
|
||||
|
||||
@@ -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'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
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
- [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>
|
||||

|
||||
</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>
|
||||

|
||||
</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>
|
||||

|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
|
||||
<StepHikeCompact.Step step={3} >
|
||||
|
||||
<StepHikeCompact.Details title="Create a new Grafana Dashboard">
|
||||
Create a new Dashboard
|
||||
</StepHikeCompact.Details>
|
||||
<StepHikeCompact.Code>
|
||||

|
||||
</StepHikeCompact.Code>
|
||||
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
<StepHikeCompact.Step step={4} >
|
||||
<StepHikeCompact.Details title="Search for the Supabase template" />
|
||||
|
||||
<StepHikeCompact.Code>
|
||||

|
||||
</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>
|
||||

|
||||
</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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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',
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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 }
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+15597
-39624
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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+341
-170
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-2
@@ -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">
|
||||
|
||||
+179
-129
@@ -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>
|
||||
)
|
||||
}
|
||||
+275
-123
@@ -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
|
||||
|
||||
+91
-90
@@ -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
|
||||
|
||||
+13
-4
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+5
@@ -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')}`
|
||||
}
|
||||
+28
-17
@@ -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
Reference in new issue
Block a user