diff --git a/.github/workflows/ai-tests.yml b/.github/workflows/ai-tests.yml index 90f94476155..64314d44fe7 100644 --- a/.github/workflows/ai-tests.yml +++ b/.github/workflows/ai-tests.yml @@ -36,7 +36,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/autofix_linters.yml b/.github/workflows/autofix_linters.yml index 650d5776a82..548b97912d5 100644 --- a/.github/workflows/autofix_linters.yml +++ b/.github/workflows/autofix_linters.yml @@ -26,7 +26,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - uses: actions/setup-node@v4 diff --git a/.github/workflows/docs-last-changed.yml b/.github/workflows/docs-last-changed.yml index d8d88c53af5..94967a403f2 100644 --- a/.github/workflows/docs-last-changed.yml +++ b/.github/workflows/docs-last-changed.yml @@ -29,7 +29,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node diff --git a/.github/workflows/docs-mgmt-api-update.yml b/.github/workflows/docs-mgmt-api-update.yml index 47508dc49c2..f395be7acad 100644 --- a/.github/workflows/docs-mgmt-api-update.yml +++ b/.github/workflows/docs-mgmt-api-update.yml @@ -20,7 +20,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/docs-tests-smoke.yml b/.github/workflows/docs-tests-smoke.yml index 930406127c1..c5a38916a46 100644 --- a/.github/workflows/docs-tests-smoke.yml +++ b/.github/workflows/docs-tests-smoke.yml @@ -25,7 +25,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml index e1de7081e50..f9a36f3cf20 100644 --- a/.github/workflows/docs-tests.yml +++ b/.github/workflows/docs-tests.yml @@ -26,7 +26,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/local-studio-tests.yml b/.github/workflows/local-studio-tests.yml index 9c64715b1e0..24ccc70cce5 100644 --- a/.github/workflows/local-studio-tests.yml +++ b/.github/workflows/local-studio-tests.yml @@ -28,7 +28,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml index 0886ee2337a..9381cd0e98e 100644 --- a/.github/workflows/prettier.yml +++ b/.github/workflows/prettier.yml @@ -21,7 +21,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node uses: actions/setup-node@v4 @@ -47,7 +46,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node uses: actions/setup-node@v4 @@ -73,7 +71,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node uses: actions/setup-node@v4 diff --git a/.github/workflows/search.yml b/.github/workflows/search.yml index 70148e80480..60385047ea4 100644 --- a/.github/workflows/search.yml +++ b/.github/workflows/search.yml @@ -41,7 +41,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Setup node diff --git a/.github/workflows/studio-tests.yml b/.github/workflows/studio-tests.yml index 7390691e03d..0f41b9e49cf 100644 --- a/.github/workflows/studio-tests.yml +++ b/.github/workflows/studio-tests.yml @@ -34,7 +34,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 42e5e2c0cb5..76e474371a1 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -22,7 +22,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/ui-patterns-tests.yml b/.github/workflows/ui-patterns-tests.yml index 2209729286c..1f5b93696d8 100644 --- a/.github/workflows/ui-patterns-tests.yml +++ b/.github/workflows/ui-patterns-tests.yml @@ -24,7 +24,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index ba4861cfb9b..80b75b974cd 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -24,7 +24,6 @@ jobs: - uses: pnpm/action-setup@v4 name: Install pnpm with: - version: 9.10.0 run_install: false - name: Use Node.js diff --git a/.npmrc b/.npmrc index 4fd021952d5..a4047535344 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,2 @@ -engine-strict=true \ No newline at end of file +engine-strict=true +update-notifier=false diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 8d55cc7b07a..9ecdd2dd9cc 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1919,6 +1919,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "multi-select-combobox-creatable": { + name: "multi-select-combobox-creatable", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-combobox-creatable")), + source: "", + files: ["registry/default/example/multi-select-combobox-creatable.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "multi-select-deletable-badge": { name: "multi-select-deletable-badge", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index 471a9c89158..e96ec0b2e5c 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -349,6 +349,11 @@ export const docsConfig: DocsConfig = { href: '/docs/components/slider', items: [], }, + { + title: 'Sidebar', + href: '/docs/components/sidebar', + items: [], + }, { title: 'Sonner', href: '/docs/components/sonner', diff --git a/apps/design-system/content/docs/components/sidebar.mdx b/apps/design-system/content/docs/components/sidebar.mdx new file mode 100644 index 00000000000..f8706d0f4ae --- /dev/null +++ b/apps/design-system/content/docs/components/sidebar.mdx @@ -0,0 +1,1372 @@ +--- +title: Sidebar +description: A composable, themeable and customizable sidebar component. +component: true +--- + +
+ +
+ A sidebar that collapses to icons. +
+
+ +Sidebars are one of the most complex components to build. They are central +to any application and often contain a lot of moving parts. + +I don't like building sidebars. So I built 30+ of them. All kinds of +configurations. Then I extracted the core components into `sidebar.tsx`. + +We now have a solid foundation to build on top of. Composable. Themeable. +Customizable. + +[Browse the Blocks Library](/blocks). + +## Installation + + + + + CLI + Manual + + + + + +Run the following command to install `sidebar.tsx` + +```bash +npx shadcn@latest add sidebar +``` + +Add the following colors to your CSS file + +The command above should install the colors for you. If not, copy and paste the following in your CSS file. + +We'll go over the colors later in the [theming section](/docs/components/sidebar#theming). + +```css title="app/globals.css" +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + + + + + + + + + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + +Add the following colors to your CSS file + +We'll go over the colors later in the [theming section](/docs/components/sidebar#theming). + +```css title="app/globals.css" +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + +Add the Sidebar tailwind config into `tailwind.config.js` + +Add the following object in the `theme.extend.colors` section of your `tailwind.config.js` file +this config enable sidebar related style utilities like bg-sidebar + +```javascript title="tailwind.config.js" +// ... +sidebar: { + DEFAULT: 'hsl(var(--sidebar-background))', + foreground: 'hsl(var(--sidebar-foreground))', + primary: 'hsl(var(--sidebar-primary))', + 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', + accent: 'hsl(var(--sidebar-accent))', + 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', + border: 'hsl(var(--sidebar-border))', + ring: 'hsl(var(--sidebar-ring))', +}, +// ... +``` + + + + + + + +## Structure + +A `Sidebar` component is composed of the following parts: + +- `SidebarProvider` - Handles collapsible state. +- `Sidebar` - The sidebar container. +- `SidebarHeader` and `SidebarFooter` - Sticky at the top and bottom of the sidebar. +- `SidebarContent` - Scrollable content. +- `SidebarGroup` - Section within the `SidebarContent`. +- `SidebarTrigger` - Trigger for the `Sidebar`. + +Sidebar Structure +Sidebar Structure + +## Usage + +```tsx showLineNumbers title="app/layout.tsx" +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + +
+ + {children} +
+
+ ) +} +``` + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarHeader, +} from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + + + + + + ) +} +``` + +## Your First Sidebar + +Let's start with the most basic sidebar. A collapsible sidebar with a menu. + + + +Add a `SidebarProvider` and `SidebarTrigger` at the root of your application. + +```tsx showLineNumbers title="app/layout.tsx" +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + +
+ + {children} +
+
+ ) +} +``` + +Create a new sidebar component at `components/app-sidebar.tsx`. + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { Sidebar, SidebarContent } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + ) +} +``` + +Now, let's add a `SidebarMenu` to the sidebar. + +We'll use the `SidebarMenu` component in a `SidebarGroup`. + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { Calendar, Home, Inbox, Search, Settings } from 'lucide-react' + +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar' + +// Menu items. +const items = [ + { + title: 'Home', + url: '#', + icon: Home, + }, + { + title: 'Inbox', + url: '#', + icon: Inbox, + }, + { + title: 'Calendar', + url: '#', + icon: Calendar, + }, + { + title: 'Search', + url: '#', + icon: Search, + }, + { + title: 'Settings', + url: '#', + icon: Settings, + }, +] + +export function AppSidebar() { + return ( + + + + Application + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + ) +} +``` + +You've created your first sidebar. + +
+ +
Your first sidebar.
+
+ +
+ +## Components + +The components in `sidebar.tsx` are built to be composable i.e you build your sidebar by putting the provided components together. They also compose well with other shadcn/ui components such as `DropdownMenu`, `Collapsible` or `Dialog` etc. + +**If you need to change the code in `sidebar.tsx`, you are encouraged to do so. The code is yours. Use `sidebar.tsx` as a starting point and build your own.** + +In the next sections, we'll go over each component and how to use them. + +## SidebarProvider + +The `SidebarProvider` component is used to provide the sidebar context to the `Sidebar` component. You should always wrap your application in a `SidebarProvider` component. + +### Props + +| Name | Type | Description | +| -------------- | ------------------------- | -------------------------------------------- | +| `defaultOpen` | `boolean` | Default open state of the sidebar. | +| `open` | `boolean` | Open state of the sidebar (controlled). | +| `onOpenChange` | `(open: boolean) => void` | Sets open state of the sidebar (controlled). | + +### Width + +If you have a single sidebar in your application, you can use the `SIDEBAR_WIDTH` and `SIDEBAR_WIDTH_MOBILE` variables in `sidebar.tsx` to set the width of the sidebar. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_WIDTH = '16rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +``` + +For multiple sidebars in your application, you can use the `style` prop to set the width of the sidebar. + +To set the width of the sidebar, you can use the `--sidebar-width` and `--sidebar-width-mobile` CSS variables in the `style` prop. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" + + + +``` + +This will handle the width of the sidebar but also the layout spacing. + +### Keyboard Shortcut + +The `SIDEBAR_KEYBOARD_SHORTCUT` variable is used to set the keyboard shortcut used to open and close the sidebar. + +To trigger the sidebar, you use the `cmd+b` keyboard shortcut on Mac and `ctrl+b` on Windows. + +You can change the keyboard shortcut by updating the `SIDEBAR_KEYBOARD_SHORTCUT` variable. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' +``` + +### Persisted State + +The `SidebarProvider` supports persisting the sidebar state across page reloads and server-side rendering. It uses cookies to store the current state of the sidebar. When the sidebar state changes, a default cookie named `sidebar_state` is set with the current open/closed state. This cookie is then read on subsequent page loads to restore the sidebar state. + +To persist sidebar state in Next.js, set up your `SidebarProvider` in `app/layout.tsx` like this: + +```tsx showLineNumbers title="app/layout.tsx" +import { cookies } from 'next/headers' + +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export async function Layout({ children }: { children: React.ReactNode }) { + const cookieStore = await cookies() + const defaultOpen = cookieStore.get('sidebar_state')?.value === 'true' + + return ( + + +
+ + {children} +
+
+ ) +} +``` + +You can change the name of the cookie by updating the `SIDEBAR_COOKIE_NAME` variable in `sidebar.tsx`. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_COOKIE_NAME = 'sidebar_state' +``` + +## Sidebar + +The main `Sidebar` component used to render a collapsible sidebar. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +### Props + +| Property | Type | Description | +| ------------- | --------------------------------- | --------------------------------- | +| `side` | `left` or `right` | The side of the sidebar. | +| `variant` | `sidebar`, `floating`, or `inset` | The variant of the sidebar. | +| `collapsible` | `offcanvas`, `icon`, or `none` | Collapsible state of the sidebar. | + +### side + +Use the `side` prop to change the side of the sidebar. + +Available options are `left` and `right`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +### variant + +Use the `variant` prop to change the variant of the sidebar. + +Available options are `sidebar`, `floating` and `inset`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + + + **Note:** If you use the `inset` variant, remember to wrap your main content in a `SidebarInset` + component. + + +```tsx showLineNumbers + + + +
{children}
+
+
+``` + +### collapsible + +Use the `collapsible` prop to make the sidebar collapsible. + +Available options are `offcanvas`, `icon` and `none`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +| Prop | Description | +| ----------- | ------------------------------------------------------------ | +| `offcanvas` | A collapsible sidebar that slides in from the left or right. | +| `icon` | A sidebar that collapses to icons. | +| `none` | A non-collapsible sidebar. | + +## useSidebar + +The `useSidebar` hook is used to control the sidebar. + +```tsx showLineNumbers +import { useSidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + const { state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar } = useSidebar() +} +``` + +| Property | Type | Description | +| --------------- | ------------------------- | --------------------------------------------- | +| `state` | `expanded` or `collapsed` | The current state of the sidebar. | +| `open` | `boolean` | Whether the sidebar is open. | +| `setOpen` | `(open: boolean) => void` | Sets the open state of the sidebar. | +| `openMobile` | `boolean` | Whether the sidebar is open on mobile. | +| `setOpenMobile` | `(open: boolean) => void` | Sets the open state of the sidebar on mobile. | +| `isMobile` | `boolean` | Whether the sidebar is on mobile. | +| `toggleSidebar` | `() => void` | Toggles the sidebar. Desktop and mobile. | + +## SidebarHeader + +Use the `SidebarHeader` component to add a sticky header to the sidebar. + +The following example adds a `` to the `SidebarHeader`. + +
+ +
+ A sidebar header with a dropdown menu. +
+
+ +```tsx showLineNumbers title="components/app-sidebar.tsx" + + + + + + + + Select Workspace + + + + + + Acme Inc + + + Acme Corp. + + + + + + + +``` + +## SidebarFooter + +Use the `SidebarFooter` component to add a sticky footer to the sidebar. + +The following example adds a `` to the `SidebarFooter`. + +
+ +
+ A sidebar footer with a dropdown menu. +
+
+ +```tsx showLineNumbers title="components/app-sidebar.tsx" +export function AppSidebar() { + return ( + + + + + + + + + + + Username + + + + + + Account + + + Billing + + + Sign out + + + + + + + + + ) +} +``` + +## SidebarContent + +The `SidebarContent` component is used to wrap the content of the sidebar. This is where you add your `SidebarGroup` components. It is scrollable. + +```tsx showLineNumbers +import { Sidebar, SidebarContent } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + + + + ) +} +``` + +## SidebarGroup + +Use the `SidebarGroup` component to create a section within the sidebar. + +A `SidebarGroup` has a `SidebarGroupLabel`, a `SidebarGroupContent` and an optional `SidebarGroupAction`. + +
+ +
A sidebar group.
+
+ +```tsx showLineNumbers +import { Sidebar, SidebarContent, SidebarGroup } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + Application + + Add Project + + + + + + ) +} +``` + +## Collapsible SidebarGroup + +To make a `SidebarGroup` collapsible, wrap it in a `Collapsible`. + +
+ +
+ A collapsible sidebar group. +
+
+ +```tsx showLineNumbers +export function AppSidebar() { + return ( + + + + + Help + + + + + + + + + ) +} +``` + + + **Note:** We wrap the `CollapsibleTrigger` in a `SidebarGroupLabel` to render a button. + + +## SidebarGroupAction + +Use the `SidebarGroupAction` component to add an action button to the `SidebarGroup`. + +```tsx showLineNumbers {5-7} +export function AppSidebar() { + return ( + + Projects + + Add Project + + + + ) +} +``` + +
+ +
+ A sidebar group with an action button. +
+
+ +## SidebarMenu + +The `SidebarMenu` component is used for building a menu within a `SidebarGroup`. + +A `SidebarMenu` component is composed of `SidebarMenuItem`, `SidebarMenuButton`, `` and `` components. + +Sidebar Menu +Sidebar Menu + +Here's an example of a `SidebarMenu` component rendering a list of projects. + +
+ +
+ A sidebar menu with a list of projects. +
+
+ +```tsx showLineNumbers + + + + Projects + + + {projects.map((project) => ( + + + + + {project.name} + + + + ))} + + + + + +``` + +## SidebarMenuButton + +The `SidebarMenuButton` component is used to render a menu button within a `SidebarMenuItem`. + +### Link or Anchor + +By default, the `SidebarMenuButton` renders a button but you can use the `asChild` prop to render a different component such as a `Link` or an `a` tag. + +```tsx showLineNumbers + + Home + +``` + +### Icon and Label + +You can render an icon and a truncated label inside the button. Remember to wrap the label in a ``. + +```tsx showLineNumbers + + + + Home + + +``` + +### isActive + +Use the `isActive` prop to mark a menu item as active. + +```tsx showLineNumbers + + Home + +``` + +## SidebarMenuAction + +The `SidebarMenuAction` component is used to render a menu action within a `SidebarMenuItem`. + +This button works independently of the `SidebarMenuButton` i.e you can have the `` as a clickable link and the `` as a button. + +```tsx showLineNumbers + + + + + Home + + + + Add Project + + +``` + +### DropdownMenu + +Here's an example of a `SidebarMenuAction` component rendering a `DropdownMenu`. + +
+ +
+ A sidebar menu action with a dropdown menu. +
+
+ +```tsx showLineNumbers + + + + + Home + + + + + + + + + + + Edit Project + + + Delete Project + + + + +``` + +## SidebarMenuSub + +The `SidebarMenuSub` component is used to render a submenu within a `SidebarMenu`. + +Use `` and `` to render a submenu item. + +
+ +
+ A sidebar menu with a submenu. +
+
+ +```tsx showLineNumbers + + + + + + + + + + + +``` + +## Collapsible SidebarMenu + +To make a `SidebarMenu` component collapsible, wrap it and the `SidebarMenuSub` components in a `Collapsible`. + +
+ +
A collapsible sidebar menu.
+
+ +```tsx showLineNumbers + + + + + + + + + + + + + + +``` + +## SidebarMenuBadge + +The `SidebarMenuBadge` component is used to render a badge within a `SidebarMenuItem`. + +
+ +
+ A sidebar menu with a badge. +
+
+ +```tsx showLineNumbers + + + 24 + +``` + +## SidebarMenuSkeleton + +The `SidebarMenuSkeleton` component is used to render a skeleton for a `SidebarMenu`. You can use this to show a loading state when using React Server Components, SWR or react-query. + +```tsx showLineNumbers +function NavProjectsSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) +} +``` + +## SidebarSeparator + +The `SidebarSeparator` component is used to render a separator within a `Sidebar`. + +```tsx showLineNumbers + + + + + + + + + +``` + +## SidebarTrigger + +Use the `SidebarTrigger` component to render a button that toggles the sidebar. + +The `SidebarTrigger` component must be used within a `SidebarProvider`. + +```tsx showLineNumbers + + +
+ +
+
+``` + +### Custom Trigger + +To create a custom trigger, you can use the `useSidebar` hook. + +```tsx showLineNumbers +import { useSidebar } from '@/components/ui/sidebar' + +export function CustomTrigger() { + const { toggleSidebar } = useSidebar() + + return +} +``` + +## SidebarRail + +The `SidebarRail` component is used to render a rail within a `Sidebar`. This rail can be used to toggle the sidebar. + +```tsx showLineNumbers + + + + + + + + +``` + +## Data Fetching + +### React Server Components + +Here's an example of a `SidebarMenu` component rendering a list of projects using React Server Components. + +
+ +
+ A sidebar menu using React Server Components. +
+
+ +```tsx showLineNumbers {6} title="Skeleton to show loading state." +function NavProjectsSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) +} +``` + +```tsx showLineNumbers {2} title="Server component fetching data." +async function NavProjects() { + const projects = await fetchProjects() + + return ( + + {projects.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +```tsx showLineNumbers {8-10} title="Usage with React Suspense." +function AppSidebar() { + return ( + + + + Projects + + }> + + + + + + + ) +} +``` + +### SWR and React Query + +You can use the same approach with [SWR](https://swr.vercel.app/) or [react-query](https://tanstack.com/query/latest/docs/framework/react/overview). + +```tsx showLineNumbers title="SWR" +function NavProjects() { + const { data, isLoading } = useSWR("/api/projects", fetcher) + + if (isLoading) { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) + } + + if (!data) { + return ... + } + + return ( + + {data.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +```tsx showLineNumbers title="React Query" +function NavProjects() { + const { data, isLoading } = useQuery() + + if (isLoading) { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) + } + + if (!data) { + return ... + } + + return ( + + {data.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +## Controlled Sidebar + +Use the `open` and `onOpenChange` props to control the sidebar. + +
+ +
A controlled sidebar.
+
+ +```tsx showLineNumbers +export function AppSidebar() { + const [open, setOpen] = React.useState(false) + + return ( + + + + ) +} +``` + +## Theming + +We use the following CSS variables to theme the sidebar. + +```css +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 0 0% 98%; + --sidebar-primary-foreground: 240 5.9% 10%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + +**We intentionally use different variables for the sidebar and the rest of the application** to make it easy to have a sidebar that is styled differently from the rest of the application. Think a sidebar with a darker shade from the main application. + +## Styling + +Here are some tips for styling the sidebar based on different states. + +- **Styling an element based on the sidebar collapsible state.** The following will hide the `SidebarGroup` when the sidebar is in `icon` mode. + +```tsx + + + + + +``` + +- **Styling a menu action based on the menu button active state.** The following will force the menu action to be visible when the menu button is active. + +```tsx + + + + +``` + +You can find more tips on using states for styling in this [Twitter thread](https://x.com/shadcn/status/1842329158879420864). + +## Changelog + +### 2024-10-30 Cookie handling in setOpen + +- [#5593](https://github.com/shadcn-ui/ui/pull/5593) - Improved setOpen callback logic in ``. + +Update the `setOpen` callback in `` as follows: + +```tsx showLineNumbers +const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] +) +``` + +### 2024-10-21 Fixed `text-sidebar-foreground` + +- [#5491](https://github.com/shadcn-ui/ui/pull/5491) - Moved `text-sidebar-foreground` from `` to `` component. + +### 2024-10-20 Typo in `useSidebar` hook. + +Fixed typo in `useSidebar` hook. + +```diff showLineNumbers title="sidebar.tsx" +- throw new Error("useSidebar must be used within a Sidebar.") ++ throw new Error("useSidebar must be used within a SidebarProvider.") +``` diff --git a/apps/design-system/content/docs/fragments/multi-select.mdx b/apps/design-system/content/docs/fragments/multi-select.mdx index 8425bb4a988..1c3d40faf46 100644 --- a/apps/design-system/content/docs/fragments/multi-select.mdx +++ b/apps/design-system/content/docs/fragments/multi-select.mdx @@ -47,6 +47,13 @@ Use `MultiSelectorInput`to add a search input. +### Badge Combo Box with creatable selector + +creatable: `boolean` +`creatable` prop on the `MultiSelectorList` component can be used to add a new option if no items match + + + ### Badge Limit badgeLimit: `number` | `"wrap"`. diff --git a/apps/design-system/registry/default/example/multi-select-combobox-creatable.tsx b/apps/design-system/registry/default/example/multi-select-combobox-creatable.tsx new file mode 100644 index 00000000000..5b8ddf96c71 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-combobox-creatable.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState([]) + + return ( + + + + + + Mango + Date + Apple + Elderberrie + Fig + Grape + Banana + Kiwi + Strawberry + Cherry + + + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 58d7c1dd508..a9f4de3e7c8 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1091,6 +1091,11 @@ export const examples: Registry = [ type: 'components:example', files: ['example/multi-select-combobox.tsx'], }, + { + name: 'multi-select-combobox-creatable', + type: 'components:example', + files: ['example/multi-select-combobox-creatable.tsx'], + }, { name: 'multi-select-deletable-badge', type: 'components:example', diff --git a/apps/design-system/tailwind.config.js b/apps/design-system/tailwind.config.js index 5a512287d82..5be9a0d5a1d 100644 --- a/apps/design-system/tailwind.config.js +++ b/apps/design-system/tailwind.config.js @@ -8,7 +8,8 @@ module.exports = config({ // purge styles from grid library // './../../packages/ui/src/**/*.{tsx,ts,js}', - './../../packages/ui-patterns/!(node_modules)/**/*.{tsx,ts,js}', + './../../packages/ui-patterns/**/*.{tsx,ts,js}', + '!./../../packages/ui-patterns/node_modules/**/*', // ignore node_modules rule ], theme: { extend: { diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index c3fccd5395e..09c57917ca9 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1747,6 +1747,10 @@ export const ai = { url: '/guides/ai/vector-indexes', items: vectorIndexItems, }, + { + name: 'Automatic embeddings', + url: '/guides/ai/automatic-embeddings', + }, { name: 'Engineering for scale', url: '/guides/ai/engineering-for-scale', @@ -1920,6 +1924,22 @@ export const local_development: NavMenuConstant = { }, ], }, + { + name: 'Testing', + url: undefined, + items: [ + { name: 'Getting started', url: '/guides/local-development/testing/overview' }, + { + name: 'pgTAP advanced guide', + url: '/guides/local-development/testing/pgtap-extended', + }, + { name: 'Database testing', url: '/guides/database/testing' }, + { + name: 'RLS policies testing', + url: '/guides/database/extensions/pgtap#testing-rls-policies', + }, + ], + }, ], } diff --git a/apps/docs/content/guides/ai/automatic-embeddings.mdx b/apps/docs/content/guides/ai/automatic-embeddings.mdx new file mode 100644 index 00000000000..48f1f7389c6 --- /dev/null +++ b/apps/docs/content/guides/ai/automatic-embeddings.mdx @@ -0,0 +1,817 @@ +--- +id: 'ai-automatic-embeddings' +title: 'Automatic embeddings' +description: 'Automate embedding generation and updates in Postgres' +sidebar_label: 'Automatic embeddings' +--- + +Vector embeddings enable powerful [semantic search](/docs/guides/ai/semantic-search) capabilities in Postgres, but managing them alongside your content has traditionally been complex. This guide demonstrates how to automate embedding generation and updates using Supabase [Edge Functions](/docs/guides/functions), [pgmq](/docs/guides/database/extensions/pgmq), [pg_net](/docs/guides/database/extensions/pg_net), and [pg_cron](/docs/guides/cron). + +## Understanding the challenge + +When implementing semantic search with pgvector, developers typically need to: + +1. Generate embeddings via an external API (like OpenAI) +2. Store these embeddings alongside the content +3. Keep embeddings in sync when content changes +4. Handle failures and retries in the embedding generation process + +While Postgres [full-text search](/docs/guides/database/full-text-search) can handle this internally through synchronous calls to `to_tsvector` and [triggers](https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS), semantic search requires asynchronous API calls to a provider like OpenAI to generate vector embeddings. This guide demonstrates how to use triggers, queues, and Supabase Edge Functions to bridge this gap. + +## Understanding the architecture + +We'll leverage the following Postgres and Supabase features to create the automated embedding system: + +1. [pgvector](/docs/guides/database/extensions/pgvector): Stores and queries vector embeddings +1. [pgmq](/docs/guides/queues): Queues embedding generation requests for processing and retries +1. [pg_net](/docs/guides/database/extensions/pg_net): Handles asynchronous HTTP requests to Edge Functions directly from Postgres +1. [pg_cron](/docs/guides/cron): Automatically processes and retries embedding generations +1. [Triggers](/docs/guides/database/postgres/triggers): Detects content changes and enqueues embedding generation requests +1. [Edge Functions](/docs/guides/functions): Generates embeddings via an API like OpenAI (customizable) + +We'll design the system to: + +1. Be generic, so that it can be used with any table and content. This allows you to configure embeddings in multiple places, each with the ability to customize the input used for embedding generation. These will all use the same queue infrastructure and Edge Function to generate the embeddings. + +1. Handle failures gracefully, by retrying failed jobs and providing detailed information about the status of each job. + +## Implementation + +We'll start by setting up the infrastructure needed to queue and process embedding generation requests. Then we'll create an example table with triggers to enqueue these embedding requests whenever content is inserted or updated. + +### Step 1: Enable extensions + +First, let's enable the required extensions: + + + + +```sql +-- For vector operations +create extension if not exists vector +with + schema extensions; + +-- For queueing and processing jobs +-- (pgmq will create its own schema) +create extension if not exists pgmq; + +-- For async HTTP requests +create extension if not exists pg_net +with + schema extensions; + +-- For scheduled processing and retries +-- (pg_cron will create its own schema) +create extension if not exists pg_cron; + +-- For clearing embeddings during updates +create extension if not exists hstore +with + schema extensions; +``` + +Even though the SQL code is `create extension`, this is the equivalent of "enabling the extension". +To disable an extension, call `drop extension`. + + + + +1. Go to the [Extensions](/dashboard/project/_/database/extensions) page in the Dashboard. +2. Search for and enable the following extensions: + - `vector` + - `pgmq` + - `pg_net` + - `pg_cron` + - `hstore` + + + + +### Step 2: Create utility functions + +Before we set up our embedding logic, we need to create some utility functions: + +```sql +-- Schema for utility functions +create schema util; + +-- Utility function to get the Supabase project URL (required for Edge Functions) +create function util.project_url() +returns text +language plpgsql +security definer +as $$ +declare + secret_value text; +begin + -- Retrieve the project URL from Vault + select decrypted_secret into secret_value from vault.decrypted_secrets where name = 'project_url'; + return secret_value; +end; +$$; + +-- Generic function to invoke any Edge Function +create or replace function util.invoke_edge_function( + name text, + body jsonb, + timeout_milliseconds int = 5 * 60 * 1000 -- default 5 minute timeout +) +returns void +language plpgsql +as $$ +declare + headers_raw text; + auth_header text; +begin + -- If we're in a PostgREST session, reuse the request headers for authorization + headers_raw := current_setting('request.headers', true); + + -- Only try to parse if headers are present + auth_header := case + when headers_raw is not null then + (headers_raw::json->>'authorization') + else + null + end; + + -- Perform async HTTP request to the edge function + perform net.http_post( + url => util.project_url() || '/functions/v1/' || name, + headers => jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', auth_header + ), + body => body, + timeout_milliseconds => timeout_milliseconds + ); +end; +$$; + +-- Generic trigger function to clear a column on update +create or replace function util.clear_column() +returns trigger +language plpgsql as $$ +declare + clear_column text := TG_ARGV[0]; +begin + NEW := NEW #= hstore(clear_column, NULL); + return NEW; +end; +$$; +``` + +Here we create: + +- A schema `util` to store utility functions. +- A function to retrieve the Supabase project URL from [Vault](/docs/guides/database/vault). We'll add this secret next. +- A generic function to invoke any Edge Function with a given name and request body. +- A generic trigger function to clear a column on update. This function accepts the column name as an argument and sets it to `NULL` in the `NEW` record. We'll explain how to use this function later. + +Every project has a unique API URL that is required to invoke Edge Functions. Let's go ahead and add the project URL secret to Vault depending on your environment. + +When working with a local Supabase stack, add the following to your `supabase/seed.sql` file: + +```sql +select + vault.create_secret('http://api.supabase.internal:8000', 'project_url'); +``` + +When deploying to the cloud platform, open the [SQL editor](/dashboard/project/_/sql/new) and run the following, replacing `` with your [project's API URL](/dashboard/project/_/settings/api): + +```sql +select + vault.create_secret('', 'project_url'); +``` + +### Step 3: Create queue and triggers + +Our goal is to automatically generate embeddings whenever content is inserted or updated within a table. We can use triggers and queues to achieve this. Our approach is to automatically queue embedding jobs whenever records are inserted or updated in a table, then process them asynchronously using a cron job. If a job fails, it will remain in the queue and be retried in the next scheduled task. + +First we create a `pgmq` queue for processing embedding requests: + +```sql +-- Queue for processing embedding jobs +select pgmq.create('embedding_jobs'); +``` + +Next we create a trigger function to queue embedding jobs. We'll use this function to handle both insert and update events: + +```sql +-- Generic trigger function to queue embedding jobs +create or replace function util.queue_embeddings() +returns trigger +language plpgsql +as $$ +declare + content_function text = TG_ARGV[0]; + embedding_column text = TG_ARGV[1]; +begin + perform pgmq.send( + queue_name => 'embedding_jobs', + msg => jsonb_build_object( + 'id', NEW.id, + 'schema', TG_TABLE_SCHEMA, + 'table', TG_TABLE_NAME, + 'contentFunction', content_function, + 'embeddingColumn', embedding_column + ) + ); + return NEW; +end; +$$; +``` + +Our `util.queue_embeddings` trigger function is generic and can be used with any table and content function. It accepts two arguments: + +1. `content_function`: The name of a function that returns the text content to be embedded. The function should accept a single row as input and return text (see the `embedding_input` example). + + This allows you to customize the text input passed to the embedding model - for example, you could concatenate multiple columns together like `title` and `content` and use the result as input. + +2. `embedding_column`: The name of the destination column where the embedding will be stored. + +Note that the `util.queue_embeddings` trigger function requires a `for each row` clause to work correctly. See [Usage](#usage) for an example of how to use this trigger function with your table. + +Next we'll create a function to process the embedding jobs. This function will read jobs from the queue, group them into batches, and invoke the Edge Function to generate embeddings. We'll use `pg_cron` to schedule this function to run every 10 seconds. + +```sql +-- Function to process embedding jobs from the queue +create or replace function util.process_embeddings( + batch_size int = 10, + max_requests int = 10, + timeout_milliseconds int = 5 * 60 * 1000 -- default 5 minute timeout +) +returns void +language plpgsql +as $$ +declare + job_batches jsonb[]; + batch jsonb; +begin + with + -- First get jobs and assign batch numbers + numbered_jobs as ( + select + message || jsonb_build_object('jobId', msg_id) as job_info, + (row_number() over (order by 1) - 1) / batch_size as batch_num + from pgmq.read( + queue_name => 'embedding_jobs', + vt => timeout_milliseconds / 1000, + qty => max_requests * batch_size + ) + ), + -- Then group jobs into batches + batched_jobs as ( + select + jsonb_agg(job_info) as batch_array, + batch_num + from numbered_jobs + group by batch_num + ) + -- Finally aggregate all batches into array + select array_agg(batch_array) + from batched_jobs + into job_batches; + + -- Invoke the embed edge function for each batch + foreach batch in array job_batches loop + perform util.invoke_edge_function( + name => 'embed', + body => batch, + timeout_milliseconds => timeout_milliseconds + ); + end loop; +end; +$$; + +-- Schedule the embedding processing +select + cron.schedule( + 'process-embeddings', + '10 seconds', + $$ + select util.process_embeddings(); + $$ + ); +``` + +Let's discuss some common questions about this approach: + +#### Why not generate all embeddings in a single Edge Function request? + +While this is possible, it can lead to long processing times and potential timeouts. Batching allows us to process multiple embeddings concurrently and handle failures more effectively. + +#### Why not one request per row? + +This approach can lead to API rate limiting and performance issues. Batching provides a balance between efficiency and reliability. + +#### Why queue requests instead of processing them immediately? + +Queuing allows us to handle failures gracefully, retry requests, and manage concurrency more effectively. Specifically we are using `pgmq`'s visibility timeouts to ensure that failed requests are retried. + +#### How do visibility timeouts work? + +Every time we read a message from the queue, we set a visibility timeout which tells `pgmq` to hide the message from other readers for a certain period. If the Edge Function fails to process the message within this period, the message becomes visible again and will be retried by the next scheduled task. + +#### How do we handle retries? + +We use `pg_cron` to schedule a task that reads messages from the queue and processes them. If the Edge Function fails to process a message, it becomes visible again after a timeout and can be retried by the next scheduled task. + +#### Is 10 seconds a good interval for processing? + +This interval is a good starting point, but you may need to adjust it based on your workload and the time it takes to generate embeddings. You can adjust the `batch_size`, `max_requests`, and `timeout_milliseconds` parameters to optimize performance. + +### Step 4: Create the Edge Function + +Finally we'll create the Edge Function to generate embeddings. We'll use OpenAI's API in this example, but you can replace it with any other embedding generation service. + +Use the Supabase CLI to create a new Edge Function: + +```bash +supabase functions new embed +``` + +This will create a new directory `supabase/functions/embed` with an `index.ts` file. Replace the contents of this file with the following: + +_supabase/functions/embed/index.ts_: + +```typescript +// Setup type definitions for built-in Supabase Runtime APIs +import 'jsr:@supabase/functions-js/edge-runtime.d.ts' + +// We'll use the OpenAI API to generate embeddings +import OpenAI from 'jsr:@openai/openai' + +import { z } from 'npm:zod' + +// We'll make a direct Postgres connection to update the document +import postgres from 'https://deno.land/x/postgresjs@v3.4.5/mod.js' + +// Initialize OpenAI client +const openai = new OpenAI({ + // We'll need to manually set the `OPENAI_API_KEY` environment variable + apiKey: Deno.env.get('OPENAI_API_KEY'), +}) + +// Initialize Postgres client +const sql = postgres( + // `SUPABASE_DB_URL` is a built-in environment variable + Deno.env.get('SUPABASE_DB_URL')! +) + +const jobSchema = z.object({ + jobId: z.number(), + id: z.number(), + schema: z.string(), + table: z.string(), + contentFunction: z.string(), + embeddingColumn: z.string(), +}) + +const failedJobSchema = jobSchema.extend({ + error: z.string(), +}) + +type Job = z.infer +type FailedJob = z.infer + +type Row = { + id: string + content: unknown +} + +const QUEUE_NAME = 'embedding_jobs' + +// Listen for HTTP requests +Deno.serve(async (req) => { + if (req.method !== 'POST') { + return new Response('expected POST request', { status: 405 }) + } + + if (req.headers.get('content-type') !== 'application/json') { + return new Response('expected json body', { status: 400 }) + } + + // Use Zod to parse and validate the request body + const parseResult = z.array(jobSchema).safeParse(await req.json()) + + if (parseResult.error) { + return new Response(`invalid request body: ${parseResult.error.message}`, { + status: 400, + }) + } + + const pendingJobs = parseResult.data + + // Track jobs that completed successfully + const completedJobs: Job[] = [] + + // Track jobs that failed due to an error + const failedJobs: FailedJob[] = [] + + async function processJobs() { + let currentJob: Job | undefined + + while ((currentJob = pendingJobs.shift()) !== undefined) { + try { + await processJob(currentJob) + completedJobs.push(currentJob) + } catch (error) { + failedJobs.push({ + ...currentJob, + error: error instanceof Error ? error.message : JSON.stringify(error), + }) + } + } + } + + try { + // Process jobs while listening for worker termination + await Promise.race([processJobs(), catchUnload()]) + } catch (error) { + // If the worker is terminating (e.g. wall clock limit reached), + // add pending jobs to fail list with termination reason + failedJobs.push( + ...pendingJobs.map((job) => ({ + ...job, + error: error instanceof Error ? error.message : JSON.stringify(error), + })) + ) + } + + // Log completed and failed jobs for traceability + console.log('finished processing jobs:', { + completedJobs: completedJobs.length, + failedJobs: failedJobs.length, + }) + + return new Response( + JSON.stringify({ + completedJobs, + failedJobs, + }), + { + // 200 OK response + status: 200, + + // Custom headers to report job status + headers: { + 'Content-Type': 'application/json', + 'X-Completed-Jobs': completedJobs.length.toString(), + 'X-Failed-Jobs': failedJobs.length.toString(), + }, + } + ) +}) + +/** + * Generates an embedding for the given text. + */ +async function generateEmbedding(text: string) { + const response = await openai.embeddings.create({ + model: 'text-embedding-3-small', + input: text, + }) + const [data] = response.data + + if (!data) { + throw new Error('failed to generate embedding') + } + + return data.embedding +} + +/** + * Processes an embedding job. + */ +async function processJob(job: Job) { + const { jobId, id, schema, table, contentFunction, embeddingColumn } = job + + // Fetch content for the schema/table/row combination + const [row]: [Row] = await sql` + select + id, + ${sql(contentFunction)}(t) as content + from + ${sql(schema)}.${sql(table)} t + where + id = ${id} + ` + + if (!row) { + throw new Error(`row not found: ${schema}.${table}/${id}`) + } + + if (typeof row.content !== 'string') { + throw new Error(`invalid content - expected string: ${schema}.${table}/${id}`) + } + + const embedding = await generateEmbedding(row.content) + + await sql` + update + ${sql(schema)}.${sql(table)} + set + ${sql(embeddingColumn)} = ${JSON.stringify(embedding)} + where + id = ${id} + ` + + await sql` + select pgmq.delete(${QUEUE_NAME}, ${jobId}::bigint) + ` +} + +/** + * Returns a promise that rejects if the worker is terminating. + */ +function catchUnload() { + return new Promise((reject) => { + addEventListener('beforeunload', (ev: any) => { + reject(new Error(ev.detail?.reason)) + }) + }) +} +``` + +The Edge Function listens for incoming HTTP requests from `pg_net` and processes each embedding job. It is a generic worker that can handle embedding jobs for any table and column. It uses OpenAI's API to generate embeddings and updates the corresponding row in the database. It also deletes the job from the queue once it has been processed. + +The function is designed to process multiple jobs independently. If one job fails, it will not affect the processing of other jobs. The function returns a `200 OK` response with a list of completed and failed jobs. We can use this information to diagnose failed jobs. See [Troubleshooting](#troubleshooting) for more details. + +You will need to set the `OPENAI_API_KEY` environment variable to authenticate with OpenAI. When running the Edge Function locally, you can add it to a `.env` file: + +_.env_: + +``` +OPENAI_API_KEY=your-api-key +``` + +When you're ready to deploy the Edge Function, set can set the environment variable using the Supabase CLI: + +```shell +supabase secrets set --env-file .env +``` + +or + +```shell +supabase secrets set OPENAI_API_KEY= +``` + +Alternatively, you can replace the `generateEmbedding` function with your own embedding generation logic. + +See [Deploy to Production](/docs/guides/functions/deploy) for more information on how to deploy the Edge Function. + +## Usage + +Now that the infrastructure is in place, let's go through an example of how to use this system to automatically generate embeddings for a table of documents. You can use this approach with multiple tables and customize the input for each embedding generation as needed. + +### 1. Create table to store documents with embeddings + +We'll set up a new `documents` table that will store our content and embeddings: + +```sql +-- Table to store documents with embeddings +create table documents ( + id integer primary key generated always as identity, + title text not null, + content text not null, + embedding halfvec, + created_at timestamp with time zone default now() +); + +-- Index for vector search over document embeddings +create index on documents using hnsw (embedding halfvec_cosine_ops); +``` + +Our `documents` table stores the title and content of each document along with its vector embedding. We use a `halfvec` column to store the embeddings, which is a `pgvector` data type that stores float values in half precision (16 bits) to save space. + +We use an [HNSW index](/docs/guides/ai/vector-indexes/hnsw-indexes) on the vector column. Note that we are choosing `halfvec_cosine_ops` as the index method, which means our future queries will need to use cosine distance (`<=>`) to find similar embeddings. Also note that HNSW indexes support a maximum of 4000 dimensions for `halfvec` vectors, so keep this in mind when choosing an embedding model. If your model generates embeddings with more than 4000 dimensions, you will need to reduce the dimensionality before indexing them. See [Matryoshka embeddings](/blog/matryoshka-embeddings) for a potential solution to shortening dimensions. + +Also note that the table must have a primary key column named `id` for our triggers to work correctly with the `util.queue_embeddings` function and for our Edge Function to update the correct row. + +### 2. Create triggers to enqueue embedding jobs + +Now we'll set up the triggers to enqueue embedding jobs whenever content is inserted or updated: + +```sql +-- Customize the input for embedding generation +-- e.g. Concatenate title and content with a markdown header +create or replace function embedding_input(doc documents) +returns text +language plpgsql +immutable +as $$ +begin + return '# ' || doc.title || E'\n\n' || doc.content; +end; +$$; + +-- Trigger for insert events +create trigger embed_documents_on_insert + after insert + on documents + for each row + execute function util.queue_embeddings('embedding_input', 'embedding'); + +-- Trigger for update events +create trigger embed_documents_on_update + after update of title, content -- must match the columns in embedding_input() + on documents + for each row + execute function util.queue_embeddings('embedding_input', 'embedding'); +``` + +We create 2 triggers: + +1. `embed_documents_on_insert`: Enqueues embedding jobs whenever new rows are inserted into the `documents` table. + +2. `embed_documents_on_update`: Enqueues embedding jobs whenever the `title` or `content` columns are updated in the `documents` table. + +Both of these triggers use the same `util.queue_embeddings` function that will queue the embedding jobs for processing. They accept 2 arguments: + +1. `embedding_input`: The name of the function that generates the input for embedding generation. This function allows you to customize the text input passed to the embedding model (e.g. concatenating the title and content). The function should accept a single row as input and return text. + +2. `embedding`: The name of the destination column where the embedding will be stored. + +Note that the update trigger only fires when the `title` or `content` columns are updated. This is to avoid unnecessary updates to the embedding column when other columns are updated. Make sure that these columns match the columns used in the `embedding_input` function. + +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + +#### (Optional) Clearing embeddings on update + +Note that our trigger will enqueue new embedding jobs when content is updated, but it will not clear any existing embeddings. This means that an embedding can be temporarily out of sync with the content until the new embedding is generated and updated. + +If it is more important to have _accurate_ embeddings than _any_ embedding, you can add another trigger to clear the existing embedding until the new one is generated: + +```sql +-- Trigger to clear the embedding column on update +create trigger clear_document_embedding_on_update + before update of title, content -- must match the columns in embedding_input() + on documents + for each row + execute function util.clear_column('embedding'); +``` + +`util.clear_column` is a generic trigger function we created earlier that can be used to clear any column in a table. + +- It accepts the column name as an argument. This column must be nullable. +- It requires a `before` trigger with a `for each row` clause. +- It requires the `hstore` extension we created earlier. + +This example will clear the `embedding` column whenever the `title` or `content` columns are updated (note the `of title, content` clause). This ensures that the embedding is always in sync with the title and content, but it will result in temporary gaps in search results until the new embedding is generated. + +We intentionally use a `before` trigger because it allows us to modify the record before it's written to disk, avoiding an extra `update` statement that would be needed with an `after` trigger. + +### 3. Insert and update documents + +Let's insert a new document and update its content to see the embedding generation in action: + +```sql +-- Insert a new document +insert into documents (title, content) +values + ('Understanding Vector Databases', 'Vector databases are specialized...'); + +-- Immediately check the embedding column +select id, embedding +from documents +where title = 'Understanding Vector Databases'; +``` + +You should observe that the `embedding` column is initially `null` after inserting the document. This is because the embedding generation is asynchronous and will be processed by the Edge Function in the next scheduled task. + +Wait up to 10 seconds for the next task to run, then check the `embedding` column again: + +```sql +select id, embedding +from documents +where title = 'Understanding Vector Databases'; +``` + +You should see the generated embedding for the document. + +Next let's update the content of the document: + +```sql +-- Update the content of the document +update documents +set content = 'Vector databases allow you to query...' +where title = 'Understanding Vector Databases'; + +-- Immediately check the embedding column +select id, embedding +from documents +where title = 'Understanding Vector Databases'; +``` + +You should observe that the `embedding` column is reset to `null` after updating the content. This is because of the trigger we added to clear existing embeddings whenever the content is updated. The embedding will be regenerated by the Edge Function in the next scheduled task. + +Wait up to 10 seconds for the next task to run, then check the `embedding` column again: + +```sql +select id, embedding +from documents +where title = 'Understanding Vector Databases'; +``` + +You should see the updated embedding for the document. + +Finally we'll update the title of the document: + +```sql +-- Update the title of the document +update documents +set title = 'Understanding Vector Databases with Supabase' +where title = 'Understanding Vector Databases'; +``` + +You should observe that the `embedding` column is once again reset to `null` after updating the title. This is because the trigger we added to clear existing embeddings fires when either the `content` or `title` columns are updated. The embedding will be regenerated by the Edge Function in the next scheduled task. + +Wait up to 10 seconds for the next task to run, then check the `embedding` column again: + +```sql +select id, embedding +from documents +where title = 'Understanding Vector Databases with Supabase'; +``` + +You should see the updated embedding for the document. + +## Troubleshooting + +The `embed` Edge Function processes a batch of embedding jobs and returns a `200 OK` response with a list of completed and failed jobs in the body. For example: + +```json +{ + "completedJobs": [ + { + "jobId": "1", + "id": "1", + "schema": "public", + "table": "documents", + "contentFunction": "embedding_input", + "embeddingColumn": "embedding" + } + ], + "failedJobs": [ + { + "jobId": "2", + "id": "2", + "schema": "public", + "table": "documents", + "contentFunction": "embedding_input", + "embeddingColumn": "embedding", + "error": "error connecting to openai api" + } + ] +} +``` + +It also returns the number of completed and failed jobs in the response headers. For example: + +``` +X-Completed-Jobs: 1 +X-Failed-Jobs: 1 +``` + +You can also use the `X-Deno-Execution-Id` header to trace the execution of the Edge Function within the [dashboard](/dashboard/project/_/functions) logs. + +Each failed job includes an `error` field with a description of the failure. Reasons for a job failing could include: + +- An error generating the embedding via external API +- An error connecting to the database +- The edge function being terminated (e.g. due to a wall clock limit) +- Any other error thrown during processing + +`pg_net` stores HTTP responses in the `net._http_response` table, which can be queried to diagnose issues with the embedding generation process. + +```sql +select + * +from + net._http_response +where + (headers->>'X-Failed-Jobs')::int > 0; +``` + +## Conclusion + +Automating embedding generation and updates in Postgres allow you to build powerful semantic search capabilities without the complexity of managing embeddings manually. + +By combining Postgres features like triggers, queues, and other extensions with Supabase Edge Functions, we can create a robust system that handles embedding generation asynchronously and retries failed jobs automatically. + +This system can be customized to work with any content and embedding generation service, providing a flexible and scalable solution for semantic search in Postgres. + +## See also + +- [What are embeddings?](/docs/guides/ai/concepts) +- [Semantic search](/docs/guides/ai/semantic-search) +- [Vector indexes](/docs/guides/ai/vector-indexes) +- [Supabase Edge Functions](/docs/guides/functions) diff --git a/apps/docs/content/guides/api/automatic-retries-in-supabase-js.mdx b/apps/docs/content/guides/api/automatic-retries-in-supabase-js.mdx new file mode 100644 index 00000000000..e9cfbe80eaf --- /dev/null +++ b/apps/docs/content/guides/api/automatic-retries-in-supabase-js.mdx @@ -0,0 +1,118 @@ +--- +id: automatic-retries-in-supabase-js +title: 'How to do automatic retries with `supabase-js`' +subtitle: 'Learn how to add automatic retries to your Supabase API requests using `fetch-retry`.' +--- + + + +You should only enable retries if your requests fail with network errors (e.g. 520 status from Cloudflare). A high number of retries have the potential to exhaust the Data API connection pool, which could result in lower throughput and failed requests. + + + +The `fetch-retry` package allows you to add retry logic to `fetch` requests, making it a useful tool for enhancing the resilience of API calls in your Supabase applications. Here's a step-by-step guide on how to integrate `fetch-retry` with the `supabase-js` library. + +## 1. Install dependencies + +To get started, ensure you have both `supabase-js` and `fetch-retry` installed in your project: + +```bash +npm install @supabase/supabase-js fetch-retry +``` + +## 2. Wrap the fetch function + +The `fetch-retry` package works by wrapping the native `fetch` function. You can create a custom fetch instance with retry logic and pass it to the `supabase-js` client. + +```javascript +import { createClient } from '@supabase/supabase-js' +import fetchRetry from 'fetch-retry' + +// Wrap the global fetch with fetch-retry +const fetchWithRetry = fetchRetry(fetch) + +// Create a Supabase client instance with the custom fetch +const supabase = createClient('https://your-supabase-url.supabase.co', 'your-anon-key', { + global: { + fetch: fetchWithRetry, + }, +}) +``` + +## 3. Configure retry options + +You can configure `fetch-retry` options to control retry behavior, such as the number of retries, retry delay, and which errors should trigger a retry. + +Here is an example with custom retry options: + +```javascript +const fetchWithRetry = fetchRetry(fetch, { + retries: 3, // Number of retry attempts + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), // Exponential backoff + retryOn: [520], // Retry only on Cloudflare errors +}) +``` + +In this example, the `retryDelay` function implements an exponential backoff strategy, and retries are triggered only for specific HTTP status codes. + +## 4. Using the Supabase client + +With `fetch-retry` integrated, you can use the Supabase client as usual. The retry logic will automatically apply to all network requests made by `supabase-js`. + +```javascript +async function fetchData() { + const { data, error } = await supabase.from('your_table').select('*') + + if (error) { + console.error('Error fetching data:', error) + } else { + console.log('Fetched data:', data) + } +} + +fetchData() +``` + +## 5. Fine-Tuning retries for specific requests + +If you need different retry logic for certain requests, you can use the `retryOn` with a custom function to inspect the URL or response and decide whether to retry the request. + +```javascript +const fetchWithRetry = fetchRetry(fetch, { + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), + retryOn: (attempt, error, response) => { + const shouldRetry + = (attempt: number, error: Error | null, response: Response | null) => + attempt < 3 + && response + && response.status == 520 // Cloudflare errors + && response.url.includes('rpc/your_stored_procedure') + + if (shouldRetry(attempt, error, response)) { + console.log(`Retrying request... Attempt #${attempt}`, response) + return true + } + + return false + } +}) + +async function yourStoredProcedure() { + const { data, error } = await supabase + .rpc('your_stored_procedure', { param1: 'value1' }); + + if (error) { + console.log('Error executing RPC:', error); + } else { + console.log('Response:', data); + } +} + +yourStoredProcedure(); +``` + +By using `retryOn` with a custom function, you can define specific conditions for retrying requests. In this example, the retry logic is applied only to requests targeting a specific stored procedure. + +## Conclusion + +Integrating `fetch-retry` with `supabase-js` is a straightforward way to add robustness to your Supabase API requests. By handling transient errors and implementing retry strategies, you can improve the reliability of your application while maintaining a seamless user experience. diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 6db4230d2ac..9bb001cb9af 100644 --- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -72,7 +72,7 @@ hideToc: true Start the development server, go to http://localhost:3000 in a browser, and you should see the contents of `app/page.tsx`. - To sign up a new user, navigate to http://localhost:3000/login, and click `Sign Up Now`. + To sign up a new user, navigate to http://localhost:3000/sign-up, and click `Sign up`. *NOTE: .env.example must be renamed to .env.local before this route becomes available* diff --git a/apps/docs/content/guides/database/connecting-to-postgres.mdx b/apps/docs/content/guides/database/connecting-to-postgres.mdx index f0e14491f93..7f88e92a951 100644 --- a/apps/docs/content/guides/database/connecting-to-postgres.mdx +++ b/apps/docs/content/guides/database/connecting-to-postgres.mdx @@ -10,7 +10,8 @@ How you connect to your database depends on where you're connecting from: - For frontend applications, use the [Data API](#data-apis-and-client-libraries) - For Postgres clients, use a connection string - - For persistent clients (for example, long-running servers or database GUIs) use the [direct connection string](#direct-connection) if your environment supports IPv6, and [Supavisor session mode](#supavisor-session-mode) if not + - For single sessions (for example, database GUIs) or Postgres native commands (for example, using client applications like [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) or specifying connections for [replication](/docs/guides/database/postgres/setup-replication-external)) use the [direct connection string](#direct-connection) if your environment supports IPv6 + - For persistent clients, and support for both IPv4 and IPv6, use [Supavisor session mode](#supavisor-session-mode) - For temporary clients (for example, serverless or edge functions) use [Supavisor transaction mode](#supavisor-transaction-mode) ## Quickstarts diff --git a/apps/docs/content/guides/database/prisma.mdx b/apps/docs/content/guides/database/prisma.mdx index 737ce4f6e01..dbb1606d7d3 100644 --- a/apps/docs/content/guides/database/prisma.mdx +++ b/apps/docs/content/guides/database/prisma.mdx @@ -98,11 +98,11 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```bash yarn init -y - yarn install prisma typescript ts-node @types/node --save-dev + yarn add prisma typescript ts-node @types/node --save-dev - yarn dlx tsc --init + npx tsc --init - yarn dlx prisma init + npx prisma init ``` @@ -241,7 +241,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```bash - yarn dlx prisma migrate dev --name first_prisma_migration + npx prisma migrate dev --name first_prisma_migration ``` @@ -318,7 +318,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```bash - yarn dlx prisma db pull + npx prisma db pull ``` Create a migration file @@ -328,7 +328,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t Synchronize the migrations ```bash - yarn dlx prisma migrate diff \ + npx prisma migrate diff \ --from-empty \ --to-schema-datamodel prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql @@ -338,7 +338,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```bash - yarn dlx prisma migrate resolve --applied 0_init_supabase + npx prisma migrate resolve --applied 0_init_supabase ``` @@ -403,8 +403,8 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```sh - yarn install @prisma/client - yarn dlx prisma generate + yarn add @prisma/client + npx prisma generate ``` diff --git a/apps/docs/content/guides/local-development/testing/overview.mdx b/apps/docs/content/guides/local-development/testing/overview.mdx new file mode 100644 index 00000000000..eca62644d1c --- /dev/null +++ b/apps/docs/content/guides/local-development/testing/overview.mdx @@ -0,0 +1,328 @@ +--- +id: 'local-development-testing-overview' +title: 'Testing Overview' +description: 'Learn how to develop and test database schemas, tables, functions, and Row Level Security (RLS) policies.' +--- + +Testing is a critical part of database development, especially when working with features like Row Level Security (RLS) policies. This guide provides a comprehensive approach to testing your Supabase database. + +## Testing approaches + +### Database unit testing with pgTAP + +[pgTAP](https://pgtap.org) is a unit testing framework for PostgreSQL that allows testing: + +- Database structure: tables, columns, constraints +- Row Level Security (RLS) policies +- Functions and procedures +- Data integrity + +This example demonstrates setting up and testing RLS policies for a simple todo application: + +1. Create a test table with RLS enabled: + + ```sql + -- Create a simple todos table + create table todos ( + id uuid primary key default gen_random_uuid(), + task text not null, + user_id uuid references auth.users not null, + completed boolean default false + ); + + -- Enable RLS + alter table todos enable row level security; + + -- Create a policy + create policy "Users can only access their own todos" + on todos for all -- this policy applies to all operations + to authenticated + using ((select auth.uid()) = user_id); + ``` + +2. Set up your testing environment: + + ```bash + # Create a new test for our policies using supabase cli + supabase test new todos_rls.test + ``` + +3. Write your RLS tests: + + ```sql + begin; + -- install tests utilities + -- install pgtap extension for testing + create extension if not exists pgtap with schema extensions; + -- Start declare we'll have 6 test cases in our test suite + select plan(4); + + -- Setup our testing data + -- Set up auth.users entries + insert into auth.users (id, email) values + ('123e4567-e89b-12d3-a456-426614174000', 'user1@test.com'), + ('987fcdeb-51a2-43d7-9012-345678901234', 'user2@test.com'); + + -- Create test todos + insert into public.todos (task, user_id) values + ('User 1 Task 1', '123e4567-e89b-12d3-a456-426614174000'), + ('User 1 Task 2', '123e4567-e89b-12d3-a456-426614174000'), + ('User 2 Task 1', '987fcdeb-51a2-43d7-9012-345678901234'); + + -- as User 1 + set local role authenticated; + set local request.jwt.claim.sub = '123e4567-e89b-12d3-a456-426614174000'; + + -- Test 1: User 1 should only see their own todos + select results_eq( + 'select count(*) from todos', + ARRAY[2::bigint], + 'User 1 should only see their 2 todos' + ); + + -- Test 2: User 1 can create their own todo + select lives_ok( + $$insert into todos (task, user_id) values ('New Task', '123e4567-e89b-12d3-a456-426614174000'::uuid)$$, + 'User 1 can create their own todo' + ); + + -- as User 2 + set local request.jwt.claim.sub = '987fcdeb-51a2-43d7-9012-345678901234'; + + -- Test 3: User 2 should only see their own todos + select results_eq( + 'select count(*) from todos', + ARRAY[1::bigint], + 'User 2 should only see their 1 todo' + ); + + -- Test 4: User 2 cannot modify User 1's todo + SELECT results_ne( + $$ update todos set task = 'Hacked!' where user_id = '123e4567-e89b-12d3-a456-426614174000'::uuid returning 1 $$, + $$ values(1) $$, + 'User 2 cannot modify User 1 todos' + ); + + select * from finish(); + rollback; + ``` + +4. Run the tests: + + ```bash + supabase test db + psql:todos_rls.test.sql:4: NOTICE: extension "pgtap" already exists, skipping + ./todos_rls.test.sql .. ok + All tests successful. + Files=1, Tests=6, 0 wallclock secs ( 0.01 usr + 0.00 sys = 0.01 CPU) + Result: PASS + ``` + +### Application-Level testing + +Testing through application code provides end-to-end verification. Unlike database-level testing with pgTAP, application-level tests cannot use transactions for isolation. + + + +Application-level tests should not rely on a clean database state, as resetting the database before each test can be slow and makes tests difficult to parallelize. +Instead, design your tests to be independent by using unique user IDs for each test case. + + + +Here's an example using TypeScript that mirrors the pgTAP tests above: + +```typescript +import { createClient } from '@supabase/supabase-js' +import { beforeAll, describe, expect, it } from 'vitest' +import crypto from 'crypto' + +describe('Todos RLS', () => { + // Generate unique IDs for this test suite to avoid conflicts with other tests + const USER_1_ID = crypto.randomUUID() + const USER_2_ID = crypto.randomUUID() + + const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!) + + beforeAll(async () => { + // Setup test data specific to this test suite + const adminSupabase = createClient(process.env.SUPABASE_URL!, process.env.SERVICE_ROLE_KEY!) + + // Create test users with unique IDs + await adminSupabase.auth.admin.createUser({ + id: USER_1_ID, + email: `user1-${USER_1_ID}@test.com`, + password: 'password123', + // We want the user to be usable right away without email confirmation + email_confirm: true, + }) + await adminSupabase.auth.admin.createUser({ + id: USER_2_ID, + email: `user2-${USER_2_ID}@test.com`, + password: 'password123', + email_confirm: true, + }) + + // Create initial todos + await adminSupabase.from('todos').insert([ + { task: 'User 1 Task 1', user_id: USER_1_ID }, + { task: 'User 1 Task 2', user_id: USER_1_ID }, + { task: 'User 2 Task 1', user_id: USER_2_ID }, + ]) + }) + + it('should allow User 1 to only see their own todos', async () => { + // Sign in as User 1 + await supabase.auth.signInWithPassword({ + email: `user1-${USER_1_ID}@test.com`, + password: 'password123', + }) + + const { data: todos } = await supabase.from('todos').select('*') + + expect(todos).toHaveLength(2) + todos?.forEach((todo) => { + expect(todo.user_id).toBe(USER_1_ID) + }) + }) + + it('should allow User 1 to create their own todo', async () => { + await supabase.auth.signInWithPassword({ + email: `user1-${USER_1_ID}@test.com`, + password: 'password123', + }) + + const { error } = await supabase.from('todos').insert({ task: 'New Task', user_id: USER_1_ID }) + + expect(error).toBeNull() + }) + + it('should allow User 2 to only see their own todos', async () => { + // Sign in as User 2 + await supabase.auth.signInWithPassword({ + email: `user2-${USER_2_ID}@test.com`, + password: 'password123', + }) + + const { data: todos } = await supabase.from('todos').select('*') + expect(todos).toHaveLength(1) + todos?.forEach((todo) => { + expect(todo.user_id).toBe(USER_2_ID) + }) + }) + + it('should prevent User 2 from modifying User 1 todos', async () => { + await supabase.auth.signInWithPassword({ + email: `user2-${USER_2_ID}@test.com`, + password: 'password123', + }) + + // Attempt to update the todos we shouldn't have access to + // result will be a no-op + await supabase.from('todos').update({ task: 'Hacked!' }).eq('user_id', USER_1_ID) + + // Log back in as User 1 to verify their todos weren't changed + await supabase.auth.signInWithPassword({ + email: `user1-${USER_1_ID}@test.com`, + password: 'password123', + }) + + // Fetch User 1's todos + const { data: todos } = await supabase.from('todos').select('*') + + // Verify that none of the todos were changed to "Hacked!" + expect(todos).toBeDefined() + todos?.forEach((todo) => { + expect(todo.task).not.toBe('Hacked!') + }) + }) +}) +``` + +#### Test isolation strategies + +For application-level testing, consider these approaches for test isolation: + +1. **Unique Identifiers**: Generate unique IDs for each test suite to prevent data conflicts +2. **Cleanup After Tests**: If necessary, clean up created data in an `afterAll` or `afterEach` hook +3. **Isolated Data Sets**: Use prefixes or namespaces in data to separate test cases + +### Continuous integration testing + +Set up automated database testing in your CI pipeline: + +1. Create a GitHub Actions workflow `.github/workflows/db-tests.yml`: + +```yaml +name: Database Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Setup Supabase CLI + uses: supabase/setup-cli@v1 + + - name: Start Supabase + run: supabase start + + - name: Run Tests + run: supabase test db +``` + +## Best practices + +1. **Test Data Setup** + + - Use begin and rollback to ensure test isolation + - Create realistic test data that covers edge cases + - Use different user roles and permissions in tests + +2. **RLS Policy Testing** + + - Test Create, Read, Update, Delete operations + - Test with different user roles: anonymous and authenticated + - Test edge cases and potential security bypasses + - Always test negative cases: what users should not be able to do + +3. **CI/CD Integration** + - Run tests automatically on every pull request + - Include database tests in deployment pipeline + - Keep test runs fast using transactions + +## Real-World examples + +For more complex, real-world examples of database testing, check out: + +- [Database Tests Example Repository](https://github.com/usebasejump/basejump/tree/main/supabase/tests/database) - A production-grade example of testing RLS policies +- [RLS Guide and Best Practices](https://github.com/orgs/supabase/discussions/14576) + +## Troubleshooting + +Common issues and solutions: + +1. **Test Failures Due to RLS** + + - Ensure you've set the correct role `set local role authenticated;` + - Verify JWT claims are set `set local "request.jwt.claims"` + - Check policy definitions match your test assumptions + +2. **CI Pipeline Issues** + - Verify Supabase CLI is properly installed + - Ensure database migrations are run before tests + - Check for proper test isolation using transactions + +## Additional resources + +- [pgTAP Documentation](https://pgtap.org) +- [Supabase CLI Reference](https://supabase.com/docs/reference/cli/supabase-test) +- [pgTAP Supabase reference](https://supabase.com/docs/guides/database/extensions/pgtap?queryGroups=database-method&database-method=sql#testing-rls-policies) +- [Database testing reference](https://supabase.com/docs/guides/database/testing) diff --git a/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx b/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx new file mode 100644 index 00000000000..bf3b5c38e71 --- /dev/null +++ b/apps/docs/content/guides/local-development/testing/pgtap-extended.mdx @@ -0,0 +1,665 @@ +--- +id: 'local-development-testing-pgtap-extended' +title: 'Advanced pgTAP Testing' +description: 'Learn how to leverage dbdev and test helpers for advanced database testing.' +--- + +While basic pgTAP provides excellent testing capabilities, you can enhance the testing workflow using database development tools and helper packages. This guide covers advanced testing techniques using database.dev and community-maintained test helpers. + +## Using database.dev + +[Database.dev](https://database.dev) is a package manager for PostgreSQL that allows installation and use of community-maintained packages, including testing utilities. + +### Setting up dbdev + +To use database development tools and packages, install some prerequisites: + +```sql +create extension if not exists http with schema extensions; +create extension if not exists pg_tle; +drop extension if exists "supabase-dbdev"; +select pgtle.uninstall_extension_if_exists('supabase-dbdev'); +select + pgtle.install_extension( + 'supabase-dbdev', + resp.contents ->> 'version', + 'PostgreSQL package manager', + resp.contents ->> 'sql' + ) +from http( + ( + 'GET', + 'https://api.database.dev/rest/v1/' + || 'package_versions?select=sql,version' + || '&package_name=eq.supabase-dbdev' + || '&order=version.desc' + || '&limit=1', + array[ + ('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header + ], + null, + null + ) +) x, +lateral ( + select + ((row_to_json(x) -> 'content') #>> '{}')::json -> 0 +) resp(contents); +create extension "supabase-dbdev"; +select dbdev.install('supabase-dbdev'); + +-- Drop and recreate the extension to ensure a clean installation +drop extension if exists "supabase-dbdev"; +create extension "supabase-dbdev"; +``` + +### Installing test helpers + +The Test Helpers package provides utilities that simplify testing Supabase-specific features: + +```sql +select dbdev.install('basejump-supabase_test_helpers'); +create extension if not exists "basejump-supabase_test_helpers" version '0.0.6'; +``` + +## Test helper benefits + +The test helpers package provides several advantages over writing raw pgTAP tests: + +1. **Simplified User Management** + + - Create test users with `tests.create_supabase_user()` + - Switch contexts with `tests.authenticate_as()` + - Retrieve user IDs using `tests.get_supabase_uid()` + +2. **Row Level Security (RLS) Testing Utilities** + + - Verify RLS status with `tests.rls_enabled()` + - Test policy enforcement + - Simulate different user contexts + +3. **Reduced Boilerplate** + - No need to manually insert auth.users + - Simplified JWT claim management + - Clean test setup and cleanup + +## Schema-wide Row Level Security testing + +When working with Row Level Security, it's crucial to ensure that RLS is enabled on all tables that need it. Create a simple test to verify RLS is enabled across an entire schema: + +```sql +begin; +select plan(1); + +-- Verify RLS is enabled on all tables in the public schema +select tests.rls_enabled('public'); + +select * from finish(); +rollback; +``` + +## Test file organization + +When working with multiple test files that share common setup requirements, it's beneficial to create a single "pre-test" file that handles the global environment setup. This approach reduces duplication and ensures consistent test environments. + +### Creating a pre-test hook + +Since pgTAP test files are executed in alphabetical order, create a setup file that runs first by using a naming convention like `000-setup-tests-hooks.sql`: + +```bash +supabase test new 000-setup-tests-hooks +``` + +This setup file should contain: + +1. All shared extensions and dependencies +2. Common test utilities +3. A simple always green test to verify the setup + +Here's an example setup file: + +```sql +-- install tests utilities +-- install pgtap extension for testing +create extension if not exists pgtap with schema extensions; +/* +--------------------- +---- install dbdev ---- +---------------------- +Requires: + - pg_tle: https://github.com/aws/pg_tle + - pgsql-http: https://github.com/pramsey/pgsql-http +*/ +create extension if not exists http with schema extensions; +create extension if not exists pg_tle; +drop extension if exists "supabase-dbdev"; +select pgtle.uninstall_extension_if_exists('supabase-dbdev'); +select + pgtle.install_extension( + 'supabase-dbdev', + resp.contents ->> 'version', + 'PostgreSQL package manager', + resp.contents ->> 'sql' + ) +from http( + ( + 'GET', + 'https://api.database.dev/rest/v1/' + || 'package_versions?select=sql,version' + || '&package_name=eq.supabase-dbdev' + || '&order=version.desc' + || '&limit=1', + array[ + ('apiKey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhtdXB0cHBsZnZpaWZyYndtbXR2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2ODAxMDczNzIsImV4cCI6MTk5NTY4MzM3Mn0.z2CN0mvO2No8wSi46Gw59DFGCTJrzM0AQKsu_5k134s')::http_header + ], + null, + null + ) +) x, +lateral ( + select + ((row_to_json(x) -> 'content') #>> '{}')::json -> 0 +) resp(contents); +create extension "supabase-dbdev"; +select dbdev.install('supabase-dbdev'); +drop extension if exists "supabase-dbdev"; +create extension "supabase-dbdev"; +-- Install test helpers +select dbdev.install('basejump-supabase_test_helpers'); +create extension if not exists "basejump-supabase_test_helpers" version '0.0.6'; + +-- Verify setup with a no-op test +begin; +select plan(1); +select ok(true, 'Pre-test hook completed successfully'); +select * from finish(); +rollback; +``` + +### Benefits + +This approach provides several advantages: + +- Reduces code duplication across test files +- Ensures consistent test environment setup +- Makes it easier to maintain and update shared dependencies +- Provides immediate feedback if the setup process fails + +Your subsequent test files (`001-auth-tests.sql`, `002-rls-tests.sql`) can focus solely on their specific test cases, knowing that the environment is properly configured. + +## Example: Advanced RLS testing + +Here's a complete example using test helpers to verify RLS policies putting it all together: + +```sql +begin; +-- Assuming 000-setup-tests-hooks.sql file is present to use tests helpers +select plan(4); + +-- Set up test data + +-- Create test supabase users +select tests.create_supabase_user('user1@test.com'); +select tests.create_supabase_user('user2@test.com'); + +-- Create test data +insert into public.todos (task, user_id) values + ('User 1 Task 1', tests.get_supabase_uid('user1@test.com')), + ('User 1 Task 2', tests.get_supabase_uid('user1@test.com')), + ('User 2 Task 1', tests.get_supabase_uid('user2@test.com')); + +-- Test as User 1 +select tests.authenticate_as('user1@test.com'); + +-- Test 1: User 1 should only see their own todos +select results_eq( + 'select count(*) from todos', + ARRAY[2::bigint], + 'User 1 should only see their 2 todos' +); + +-- Test 2: User 1 can create their own todo +select lives_ok( + $$insert into todos (task, user_id) values ('New Task', tests.get_supabase_uid('user1@test.com'))$$, + 'User 1 can create their own todo' +); + +-- Test as User 2 +select tests.authenticate_as('user2@test.com'); + +-- Test 3: User 2 should only see their own todos +select results_eq( + 'select count(*) from todos', + ARRAY[1::bigint], + 'User 2 should only see their 1 todo' +); + +-- Test 4: User 2 cannot modify User 1's todo +SELECT results_ne( + $$ update todos set task = 'Hacked!' where user_id = tests.get_supabase_uid('user1@test.com') returning 1 $$, + $$ values(1) $$, + 'User 2 cannot modify User 1 todos' +); + +select * from finish(); +rollback; +``` + +## Not another todo app: Testing complex organizations + +Todo apps are great for learning, but this section explores testing a more realistic scenario: a multi-tenant content publishing platform. This example demonstrates testing complex permissions, plan restrictions, and content management. + +### System overview + +This demo app implements: + +- Organizations with tiered plans (free/pro/enterprise) +- Role-based access (owner/admin/editor/viewer) +- Content management (posts/comments) +- Premium content restrictions +- Plan-based limitations + +### What makes this complex? + +1. **Layered Permissions** + + - Role hierarchies affect access rights + - Plan types influence user capabilities + - Content state (draft/published) affects permissions + +2. **Business Rules** + - Free plan post limits + - Premium content visibility + - Cross-organization security + +### Testing focus areas + +When writing tests, verify: + +- Organization member access control +- Content visibility across roles +- Plan limitation enforcement +- Cross-organization data isolation + +#### 1. App schema definitions + +The app schema tables are defined like this: + +```sql +create table public.profiles ( + id uuid references auth.users(id) primary key, + username text unique not null, + full_name text, + bio text, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table public.organizations ( + id bigint primary key generated always as identity, + name text not null, + slug text unique not null, + plan_type text not null check (plan_type in ('free', 'pro', 'enterprise')), + max_posts int not null default 5, + created_at timestamptz default now() +); + +create table public.org_members ( + org_id bigint references public.organizations(id) on delete cascade, + user_id uuid references auth.users(id) on delete cascade, + role text not null check (role in ('owner', 'admin', 'editor', 'viewer')), + created_at timestamptz default now(), + primary key (org_id, user_id) +); + +create table public.posts ( + id bigint primary key generated always as identity, + title text not null, + content text not null, + author_id uuid references public.profiles(id) not null, + org_id bigint references public.organizations(id), + status text not null check (status in ('draft', 'published', 'archived')), + is_premium boolean default false, + scheduled_for timestamptz, + category text, + view_count int default 0, + published_at timestamptz, + created_at timestamptz default now(), + updated_at timestamptz default now() +); + +create table public.comments ( + id bigint primary key generated always as identity, + post_id bigint references public.posts(id) on delete cascade, + author_id uuid references public.profiles(id), + content text not null, + is_deleted boolean default false, + created_at timestamptz default now(), + updated_at timestamptz default now() +); +``` + +#### 2. RLS policies declaration + +Now to setup the RLS policies for each tables: + +```sql +-- Create a private schema to store all security definer functions utils +-- As such functions should never be in a API exposed schema +create schema if not exists private; +-- Helper function for role checks +create or replace function private.get_user_org_role(org_id bigint, user_id uuid) +returns text +set search_path = '' +as $$ + select role from public.org_members + where org_id = $1 and user_id = $2; +-- Note the use of security definer to avoid RLS checking recursion issue +-- see: https://supabase.com/docs/guides/database/postgres/row-level-security#use-security-definer-functions +$$ language sql security definer; +-- Helper utils to check if an org is below the max post limit +create or replace function private.can_add_post(org_id bigint) +returns boolean +set search_path = '' +as $$ + select (select count(*) + from public.posts p + where p.org_id = $1) < o.max_posts + from public.organizations o + where o.id = $1 +$$ language sql security definer; + + +-- Enable RLS for all tables +alter table public.profiles enable row level security; +alter table public.organizations enable row level security; +alter table public.org_members enable row level security; +alter table public.posts enable row level security; +alter table public.comments enable row level security; + +-- Profiles policies +create policy "Public profiles are viewable by everyone" + on public.profiles for select using (true); + +create policy "Users can insert their own profile" + on public.profiles for insert with check ((select auth.uid()) = id); + +create policy "Users can update their own profile" + on public.profiles for update using ((select auth.uid()) = id) + with check ((select auth.uid()) = id); + +-- Organizations policies +create policy "Public org info visible to all" + on public.organizations for select using (true); + +create policy "Org management restricted to owners" + on public.organizations for all using ( + private.get_user_org_role(id, (select auth.uid())) = 'owner' + ); + +-- Org Members policies +create policy "Members visible to org members" + on public.org_members for select using ( + private.get_user_org_role(org_id, (select auth.uid())) is not null + ); + +create policy "Member management restricted to admins and owners" + on public.org_members for all using ( + private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin') + ); + +-- Posts policies +create policy "Complex post visibility" + on public.posts for select using ( + -- Published non-premium posts are visible to all + (status = 'published' and not is_premium) + or + -- Premium posts visible to org members only + (status = 'published' and is_premium and + private.get_user_org_role(org_id, (select auth.uid())) is not null) + or + -- All posts visible to editors and above + private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin', 'editor') + ); + +create policy "Post creation rules" + on public.posts for insert with check ( + -- Must be org member with appropriate role + private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin', 'editor') + and + -- Check org post limits for free plans + ( + (select o.plan_type != 'free' + from organizations o + where o.id = org_id) + or + (select private.can_add_post(org_id)) + ) + ); + +create policy "Post update rules" + on public.posts for update using ( + exists ( + select 1 + where + -- Editors can update non-published posts + (private.get_user_org_role(org_id, (select auth.uid())) = 'editor' and status != 'published') + or + -- Admins and owners can update any post + private.get_user_org_role(org_id, (select auth.uid())) in ('owner', 'admin') + ) + ); + +-- Comments policies +create policy "Comments on published posts are viewable by everyone" + on public.comments for select using ( + exists ( + select 1 from public.posts + where id = post_id + and status = 'published' + ) + and not is_deleted + ); + +create policy "Authenticated users can create comments" + on public.comments for insert with check ((select auth.uid()) = author_id); + +create policy "Users can update their own comments" + on public.comments for update using (author_id = (select auth.uid())); +``` + +#### 3. Test cases: + +Now everything is setup, let's write RLS test cases, note that each section could be in its own test: + +```sql +-- Assuming we already have: 000-setup-tests-hooks.sql file we can use tests helpers +begin; +-- Declare total number of tests +select plan(10); + +-- Create test users +select tests.create_supabase_user('org_owner', 'owner@test.com'); +select tests.create_supabase_user('org_admin', 'admin@test.com'); +select tests.create_supabase_user('org_editor', 'editor@test.com'); +select tests.create_supabase_user('premium_user', 'premium@test.com'); +select tests.create_supabase_user('free_user', 'free@test.com'); +select tests.create_supabase_user('scheduler', 'scheduler@test.com'); +select tests.create_supabase_user('free_author', 'free_author@test.com'); + +-- Create profiles for test users +insert into profiles (id, username, full_name) +values + (tests.get_supabase_uid('org_owner'), 'org_owner', 'Organization Owner'), + (tests.get_supabase_uid('org_admin'), 'org_admin', 'Organization Admin'), + (tests.get_supabase_uid('org_editor'), 'org_editor', 'Organization Editor'), + (tests.get_supabase_uid('premium_user'), 'premium_user', 'Premium User'), + (tests.get_supabase_uid('free_user'), 'free_user', 'Free User'), + (tests.get_supabase_uid('scheduler'), 'scheduler', 'Scheduler User'), + (tests.get_supabase_uid('free_author'), 'free_author', 'Free Author'); + +-- First authenticate as service role to bypass RLS for initial setup +select tests.authenticate_as_service_role(); + +-- Create test organizations and setup data +with new_org as ( + insert into organizations (name, slug, plan_type, max_posts) + values + ('Test Org', 'test-org', 'pro', 100), + ('Premium Org', 'premium-org', 'enterprise', 1000), + ('Schedule Org', 'schedule-org', 'pro', 100), + ('Free Org', 'free-org', 'free', 2) + returning id, slug +), +-- Setup members and posts +member_setup as ( + insert into org_members (org_id, user_id, role) + select + org.id, + user_id, + role + from new_org org cross join ( + values + (tests.get_supabase_uid('org_owner'), 'owner'), + (tests.get_supabase_uid('org_admin'), 'admin'), + (tests.get_supabase_uid('org_editor'), 'editor'), + (tests.get_supabase_uid('premium_user'), 'viewer'), + (tests.get_supabase_uid('scheduler'), 'editor'), + (tests.get_supabase_uid('free_author'), 'editor') + ) as members(user_id, role) + where org.slug = 'test-org' + or (org.slug = 'premium-org' and role = 'viewer') + or (org.slug = 'schedule-org' and role = 'editor') + or (org.slug = 'free-org' and role = 'editor') +) +-- Setup initial posts +insert into posts (title, content, org_id, author_id, status, is_premium, scheduled_for) +select + title, + content, + org.id, + author_id, + status, + is_premium, + scheduled_for +from new_org org cross join ( + values + ('Premium Post', 'Premium content', tests.get_supabase_uid('premium_user'), 'published', true, null), + ('Free Post', 'Free content', tests.get_supabase_uid('premium_user'), 'published', false, null), + ('Future Post', 'Future content', tests.get_supabase_uid('scheduler'), 'published', false, '2024-01-02 12:00:00+00'::timestamptz) +) as posts(title, content, author_id, status, is_premium, scheduled_for) +where org.slug in ('premium-org', 'schedule-org'); + +-- Test owner privileges +select tests.authenticate_as('org_owner'); +select lives_ok( + $$ + update organizations + set name = 'Updated Org' + where id = (select id from organizations limit 1) + $$, + 'Owner can update organization' +); + +-- Test admin privileges +select tests.authenticate_as('org_admin'); +select results_eq( + $$select count(*) from org_members$$, + ARRAY[6::bigint], + 'Admin can view all members' +); + +-- Test editor restrictions +select tests.authenticate_as('org_editor'); +select throws_ok( + $$ + insert into org_members (org_id, user_id, role) + values ( + (select id from organizations limit 1), + (select tests.get_supabase_uid('org_editor')), + 'viewer' + ) + $$, + '42501', + 'new row violates row-level security policy for table "org_members"', + 'Editor cannot manage members' +); + +-- Premium Content Access Tests +select tests.authenticate_as('premium_user'); +select results_eq( + $$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$, + ARRAY[3::bigint], + 'Premium user can see all posts' +); + +select tests.clear_authentication(); +select results_eq( + $$select count(*) from posts where org_id = (select id from organizations where slug = 'premium-org')$$, + ARRAY[2::bigint], + 'Anonymous users can only see free posts' +); + +-- Time-Based Publishing Tests +select tests.authenticate_as('scheduler'); +select tests.freeze_time('2024-01-01 12:00:00+00'::timestamptz); + +select results_eq( + $$select count(*) from posts where scheduled_for > now() and org_id = (select id from organizations where slug = 'schedule-org')$$, + ARRAY[1::bigint], + 'Can see scheduled posts' +); + +select tests.freeze_time('2024-01-02 13:00:00+00'::timestamptz); + +select results_eq( + $$select count(*) from posts where scheduled_for < now() and org_id = (select id from organizations where slug = 'schedule-org')$$, + ARRAY[1::bigint], + 'Can see posts after schedule time' +); + +select tests.unfreeze_time(); + +-- Plan Limit Tests +select tests.authenticate_as('free_author'); + +select lives_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 1', 'Content 1', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + 'First post creates successfully' +); + +select lives_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 2', 'Content 2', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + 'Second post creates successfully' +); + +select throws_ok( + $$ + insert into posts (title, content, org_id, author_id, status) + select 'Post 3', 'Content 3', id, auth.uid(), 'draft' + from organizations where slug = 'free-org' limit 1 + $$, + '42501', + 'new row violates row-level security policy for table "posts"', + 'Cannot exceed free plan post limit' +); + +select * from finish(); +rollback; +``` + +## Additional resources + +- [Test Helpers Documentation](https://database.dev/basejump/supabase_test_helpers) +- [Test Helpers Reference](https://github.com/usebasejump/supabase-test-helpers) +- [Row Level Security Writing Guide](https://usebasejump.com/blog/testing-on-supabase-with-pgtap) +- [Database.dev Package Registry](https://database.dev) +- [Row Level Security Performance and Best Practices](https://github.com/orgs/supabase/discussions/14576) diff --git a/apps/docs/content/guides/platform/fly-postgres.mdx b/apps/docs/content/guides/platform/fly-postgres.mdx index 9eccbff39be..47f96ab8ad5 100644 --- a/apps/docs/content/guides/platform/fly-postgres.mdx +++ b/apps/docs/content/guides/platform/fly-postgres.mdx @@ -6,19 +6,16 @@ description: 'Deploy Supabase projects on the Fly.io platform' Fly Postgres databases are deployed on the Fly.io edge network. Fly Postgres is supported in [every region](https://fly.io/docs/reference/regions/) where Fly.io operates. - + -Fly Postgres is currently in private alpha. To request early access, [submit the request form](https://forms.supabase.com/fly-postgres). Do not use it for Production workloads or to process Protected Health Information (PHI). +Fly Postgres is being deprecated on March 14, 2025. Head over to the [announcement](https://github.com/orgs/supabase/discussions/33413) for more information. ## Quickstart -1. Install the latest version of the [Fly CLI](https://fly.io/docs/hands-on/install-flyctl/) -2. Authenticate via the CLI: `flyctl auth login` -3. Set up a new Fly Postgres application with `flyctl extensions supabase create`. When prompted, enter your Fly organization, app name, and the region to launch the database in. -4. Once the provisioning is complete, the database connection string is displayed. Add this as an environment variable to connect to the database from your Fly applications. -5. Access the Supabase dashboard by running `flyctl extensions supabase dashboard ` +1. Authenticate via the CLI: `flyctl auth login` +2. Access the Supabase dashboard by running `flyctl extensions supabase dashboard ` The full list of CLI commands for Fly Postgres are documented in the [Fly docs](https://fly.io/docs/flyctl/extensions-supabase/). @@ -40,7 +37,6 @@ In the dashboard, you gain access to several powerful tools, including: - Table editor: Create, edit and delete tables and columns. - Log explorer: Inspect real-time logs for your database. - Postgres upgrades: Easily upgrade your Fly Postgres instance to the latest version. -- Compute upgrades: Scale the compute resources allocated to your database. ## Permissions @@ -50,14 +46,6 @@ User accounts on Supabase are also created on demand. Every Fly user gets a uniq Upon launching a Fly Postgres database, the initiating user is granted the owner role within the new Supabase organization. All subsequent users are added with the `developer` role. Roles can be adjusted in the Supabase dashboard as required. -## Pricing - -Each Fly user is entitled to one free Fly Postgres database. To create additional databases, upgrade your Supabase organization to a paid plan. - -Fly Postgres databases are priced exactly the same as projects launched via Supabase. For detailed pricing information, visit the [Supabase pricing page](https://supabase.com/pricing). - -The [usage page](https://supabase.com/dashboard/org/_/usage) details the usage of your Fly Postgres databases. The usage information is sent to Fly and the billing is added to your Fly invoice. Supabase adheres to the Fly billing cycles, which start on the 1st of every month. Any upgrades or downgrades in between are prorated and billed on the 1st of the next month. This can lead to delayed charges. For example, if you upgrade to the Pro Plan in the middle of a month, you will be charged the pro-rated amount on the first of the next month. - ## Limitations When using Fly Postgres, be aware of the current restrictions: @@ -65,5 +53,4 @@ When using Fly Postgres, be aware of the current restrictions: - Direct database connections are only supported via IPv6. Read more in the [connecting to your database](#connecting-to-your-database) section. - [Network restrictions](/docs/guides/platform/network-restrictions) are not supported - Currently, only the database, Supavisor, and PostgREST are supported. Support for additional Supabase products such as Realtime, Storage, and Auth is planned. -- New projects can only be launched using the Fly CLI and not via the Supabase dashboard. -- The [pg_cron](/docs/guides/database/extensions/pg_cron) extension is not fully supported for Fly projects. Fly projects shut down after 15 minutes of inactivity, but transparently start up when an external request is received. However, this does not apply to pg_cron jobs, which aren’t triggered by external requests. ` pg_cron` jobs don’t run when the database is shut down due to inactivity. We are working on removing this behavior for paid projects. +- The [pg_cron](/docs/guides/database/extensions/pg_cron) extension is not fully supported for Fly projects. Fly projects shut down after 15 minutes of inactivity, but transparently start up when an external request is received. However, this does not apply to pg_cron jobs, which aren’t triggered by external requests. ` pg_cron` jobs don’t run when the database is shut down due to inactivity. diff --git a/apps/docs/content/guides/platform/org-based-billing.mdx b/apps/docs/content/guides/platform/org-based-billing.mdx index 84963c02b27..2edbd9ff146 100644 --- a/apps/docs/content/guides/platform/org-based-billing.mdx +++ b/apps/docs/content/guides/platform/org-based-billing.mdx @@ -9,8 +9,6 @@ Our goal at Supabase is to provide a _predictable_ billing system that grows wit -If you’re using Fly Postgres, see the [Fly Postgres Pricing](/docs/guides/platform/fly-postgres#pricing) section. - If you’re using Vercel Marketplace, see the [Vercel Marketplace Pricing](/docs/guides/platform/vercel-marketplace#pricing) section. diff --git a/apps/docs/content/guides/realtime.mdx b/apps/docs/content/guides/realtime.mdx index 80cf895d32b..9d5cd5d2026 100644 --- a/apps/docs/content/guides/realtime.mdx +++ b/apps/docs/content/guides/realtime.mdx @@ -138,6 +138,26 @@ for await insertion in insertions { ``` + + +```py +# Initialize the Python client +from supabase import create_client, Client + +supabase: Client = create_client(SUPABASE_URL, SUPABASE_ANON_KEY) + +# Create a function to handle inserts +def handle_inserts(payload): + print("Change received", payload) + +# Listen to inserts +await supabase.channel("todos").on_postgres_changes( + "INSERT", schema="public", table="todos", callback=handle_inserts +).subscribe() +``` + + + Use [subscribe()](/docs/reference/javascript/subscribe) to listen to database changes. diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx index 7e5bf96871e..023a6775c02 100644 --- a/apps/docs/features/docs/GuidesMdx.template.tsx +++ b/apps/docs/features/docs/GuidesMdx.template.tsx @@ -1,6 +1,7 @@ import { ExternalLink } from 'lucide-react' import { type SerializeOptions } from 'next-mdx-remote/dist/types' import { type ReactNode } from 'react' +import ReactMarkdown from 'react-markdown' import { cn } from 'ui' @@ -78,9 +79,13 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT id="sb-docs-guide-main-article" className="prose max-w-none" > -

{meta?.title || 'Supabase Docs'}

+

+ {meta?.title || 'Supabase Docs'} +

{meta?.subtitle && ( -

{meta.subtitle}

+

+ {meta.subtitle} +

)}
diff --git a/apps/docs/features/docs/GuidesMdx.utils.tsx b/apps/docs/features/docs/GuidesMdx.utils.tsx index 04c56ce7dd2..6fbb0858829 100644 --- a/apps/docs/features/docs/GuidesMdx.utils.tsx +++ b/apps/docs/features/docs/GuidesMdx.utils.tsx @@ -89,7 +89,7 @@ const getGuidesMarkdownInternal = async (slug: string[]) => { const getGuidesMarkdown = cache_fullProcess_withDevCacheBust( getGuidesMarkdownInternal, GUIDES_DIRECTORY, - (filename: string) => JSON.stringify([{ slug: filename.replace(/\.mdx$/, '').split(sep) }]) + (filename: string) => JSON.stringify([filename.replace(/\.mdx$/, '').split(sep)]) ) const genGuidesStaticParams = (directory?: string) => async () => { diff --git a/apps/docs/features/docs/MdxBase.shared.tsx b/apps/docs/features/docs/MdxBase.shared.tsx index 7b0fb4b1c99..38b6921089a 100644 --- a/apps/docs/features/docs/MdxBase.shared.tsx +++ b/apps/docs/features/docs/MdxBase.shared.tsx @@ -1,7 +1,7 @@ import { ArrowDown, Check, X } from 'lucide-react' import Link from 'next/link' import { Button, Image } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Admonition, type AdmonitionProps } from 'ui-patterns/admonition' import { GlassPanel } from 'ui-patterns/GlassPanel' import { IconPanel } from 'ui-patterns/IconPanel' import SqlToRest from 'ui-patterns/SqlToRest' @@ -46,6 +46,7 @@ const components = { Accordion, AccordionItem, Admonition, + // Admonition: (props: AdmonitionProps) => , AuthErrorCodesTable, AuthRateLimits, AuthSmsProviderConfig, diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs index b4572b5640f..18267dfd271 100644 --- a/apps/docs/next.config.mjs +++ b/apps/docs/next.config.mjs @@ -115,6 +115,10 @@ const nextConfig = { }, ], }, + { + source: '/favicon/:slug*', + headers: [{ key: 'cache-control', value: 'public, max-age=86400' }], + }, ] }, diff --git a/apps/docs/spec/api_v1_openapi.json b/apps/docs/spec/api_v1_openapi.json index cee17c87cd1..c3ef4092d80 100644 --- a/apps/docs/spec/api_v1_openapi.json +++ b/apps/docs/spec/api_v1_openapi.json @@ -4501,7 +4501,7 @@ "type": "string", "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] }, - "postgres_engine": { "type": "string", "enum": ["13", "14", "15", "17-oriole"] } + "postgres_engine": { "type": "string", "enum": ["13", "14", "15", "17", "17-oriole"] } }, "required": ["version", "release_channel", "postgres_engine"] }, diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index 6715e529876..63e8b0ed8e5 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 2.9.6 + version: 2.12.0 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -3006,7 +3006,7 @@ commands: Linked project is up to date. - id: self-hosted name: Self hosted - code: supabase db push --db-url "postgres://user:pass@127.0.0.1:5432/postgres" + code: supabase db push --db-url "postgres://user.tenant:pass@127.0.0.1:5432" response: | Pushing migration 20230410135622_create_employees_table.sql... Finished supabase db push. diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index 20f89198ded..bcb73ea1fae 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -6055,7 +6055,7 @@ functions: ```ts const { data, error } = await supabase .from('characters') - .select('id', 'name') + .select('id, name') .order('id', { ascending: false }) ``` data: diff --git a/apps/docs/spec/supabase_kt_v3.yml b/apps/docs/spec/supabase_kt_v3.yml index b375a697e46..970fc1229bb 100644 --- a/apps/docs/spec/supabase_kt_v3.yml +++ b/apps/docs/spec/supabase_kt_v3.yml @@ -2883,7 +2883,7 @@ functions: isSpotlight: true code: | ```kotlin - val supabase = createSupabaseClient(supabaseURL = "https://xyzcompany.supabase.co'", supabaseKey = "public-anon-key") { ... } + val supabase = createSupabaseClient(supabaseUrl = "https://xyzcompany.supabase.co'", supabaseKey = "public-anon-key") { ... } val auth = supabase.auth ``` - id: sign-up diff --git a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json index 62ea0ee1c78..ee6a05a7eb3 100644 --- a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json +++ b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json @@ -8234,7 +8234,7 @@ }, "postgres_engine": { "type": "string", - "enum": ["13", "14", "15", "17-oriole"] + "enum": ["13", "14", "15", "17", "17-oriole"] } }, "required": ["version", "release_channel", "postgres_engine"] @@ -14521,7 +14521,7 @@ }, "postgres_engine": { "type": "string", - "enum": ["13", "14", "15", "17-oriole"] + "enum": ["13", "14", "15", "17", "17-oriole"] } }, "required": ["version", "release_channel", "postgres_engine"] @@ -14543,7 +14543,7 @@ }, "postgres_engine": { "type": "string", - "enum": ["13", "14", "15", "17-oriole"] + "enum": ["13", "14", "15", "17", "17-oriole"] } }, "required": ["version", "release_channel", "postgres_engine"] diff --git a/apps/docs/tailwind.config.js b/apps/docs/tailwind.config.js index eb86da3cc96..176e4401cee 100644 --- a/apps/docs/tailwind.config.js +++ b/apps/docs/tailwind.config.js @@ -10,7 +10,8 @@ module.exports = config({ './layouts/**/*.tsx', './pages/**/*.{tsx,mdx}', './../../packages/ui/src/**/*.{tsx,ts,js}', - './../../packages/ui-patterns/!(node_modules)/**/*.{tsx,ts,js}', + './../../packages/ui-patterns/**/*.{tsx,ts,js}', + '!./../../packages/ui-patterns/node_modules/**/*', // ignore node_modules rule ], plugins: [ function ({ addUtilities, addVariant }) { diff --git a/apps/studio/Dockerfile b/apps/studio/Dockerfile index 23dfdc12c5b..586be9e9177 100644 --- a/apps/studio/Dockerfile +++ b/apps/studio/Dockerfile @@ -11,7 +11,6 @@ FROM node:20-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable # Fixes issues with Sentry CLI and SSL certificates during build # TODO: Git is added because it's needed to build libpg, remove it once they publish a binary on the S3 bucket @@ -24,6 +23,8 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists/* && \ update-ca-certificates +RUN npm install -g pnpm@9.15.5 + WORKDIR /app # Prune unneeded dependencies with turbo (from apps/ for example) diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 7ae2be788e9..d54189d2d38 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,36 +1,22 @@ -import { useMonaco } from '@monaco-editor/react' -import { useTheme } from 'next-themes' -import { PropsWithChildren, useMemo } from 'react' +import { PropsWithChildren } from 'react' import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner' import IncidentBanner from 'components/layouts/AppLayout/IncidentBanner' import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' import { RestrictionBanner } from 'components/layouts/AppLayout/RestrictionBanner' -import { getTheme } from 'components/ui/CodeEditor/CodeEditor.utils' import { useFlag } from 'hooks/ui/useFlag' import { useProfile } from 'lib/profile' const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { - const monaco = useMonaco() const { profile } = useProfile() - const { resolvedTheme } = useTheme() const ongoingIncident = useFlag('ongoingIncident') const showNoticeBanner = useFlag('showNoticeBanner') const clockSkewBanner = useFlag('clockSkewBanner') - // Define the supabase theme for Monaco before anything is rendered. Using useEffect would sometime load the theme - // after the editor was loaded, so it looked off. useMemo will always be run before rendering - useMemo(() => { - if (monaco && resolvedTheme) { - const mode: any = getTheme(resolvedTheme) - monaco.editor.defineTheme('supabase', mode) - } - }, [resolvedTheme, monaco]) - return ( -
-
+
+
{ongoingIncident && } {showNoticeBanner && } {profile !== undefined && } diff --git a/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx b/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx new file mode 100644 index 00000000000..92b9fb6e0f8 --- /dev/null +++ b/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx @@ -0,0 +1,44 @@ +import { useMonaco } from '@monaco-editor/react' +import { useTheme } from 'next-themes' +import { useMemo } from 'react' + +const getTheme = (theme: string) => { + const isDarkMode = theme.includes('dark') + // [TODO] Probably need better theming for light mode + return { + base: isDarkMode ? ('vs-dark' as const) : ('vs' as const), // can also be vs-dark or hc-black + inherit: true, // can also be false to completely replace the builtin rules + rules: [ + { token: '', background: isDarkMode ? '1f1f1f' : 'f0f0f0' }, + { + token: '', + background: isDarkMode ? '1f1f1f' : 'f0f0f0', + foreground: isDarkMode ? 'd4d4d4' : '444444', + }, + { token: 'string.sql', foreground: '24b47e' }, + { token: 'comment', foreground: '666666' }, + { token: 'predefined.sql', foreground: isDarkMode ? 'D4D4D4' : '444444' }, + ], + colors: { 'editor.background': isDarkMode ? '#1f1f1f' : '#f0f0f0' }, + } +} + +/** + * This component is used to set the theme for the Monaco editor. This would be a hook but it needs to be placed between + * ThemeProvider and the layout page so a component is the most convenient way to do this. + */ +export const MonacoThemeProvider = () => { + const monaco = useMonaco() + const { resolvedTheme } = useTheme() + + // Define the supabase theme for Monaco before anything is rendered. Using useEffect would sometime load the theme + // after the editor was loaded, so it looked off. useMemo will always be run before rendering + useMemo(() => { + if (monaco && resolvedTheme) { + const mode = getTheme(resolvedTheme) + monaco.editor.defineTheme('supabase', mode) + } + }, [resolvedTheme, monaco]) + + return null +} diff --git a/apps/studio/components/interfaces/Auth/AdvancedAuthSettingsForm/AdvancedAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/AdvancedAuthSettingsForm/AdvancedAuthSettingsForm.tsx index 1cd50cd0cbb..44a2ef5f487 100644 --- a/apps/studio/components/interfaces/Auth/AdvancedAuthSettingsForm/AdvancedAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/AdvancedAuthSettingsForm/AdvancedAuthSettingsForm.tsx @@ -1,7 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useEffect } from 'react' import { toast } from 'sonner' -import { boolean, number, object, string } from 'yup' +import { number, object } from 'yup' import { useParams } from 'common' import { FormActions } from 'components/ui/Forms/FormActions' @@ -16,7 +15,6 @@ 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_, @@ -24,62 +22,15 @@ import { Alert_Shadcn_, Form, InputNumber, - Toggle, WarningIcon, } from 'ui' const schema = object({ - JWT_EXP: number() - .max(604800, 'Must be less than 604800') - .required('Must have a JWT expiry value'), - 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'), - 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 be a value more than 0') - .max(30, 'Must be a value no greater than 30'), - DB_MAX_POOL_SIZE: number() - .min(1, 'Must be 1 or larger') - .max(200, 'Must be a value no greater than 200'), API_MAX_REQUEST_DURATION: number() .min(5, 'Must be 5 or larger') .max(30, 'Must be a value no greater than 30'), - MFA_TOTP: string().required(), - MFA_PHONE: string().required(), }) -function determineMFAStatus(verifyEnabled: boolean, enrollEnabled: boolean) { - return verifyEnabled ? (enrollEnabled ? 'Enabled' : 'Verify Enabled') : 'Disabled' -} - -const MFAFactorSelectionOptions = [ - { - label: 'Enabled', - value: 'Enabled', - }, - { - label: 'Verify Enabled', - value: 'Verify Enabled', - }, - { - label: 'Disabled', - value: 'Disabled', - }, -] - -const MfaStatusToState = (status: (typeof MFAFactorSelectionOptions)[number]['value']) => { - return status === 'Enabled' - ? { verifyEnabled: true, enrollEnabled: true } - : status === 'Verify Enabled' - ? { verifyEnabled: true, enrollEnabled: false } - : { verifyEnabled: false, enrollEnabled: false } -} - const AdvancedAuthSettingsForm = () => { const { ref: projectRef } = useParams() const { @@ -102,72 +53,16 @@ const AdvancedAuthSettingsForm = () => { const isTeamsEnterprisePlan = isSuccessSubscription && subscription.plan.id !== 'free' && subscription.plan.id !== 'pro' - const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free' - const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp - - const projectAddons = subscription?.project_addons.find((addon) => addon.ref === projectRef) - const hasPurchasedAuthMFAAddOn = projectAddons?.addons.some( - (addon) => addon.type === 'auth_mfa_phone' - ) const promptTeamsEnterpriseUpgrade = IS_PLATFORM && !isTeamsEnterprisePlan const INITIAL_VALUES = { - SITE_URL: authConfig?.SITE_URL, - JWT_EXP: authConfig?.JWT_EXP, - REFRESH_TOKEN_ROTATION_ENABLED: authConfig?.REFRESH_TOKEN_ROTATION_ENABLED || false, - MFA_PHONE_OTP_LENGTH: authConfig?.MFA_PHONE_OTP_LENGTH || 6, - MFA_PHONE_TEMPLATE: authConfig?.MFA_PHONE_TEMPLATE || 'Your code is {{ .Code }}', - SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: authConfig?.SECURITY_REFRESH_TOKEN_REUSE_INTERVAL, - MFA_MAX_ENROLLED_FACTORS: authConfig?.MFA_MAX_ENROLLED_FACTORS || 10, - DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE || 10, API_MAX_REQUEST_DURATION: authConfig?.API_MAX_REQUEST_DURATION || 10, - // TOTP is enabled by default. Auth environment variables are distinct from UI state - we use MFA_TOTP and MFA_PHONE to hold the derivedUI state. - // MFA_TOTP_VERIFY_ENABLED and MFA_TOTP_ENROLL_ENABLED -> Enabled - // MFA_TOTP_VERIFY_ENABLED and !MFA_TOTP_ENROLL_ENABLED -> Verify Enabled - // !MFA_TOTP_VERIFY_ENABLED and !MFA_TOTP_ENROLL_ENABLED -> Disabled - 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 { 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. - if (isProPlanAndUp) { - const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } = - MfaStatusToState(values.MFA_PHONE) - payload = { - ...payload, - MFA_PHONE_ENROLL_ENABLED, - MFA_PHONE_VERIFY_ENABLED, - } - } - payload = { - ...payload, - MFA_TOTP_ENROLL_ENABLED, - MFA_TOTP_VERIFY_ENABLED, - } - delete payload.MFA_TOTP - delete payload.MFA_PHONE + const payload = { ...values } if (!isTeamsEnterprisePlan) { - delete payload.DB_MAX_POOL_SIZE delete payload.API_MAX_REQUEST_DURATION } @@ -201,27 +96,11 @@ const AdvancedAuthSettingsForm = () => { return (
- {({ handleReset, resetForm, values, initialValues, setFieldValue }: any) => { + {({ handleReset, values, initialValues }: 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]) - - 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' - return ( <> - - {
} > - Access Tokens (JWT)}> - - seconds} - disabled={!canUpdateConfig} - /> - - - Refresh Tokens}> - - - {values.REFRESH_TOKEN_ROTATION_ENABLED && ( - seconds} - disabled={!canUpdateConfig} - /> - )} - - - Multi-Factor Authentication (MFA)} - > - - - - factors} - disabled={!canUpdateConfig} - /> - - - Advanced MFA}> - - {promptProPlanUpgrade && ( - - )} - - {!hasValidMFAProvider && phoneMFAIsEnabled && ( - - - - To use MFA with Phone you should set up a Phone provider or Send SMS Hook. - - - )} - - - - {hasUpgradedPhoneMFA && ( - - - - 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. - - - )} - - - - Max Direct Database Connections} - > - - {promptTeamsEnterpriseUpgrade && ( - - )} - - connections} - disabled={!canUpdateConfig || !isTeamsEnterprisePlan} - /> - - - Max Request Duration}> {promptTeamsEnterpriseUpgrade && ( diff --git a/apps/studio/components/interfaces/Auth/Auth.constants.ts b/apps/studio/components/interfaces/Auth/Auth.constants.ts index a970de63739..27458458bc6 100644 --- a/apps/studio/components/interfaces/Auth/Auth.constants.ts +++ b/apps/studio/components/interfaces/Auth/Auth.constants.ts @@ -41,3 +41,6 @@ export function urlRegex( 'i' ) } + +// Use a const string to represent no chars option. Represented as empty string on the backend side. +export const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS' diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 9f2f3e5f416..1d7910cc620 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -6,6 +6,7 @@ import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DocsButton } from 'components/ui/DocsButton' import type { components } from 'data/api' @@ -24,11 +25,11 @@ import { Input, WarningIcon, } from 'ui' +import { Admonition } from 'ui-patterns' +import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants' import type { Provider } from './AuthProvidersForm.types' import FormField from './FormField' -import { Markdown } from 'components/interfaces/Markdown' -import { Admonition } from 'ui-patterns' export interface ProviderFormProps { config: components['schemas']['GoTrueConfigResponse'] @@ -175,6 +176,11 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => { if (payload[x] === '') payload[x] = null }) + // The backend uses empty string to represent no required characters in the password + if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) { + payload.PASSWORD_REQUIRED_CHARACTERS = '' + } + updateAuthConfig( { projectRef: projectRef!, config: payload }, { diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx index dcb72820e2a..9cd4c2e2ca7 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx @@ -1,6 +1,6 @@ -import { boolean, number, object, string } from 'yup' -import { urlRegex } from 'components/interfaces/Auth/Auth.constants' +import { NO_REQUIRED_CHARACTERS, urlRegex } from 'components/interfaces/Auth/Auth.constants' import { ProjectAuthConfigData } from 'data/auth/auth-config-query' +import { boolean, number, object, string } from 'yup' const parseBase64URL = (b64url: string) => { return atob(b64url.replace(/[-]/g, '+').replace(/[_]/g, '/')) @@ -36,6 +36,44 @@ const PROVIDER_EMAIL = { If disabled, a user can change their password at any time.`, type: 'boolean', }, + PASSWORD_HIBP_ENABLED: { + title: 'Prevent use of leaked passwords', + description: + 'Rejects the use of known or easy to guess passwords on sign up or password change. Powered by the HaveIBeenPwned.org Pwned Passwords API.', + type: 'boolean', + }, + PASSWORD_MIN_LENGTH: { + title: 'Minimum password length', + type: 'number', + description: + 'Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.', + units: 'characters', + }, + PASSWORD_REQUIRED_CHARACTERS: { + type: 'select', + title: 'Password Requirements', + description: 'Passwords that do not have at least one of each will be rejected as weak.', + enum: [ + { + label: 'No required characters (default)', + value: NO_REQUIRED_CHARACTERS, + }, + { + label: 'Letters and digits', + value: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789', + }, + { + label: 'Lowercase, uppercase letters and digits', + value: 'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789', + }, + { + label: 'Lowercase, uppercase letters, digits and symbols (recommended)', + value: + 'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};\'\\\\:"|<>?,./`~', + }, + ], + }, + MAILER_OTP_EXP: { title: 'Email OTP Expiration', type: 'number', @@ -55,6 +93,9 @@ const PROVIDER_EMAIL = { .max(86400, 'Must be no more than 86400') .required('This is required'), MAILER_OTP_LENGTH: number().min(6, 'Must be at least 6').max(10, 'Must be no more than 10'), + PASSWORD_MIN_LENGTH: number() + .min(6, 'Must be greater or equal to 6.') + .required('This is required'), }), misc: { iconKey: 'email-icon2', diff --git a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx index 6ddf1eafef7..1e1c108669c 100644 --- a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx @@ -1,77 +1,38 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ExternalLink, Eye, EyeOff } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import { toast } from 'sonner' -import { boolean, number, object, string } from 'yup' +import { boolean, object, string } from 'yup' import { useParams } from 'common' 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 NoPermission from 'components/ui/NoPermission' -import UpgradeToPro from 'components/ui/UpgradeToPro' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' 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_, Button, Form, - Input, - InputNumber, Toggle, WarningIcon, } from 'ui' -import FormField from '../AuthProvidersForm/FormField' - -// Use a const string to represent no chars option. Represented as empty string on the backend side. -const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS' -const LETTERS_AND_DIGITS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789' -const LOWER_UPPER_DIGITS = 'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789' -const LOWER_UPPER_DIGITS_SYMBOLS = LOWER_UPPER_DIGITS + ':!@#$%^&*()_+-=[]{};\'\\\\:"|<>?,./`~' +import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' const schema = object({ DISABLE_SIGNUP: boolean().required(), EXTERNAL_ANONYMOUS_USERS_ENABLED: boolean().required(), SECURITY_MANUAL_LINKING_ENABLED: boolean().required(), SITE_URL: string().required('Must have a Site URL'), - SECURITY_CAPTCHA_ENABLED: boolean().required(), - SECURITY_CAPTCHA_SECRET: string().when('SECURITY_CAPTCHA_ENABLED', { - is: true, - then: (schema) => schema.required('Must have a Captcha secret'), - }), - SECURITY_CAPTCHA_PROVIDER: string().when('SECURITY_CAPTCHA_ENABLED', { - is: true, - then: (schema) => - schema - .oneOf(['hcaptcha', 'turnstile']) - .required('Captcha provider must be either hcaptcha or turnstile'), - }), - 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(), - PASSWORD_MIN_LENGTH: number().min(6, 'Must be greater or equal to 6.'), - PASSWORD_REQUIRED_CHARACTERS: string(), - PASSWORD_HIBP_ENABLED: boolean(), }) -function HoursOrNeverText({ value }: { value: number }) { - if (value === 0) { - return 'never' - } else if (value === 1) { - return 'hour' - } else { - return 'hours' - } -} - const formId = 'auth-config-basic-settings' const BasicAuthSettingsForm = () => { @@ -85,36 +46,14 @@ const BasicAuthSettingsForm = () => { } = useAuthConfigQuery({ projectRef }) const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() - const [hidden, setHidden] = useState(true) const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') - const organization = useSelectedOrganization() - const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery( - { - orgSlug: organization?.slug, - }, - { enabled: IS_PLATFORM } - ) - - const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free' - const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp - 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 onSubmit = (values: any, { resetForm }: any) => { @@ -154,114 +93,125 @@ const BasicAuthSettingsForm = () => { } return ( - - {({ 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]) +
+ - return ( - <> - - -
- } - > - User Signups}> - - - - } - disabled={!canUpdateConfig} - /> - - } - disabled={!canUpdateConfig} - /> - {values.EXTERNAL_ANONYMOUS_USERS_ENABLED && ( -
- - -
- - Anonymous users will use the{' '} - authenticated role when signing in - - -

- As a result, anonymous users will be subjected to RLS policies that - apply to the public and{' '} - authenticated roles. We strongly - advise{' '} - + {({ 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]) + + return ( + <> + + +

+ } + > + User Signups}> + + + + } + disabled={!canUpdateConfig} + /> + + } + disabled={!canUpdateConfig} + /> + {values.EXTERNAL_ANONYMOUS_USERS_ENABLED && ( +
+ + +
+ + Anonymous users will use the{' '} + authenticated role when signing in + + +

+ As a result, anonymous users will be subjected to RLS policies that + apply to the public and{' '} + authenticated roles. We strongly + advise{' '} + + reviewing your RLS policies + {' '} + to ensure that access to your data is restricted where required. +

+ -
-
-
- {!values.SECURITY_CAPTCHA_ENABLED && ( + + View access control docs + + + +
+
+
+ )} + {!authConfig?.SECURITY_CAPTCHA_ENABLED && + values.EXTERNAL_ANONYMOUS_USERS_ENABLED && ( @@ -284,173 +234,14 @@ const BasicAuthSettingsForm = () => { )} -
- )} - - - Passwords}> - - characters} - disabled={!canUpdateConfig} - /> - - {!promptProPlanUpgrade ? ( - <> - ) : ( - - )} - - - - User Sessions}> - - {!promptProPlanUpgrade ? ( - <> - ) : ( - - )} - - - - - } - disabled={!canUpdateConfig || !isProPlanAndUp} - /> - - - - } - disabled={!canUpdateConfig || !isProPlanAndUp} - /> - - - Bot and Abuse Protection} - > - - - {values.SECURITY_CAPTCHA_ENABLED && ( - <> - - : } - type="default" - onClick={() => setHidden(!hidden)} - /> - } - /> - - )} - - - - - ) - }} - + + + + + ) + }} + +
) } diff --git a/apps/studio/components/interfaces/Auth/EmailRateLimitsAlert/EmailRateLimitsAlert.tsx b/apps/studio/components/interfaces/Auth/EmailRateLimitsAlert/EmailRateLimitsAlert.tsx index 4eb4b01e371..e5e4d39fdb7 100644 --- a/apps/studio/components/interfaces/Auth/EmailRateLimitsAlert/EmailRateLimitsAlert.tsx +++ b/apps/studio/components/interfaces/Auth/EmailRateLimitsAlert/EmailRateLimitsAlert.tsx @@ -1,6 +1,7 @@ import Link from 'next/link' import { useParams } from 'common' +import { InlineLink } from 'components/ui/InlineLink' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -19,20 +20,14 @@ export function EmailRateLimitsAlert() { You're using the built-in email service. The service has rate limits and it's not meant to be used for production apps. Check the{' '} - {/* [Refactor] Swap for InlineLink component once https://github.com/supabase/supabase/pull/30494 is in */} - + documentation - {' '} + {' '} for an up-to-date information on the current rate limits. - diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx index d7ed428d7cd..e67cb1ca492 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/EmailTemplates.tsx @@ -33,13 +33,6 @@ const EmailTemplates = () => { return (
-
- -
{isError && ( )} @@ -49,32 +42,35 @@ const EmailTemplates = () => {
)} {isSuccess && ( - - - + <> + {builtInSMTP ? ( +
+ +
+ ) : null} + + + + {TEMPLATES_SCHEMAS.map((template) => { + return ( + + {template.title} + + ) + })} + + {TEMPLATES_SCHEMAS.map((template) => { + const panelId = template.title.trim().replace(/\s+/g, '-') return ( - - {template.title} - + + + ) })} -
- {TEMPLATES_SCHEMAS.map((template) => { - const panelId = template.title.trim().replace(/\s+/g, '-') - return ( - - {builtInSMTP ? ( -
- -
- ) : null} - -
- ) - })} -
-
+ + + )}
) diff --git a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx index 23b202cc9ba..5e1d5738d1a 100644 --- a/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx +++ b/apps/studio/components/interfaces/Auth/EmailTemplates/TemplateEditor.tsx @@ -166,7 +166,7 @@ const TemplateEditor = ({ template }: TemplateEditorProps) => { return ( <> - + {Object.keys(properties).map((x: string) => { const property = properties[x] diff --git a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx index 37c469af4ac..f005bb73987 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/HooksListing.tsx @@ -69,11 +69,7 @@ export const HooksListing = () => { return (
- } - /> + } /> {hooks.filter((h) => isValidHook(h)).length === 0 && (
{ + return status === 'Enabled' + ? { verifyEnabled: true, enrollEnabled: true } + : status === 'Verify Enabled' + ? { verifyEnabled: true, enrollEnabled: false } + : { verifyEnabled: false, enrollEnabled: false } +} + +const MfaAuthSettingsForm = () => { + const { ref: projectRef } = useParams() + const { + data: authConfig, + error: authConfigError, + isLoading, + isError, + isSuccess, + } = useAuthConfigQuery({ projectRef }) + const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() + + const formId = 'auth-config-advanced-form' + const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + + const organization = useSelectedOrganization() + const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({ + orgSlug: organization?.slug, + }) + + const isProPlanAndUp = isSuccessSubscription && subscription?.plan?.id !== 'free' + const promptProPlanUpgrade = IS_PLATFORM && !isProPlanAndUp + + const projectAddons = subscription?.project_addons.find((addon) => addon.ref === projectRef) + const hasPurchasedAuthMFAAddOn = projectAddons?.addons.some( + (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 { 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. + if (isProPlanAndUp) { + const { verifyEnabled: MFA_PHONE_VERIFY_ENABLED, enrollEnabled: MFA_PHONE_ENROLL_ENABLED } = + MfaStatusToState(values.MFA_PHONE) + payload = { + ...payload, + MFA_PHONE_ENROLL_ENABLED, + MFA_PHONE_VERIFY_ENABLED, + } + } + payload = { + ...payload, + MFA_TOTP_ENROLL_ENABLED, + MFA_TOTP_VERIFY_ENABLED, + } + delete payload.MFA_TOTP + delete payload.MFA_PHONE + + updateAuthConfig( + { projectRef: projectRef!, config: payload }, + { + onError: (error) => { + toast.error(`Failed to update settings: ${error?.message}`) + }, + onSuccess: () => { + toast.success('Successfully updated settings') + resetForm({ values: values, initialValues: values }) + }, + } + ) + } + + if (isError) { + return ( + + + Failed to retrieve auth configuration + {authConfigError.message} + + ) + } + + if (!canReadConfig) { + return + } + + return ( +
+ {({ 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]) + + 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' + + return ( + <> + + +
+ } + > + Multi-Factor Authentication (MFA)} + > + + + + factors} + disabled={!canUpdateConfig} + /> + + + SMS MFA}> + + {promptProPlanUpgrade && ( + + )} + + {!hasValidMFAProvider && phoneMFAIsEnabled && ( + + + + To use MFA with Phone you should set up a Phone provider or Send SMS Hook. + + + )} + + + + {hasUpgradedPhoneMFA && ( + + + + 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. + + + )} + + + + + ) + }} + + ) +} + +export default MfaAuthSettingsForm diff --git a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx new file mode 100644 index 00000000000..ce1aad4abbc --- /dev/null +++ b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx @@ -0,0 +1,228 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Eye, EyeOff } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { boolean, 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 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 { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + Form, + Input, + Toggle, + WarningIcon, +} from 'ui' +import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' +import FormField from '../AuthProvidersForm/FormField' + +const schema = object({ + DISABLE_SIGNUP: boolean().required(), + EXTERNAL_ANONYMOUS_USERS_ENABLED: boolean().required(), + SECURITY_MANUAL_LINKING_ENABLED: boolean().required(), + SITE_URL: string().required('Must have a Site URL'), + SECURITY_CAPTCHA_ENABLED: boolean().required(), + SECURITY_CAPTCHA_SECRET: string().when('SECURITY_CAPTCHA_ENABLED', { + is: true, + then: (schema) => schema.required('Must have a Captcha secret'), + }), + SECURITY_CAPTCHA_PROVIDER: string().when('SECURITY_CAPTCHA_ENABLED', { + is: true, + then: (schema) => + schema + .oneOf(['hcaptcha', 'turnstile']) + .required('Captcha provider must be either hcaptcha or turnstile'), + }), + 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(), + PASSWORD_MIN_LENGTH: number().min(6, 'Must be greater or equal to 6.'), + PASSWORD_REQUIRED_CHARACTERS: string(), + PASSWORD_HIBP_ENABLED: boolean(), +}) + +const formId = 'auth-config-protection-settings' + +const ProtectionAuthSettingsForm = () => { + const { ref: projectRef } = useParams() + const { + data: authConfig, + error: authConfigError, + isLoading, + isError, + isSuccess, + } = useAuthConfigQuery({ projectRef }) + const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() + + 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 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 + if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) { + payload.PASSWORD_REQUIRED_CHARACTERS = '' + } + + updateAuthConfig( + { projectRef: projectRef!, config: payload }, + { + onError: (error) => { + toast.error(`Failed to update settings: ${error?.message}`) + }, + onSuccess: () => { + toast.success(`Successfully updated settings`) + resetForm({ values: values, initialValues: values }) + }, + } + ) + } + + if (isError) { + return ( + + + Failed to retrieve auth configuration + {authConfigError.message} + + ) + } + + if (!canReadConfig) { + return + } + + return ( +
+ + +
+ {({ 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]) + + return ( + <> + + +
+ } + > + Bot and Abuse Protection}> + + + {values.SECURITY_CAPTCHA_ENABLED && ( + <> + + : } + type="default" + onClick={() => setHidden(!hidden)} + /> + } + /> + + )} + + + + + ) + }} + +
+ ) +} + +export default ProtectionAuthSettingsForm diff --git a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx index bbb3210480f..eb22382e5e5 100644 --- a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx +++ b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx @@ -129,12 +129,6 @@ const RateLimits = () => { return (
- - {isError && } {isLoading && } diff --git a/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx new file mode 100644 index 00000000000..d7c7a9dbf20 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx @@ -0,0 +1,218 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useEffect } from 'react' +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 NoPermission from 'components/ui/NoPermission' +import UpgradeToPro from 'components/ui/UpgradeToPro' +import { useAuthConfigQuery } from 'data/auth/auth-config-query' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +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, + WarningIcon, +} from 'ui' + +function HoursOrNeverText({ value }: { value: number }) { + if (value === 0) { + return 'never' + } else if (value === 1) { + return 'hour' + } else { + return 'hours' + } +} + +const schema = 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'), + 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(), +}) + +const SessionsAuthSettingsForm = () => { + const { ref: projectRef } = useParams() + const { + data: authConfig, + error: authConfigError, + isLoading, + isError, + isSuccess, + } = useAuthConfigQuery({ projectRef }) + const { mutate: updateAuthConfig, isLoading: isUpdatingConfig } = useAuthConfigUpdateMutation() + + const formId = 'auth-config-sessions-form' + const canReadConfig = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + + const organization = useSelectedOrganization() + const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({ + orgSlug: organization?.slug, + }) + + 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 onSubmit = (values: any, { resetForm }: any) => { + const payload = { ...values } + + updateAuthConfig( + { projectRef: projectRef!, config: payload }, + { + onError: (error) => { + toast.error(`Failed to update settings: ${error?.message}`) + }, + onSuccess: () => { + toast.success('Successfully updated settings') + resetForm({ values: values, initialValues: values }) + }, + } + ) + } + + if (isError) { + return ( + + + Failed to retrieve auth configuration + {authConfigError.message} + + ) + } + + if (!canReadConfig) { + return + } + + return ( +
+ {({ handleReset, resetForm, values, initialValues }: 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]) + + return ( + <> + + +
+ } + > + Refresh Tokens}> + + + {values.REFRESH_TOKEN_ROTATION_ENABLED && ( + seconds
} + disabled={!canUpdateConfig} + /> + )} + + + + User Sessions}> + + {promptProPlanUpgrade && ( + + )} + + + + + } + disabled={!canUpdateConfig || !isProPlanAndUp} + /> + + + + } + disabled={!canUpdateConfig || !isProPlanAndUp} + /> + + + + + ) + }} + + ) +} + +export default SessionsAuthSettingsForm diff --git a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx index 91dac8155f2..f099a80fa74 100644 --- a/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx +++ b/apps/studio/components/interfaces/Auth/SmtpForm/SmtpForm.tsx @@ -185,10 +185,6 @@ const SmtpForm = () => { return ( <> - @@ -232,22 +228,16 @@ const SmtpForm = () => {

} /> - {enableSmtp ? ( - !isValidSmtpConfig && ( -
- - - All fields below must be filled - - The following fields must be filled before custom SMTP can be properly - enabled - - -
- ) - ) : ( + {enableSmtp && !isValidSmtpConfig && !isValidSmtpConfig && (
- + + + All fields below must be filled + + The following fields must be filled before custom SMTP can be properly + enabled + +
)} diff --git a/apps/studio/components/interfaces/Auth/index.tsx b/apps/studio/components/interfaces/Auth/index.tsx index 1c63343165a..0e4fe8f9d3e 100644 --- a/apps/studio/components/interfaces/Auth/index.tsx +++ b/apps/studio/components/interfaces/Auth/index.tsx @@ -2,5 +2,8 @@ export { default as AdvancedAuthSettingsForm } from './AdvancedAuthSettingsForm/ export { default as AuthProvidersForm } from './AuthProvidersForm/AuthProvidersForm' export { default as BasicAuthSettingsForm } from './BasicAuthSettingsForm/BasicAuthSettingsForm' export { default as EmailTemplates } from './EmailTemplates/EmailTemplates' +export { default as MfaAuthSettingsForm } from './MfaAuthSettingsForm/MfaAuthSettingsForm' +export { default as SessionsAuthSettingsForm } from './SessionsAuthSettingsForm/SessionsAuthSettingsForm' +export { default as ProtectionAuthSettingsForm } from './ProtectionAuthSettingsForm/ProtectionAuthSettingsForm' export { default as SmtpForm } from './SmtpForm/SmtpForm' export { ThirdPartyAuthForm } from './ThirdPartyAuthForm' diff --git a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/CPUWarnings.tsx b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/CPUWarnings.tsx index 8616aeddf4a..fc254440a42 100644 --- a/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/CPUWarnings.tsx +++ b/apps/studio/components/interfaces/Billing/Usage/UsageWarningAlerts/CPUWarnings.tsx @@ -22,7 +22,9 @@ const CPUWarnings = ({ isFreePlan, upgradeUrl, severity }: CPUWarningsProps) =>
+ + ) : ( + <> + {/* [Joshen] Can probably remove this after Feb 28 */} + {type === 'session' && isSessionMode && ( + + )} + + {notice && ( +
+ {notice?.map((text: string) => ( +

+ {text} +

+ ))} +
+ )} + {parameters.length > 0 && } + )} - {parameters.length > 0 && }
diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx index 357ad0f78af..9e3817a5ff2 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -11,6 +11,7 @@ import { usePoolingConfigurationQuery } from 'data/database/pooling-configuratio import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { pluckObjectFields } from 'lib/helpers' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { @@ -36,7 +37,6 @@ import { import { CodeBlockFileHeader, ConnectionPanel } from './ConnectionPanel' import { getConnectionStrings } from './DatabaseSettings.utils' import examples, { Example } from './DirectConnectionExamples' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' const StepLabel = ({ number, diff --git a/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx b/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx index 98543751db2..5bb7e906d59 100644 --- a/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx +++ b/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx @@ -127,7 +127,9 @@ export const CollectionForm = ({ className="mt-2" type="primary" onClick={() => - router.push(`/org/${currentOrg?.slug}/billing?panel=subscriptionPlan`) + router.push( + `/org/${currentOrg?.slug}/billing?panel=subscriptionPlan&source=dataWarehouseIncreaseRetention` + ) } > Upgrade plan diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx index 48a962d8840..ac58b65c35b 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -62,6 +62,7 @@ const BackupsList = () => { icon={} primaryText="Free Plan does not include project backups." secondaryText="Upgrade to the Pro Plan for up to 7 days of scheduled backups." + source="backups" /> ) } diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 706c526cfd5..d404c52701a 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -1,18 +1,20 @@ import type { PostgresSchema } from '@supabase/postgres-meta' import { Loader2 } from 'lucide-react' import { useTheme } from 'next-themes' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo } from 'react' import ReactFlow, { Background, BackgroundVariant, MiniMap, useReactFlow } from 'reactflow' import 'reactflow/dist/style.css' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import SchemaSelector from 'components/ui/SchemaSelector' import { useSchemasQuery } from 'data/database/schemas-query' import { useTablesQuery } from 'data/tables/tables-query' import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { SchemaGraphLegend } from './SchemaGraphLegend' import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils' @@ -24,7 +26,7 @@ export const SchemaGraph = () => { const { ref } = useParams() const { resolvedTheme } = useTheme() const { project } = useProjectContext() - const [selectedSchema, setSelectedSchema] = useState('public') + const { selectedSchema, setSelectedSchema } = useQuerySchemaState() const miniMapNodeColor = '#111318' const miniMapMaskColor = resolvedTheme?.includes('dark') @@ -147,42 +149,58 @@ export const SchemaGraph = () => {
)} {isSuccessTables && ( -
- saveNodePositions()} - > - - - - -
+ <> + {tables.length === 0 ? ( +
+ +

+ There are no tables found in the schema "{selectedSchema}" +

+
+
+ ) : ( +
+ saveNodePositions()} + > + + + + +
+ )} + )} ) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 84a224ed922..784bc7e930a 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -314,7 +314,11 @@ export function DiskManagementForm() { title="Compute and Disk configuration is not available on the Free Plan" actions={ } description="You will need to upgrade to at least the Pro Plan to configure compute and disk" diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index 7f42ba51bce..a0c7d66194c 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -316,7 +316,7 @@ export function DiskManagementPanelForm() { Upgrade plan diff --git a/apps/studio/components/interfaces/Docs/Introduction.tsx b/apps/studio/components/interfaces/Docs/Introduction.tsx index ae8dd1b4f9d..18ee355957d 100644 --- a/apps/studio/components/interfaces/Docs/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Introduction.tsx @@ -15,7 +15,7 @@ export default function Introduction({ selectedLang }: Props) { const { ref: projectRef } = useParams() const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: config, isSuccess } = useProjectPostgrestConfigQuery({ projectRef }) const protocol = settings?.app_config?.protocol ?? 'https' const hostEndpoint = settings?.app_config?.endpoint @@ -42,9 +42,9 @@ export default function Introduction({ selectedLang }: Props) { the easiest way to interact with everything we offer within the Supabase ecosystem.

-
+
- {!isPublicSchemaEnabled && } + {isSuccess && !isPublicSchemaEnabled && }
diff --git a/apps/studio/components/interfaces/Docs/Pages/Tables/Introduction.tsx b/apps/studio/components/interfaces/Docs/Pages/Tables/Introduction.tsx index d3116933cb6..5a7ad40d0f1 100644 --- a/apps/studio/components/interfaces/Docs/Pages/Tables/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Pages/Tables/Introduction.tsx @@ -13,7 +13,7 @@ interface IntroductionProps { const Introduction = ({ selectedLang }: IntroductionProps) => { const { ref: projectRef } = useParams() - const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: config, isSuccess } = useProjectPostgrestConfigQuery({ projectRef }) const isPublicSchemaEnabled = config?.db_schema .split(',') @@ -24,13 +24,14 @@ const Introduction = ({ selectedLang }: IntroductionProps) => { <>

Introduction

-
+

All views and tables in the public schema and accessible by the active database role for a request are available for querying.

- - {!isPublicSchemaEnabled && } +
+
+ {isSuccess && !isPublicSchemaEnabled && }
diff --git a/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx b/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx index 99be76b020f..15d2a8b505c 100644 --- a/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx +++ b/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx @@ -1,36 +1,28 @@ -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -import { Info } from 'lucide-react' -import ReactMarkdown from 'react-markdown' import Link from 'next/link' + import { useParams } from 'common' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns' const PublicSchemaNotEnabledAlert = () => { const { ref: projectRef } = useParams() return ( - - - - The `public` schema for this project is not exposed - - - - You will not be able to query tables and views in the `public` schema via supabase-js or - HTTP clients. - - -
- -
-
-
+ +

The public schema for this project is not exposed

+

+ You will not be able to query tables and views in the public schema via supabase-js or HTTP + clients. Configure this behavior in your project's Data API settings. +

+ +
) } diff --git a/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts new file mode 100644 index 00000000000..cf4a7f0c93b --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Vercel/VercelIntegration.utils.ts @@ -0,0 +1,8 @@ +export function isVercelUrl(url: string): boolean { + try { + return new URL(url).hostname === 'vercel.com' + } catch { + // If the URL is invalid, return false + return false + } +} diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index 3ca3f447120..5abde526b91 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -96,6 +96,7 @@ const AuditLogs = () => { return () => clearInterval(interval) }, [dateRange.from, dateRange.to]) + const activeMembers = (members ?? []).filter((x) => !x.invited_at) const roles = [...(rolesData?.org_scoped_roles ?? []), ...(rolesData?.project_scoped_roles ?? [])] const retentionPeriod = data?.retention_period ?? 0 @@ -165,7 +166,7 @@ const AuditLogs = () => {
@@ -184,7 +185,7 @@ const AuditLogs = () => {

Filter by

)} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index 2e9a6b3504d..0ee67a41a87 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -1,8 +1,11 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' import { useEffect } from 'react' +import { useForm } from 'react-hook-form' import { toast } from 'sonner' +import { z } from 'zod' +import { Form, FormControl, FormField } from '@ui/components/shadcn/ui/form' import { useParams } from 'common' import { ScaffoldSection, @@ -12,29 +15,56 @@ import { import { FormActions } from 'components/ui/Forms/FormActions' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection' +import NoPermission from 'components/ui/NoPermission' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' -import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { Form, Input } from 'ui' +import { FormMessage_Shadcn_, Input_Shadcn_ } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { InfoTooltip } from 'ui-patterns/info-tooltip' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +const FORM_ID = 'org-billing-email' +const formSchema = z.object({ + billingEmail: z.string().email('Please provide a valid email address').optional(), + additionalBillingEmails: z.string().email({ message: 'invalid_email' }).array().default([]), +}) const BillingEmail = () => { const { slug } = useParams() - const queryClient = useQueryClient() const selectedOrganization = useSelectedOrganization() - const { name, billing_email } = selectedOrganization ?? {} - const formId = 'org-billing-email' - const initialValues = { billing_email: billing_email ?? '' } + const { name, billing_email } = selectedOrganization ?? {} const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') const canReadBillingEmail = useCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' ) + + const { data: billingCustomer, isLoading: loadingBillingCustomer } = + useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingEmail }) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + billingEmail: billing_email ?? '', + additionalBillingEmails: billingCustomer?.additional_emails ?? [], + }, + }) + const { additionalBillingEmails } = form.watch() + const { errors } = form.formState + const additionalEmailsError = errors.additionalBillingEmails ?? [] + const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - const onUpdateOrganizationEmail = async (values: any, { resetForm }: any) => { + const onUpdateOrganizationEmail = async (values: z.infer) => { if (!canUpdateOrganization) { return toast.error('You do not have the required permissions to update this organization') } @@ -45,19 +75,26 @@ const BillingEmail = () => { { slug, name, - billing_email: values.billing_email, + billing_email: values.billingEmail, + additional_billing_emails: values.additionalBillingEmails, }, { - onSuccess: ({ billing_email }) => { - resetForm({ values: { billing_email }, initialValues: { billing_email } }) - invalidateOrganizationsQuery(queryClient) + onSuccess: () => { toast.success('Successfully saved settings') + form.reset(values) }, } ) } - if (!canReadBillingEmail) return null + useEffect(() => { + if (billingCustomer) { + form.reset({ + billingEmail: billing_email, + additionalBillingEmails: billingCustomer.additional_emails ?? [], + }) + } + }, [billingCustomer]) return ( @@ -70,31 +107,23 @@ const BillingEmail = () => { -
- {({ handleReset, values, initialValues, resetForm }: any) => { - 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(() => { - const values = { billing_email: billing_email ?? '' } - resetForm({ values, initialValues: values }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [slug]) - - return ( + {!canReadBillingEmail ? ( + + ) : ( + + @@ -102,20 +131,79 @@ const BillingEmail = () => { } > - - + ( + + + + + + + )} + /> + + ( + + Additional emails + + These email addresses will be CC'd in automated invoice or payment + failure emails. Payment receipts will still only go to the primary + billing address. + + + } + > + + + + + + + + + {/* [Joshen] Manually construct the message here as MultiSelector doesn't handle array errors from RHF atm */} + {Array.isArray(additionalEmailsError) && + additionalEmailsError.length > 0 && ( +
+ {additionalEmailsError.map((x, idx) => ( +

+ "{additionalBillingEmails[idx]}" is not a valid email address +

+ ))} +
+ )} +
+ )} />
- ) - }} - + + + )}
) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx index 4d5dd46d636..fce069a56d4 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx @@ -97,7 +97,11 @@ export const Restriction = () => {

) : ( diff --git a/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx b/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx index 9aa74921ba5..28b6d1704c5 100644 --- a/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx @@ -74,7 +74,9 @@ const SecurityQuestionnaire = () => { {isSuccess && (
{currentPlan?.id === 'free' || currentPlan?.id === 'pro' ? ( - + ) : ( diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 6cc226b233d..c306c9f87d9 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -305,7 +305,10 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {

The plan applies only to this new organization. To upgrade an existing organization,{' '} - + click here . diff --git a/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx b/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx index 0c4923d91fe..6411fbe4571 100644 --- a/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx +++ b/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx @@ -38,7 +38,11 @@ const FreeProjectLimitWarning = ({

diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 56b641157f8..f7f0a9d7e51 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -408,7 +408,9 @@ export const InviteMemberButton = () => { {isSuccessSubscription && (currentPlan?.id === 'free' || currentPlan?.id === 'pro') && ( diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index af4c964549e..dc8b94ea948 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -1,13 +1,13 @@ import { ArrowRight, Check, Minus, User, X } from 'lucide-react' -import Image from 'next/legacy/image' -import { useState } from 'react' import Table from 'components/to-be-cleaned/Table' import PartnerIcon from 'components/ui/PartnerIcon' +import { ProfileImage } from 'components/ui/ProfileImage' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { OrganizationMember } from 'data/organizations/organization-members-query' import { useProjectsQuery } from 'data/projects/projects-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { getGitHubProfileImgUrl } from 'lib/github' import { useProfile } from 'lib/profile' import Link from 'next/link' import { @@ -33,7 +33,6 @@ const MEMBER_ORIGIN_TO_MANAGED_BY = { export const MemberRow = ({ member }: MemberRowProps) => { const { profile } = useProfile() const selectedOrganization = useSelectedOrganization() - const [hasInvalidImg, setHasInvalidImg] = useState(false) const { data: projects } = useProjectsQuery() const { data: roles, isLoading: isLoadingRoles } = useOrganizationRolesV2Query({ @@ -47,6 +46,9 @@ export const MemberRow = ({ member }: MemberRowProps) => { const isEmailUser = member.username === member.primary_email const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io')) + const profileImageUrl = + isInvitedUser || isEmailUser || isFlyUser ? undefined : getGitHubProfileImgUrl(member.username) + // [Joshen] From project role POV, mask any roles for other projects const isObfuscated = member.role_ids.filter((id) => { @@ -69,25 +71,22 @@ export const MemberRow = ({ member }: MemberRowProps) => { return ( -
-
- {isInvitedUser || isEmailUser || isFlyUser || hasInvalidImg ? ( -
+
+
- ) : ( - {member.username} { - setHasInvalidImg(true) - }} - /> - )} -
+ } + />
{isInvitedUser === undefined ? (

{member.primary_email}

diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts b/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts index 62dcce33ae1..73feec66168 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts @@ -13,14 +13,14 @@ export const generateUsageData = (attribute: string, days: number): DataPoint[] }) } -export const getUpgradeUrl = (slug: string, subscription?: OrgSubscription) => { +export const getUpgradeUrl = (slug: string, subscription?: OrgSubscription, source?: string) => { if (!subscription) { return `/org/${slug}/billing` } return subscription?.plan?.id === 'pro' && subscription?.usage_billing_enabled === false ? `/org/${slug}/billing#cost-control` - : `/org/${slug}/billing?panel=subscriptionPlan` + : `/org/${slug}/billing?panel=subscriptionPlan&source=usage${source}` } const compactNumberFormatter = new Intl.NumberFormat('en-US', { diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx index 517b5ae00f4..d48d114fed4 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx @@ -53,7 +53,7 @@ const AttributeUsage = ({ isSuccess, currentBillingCycleSelected, }: AttributeUsageProps) => { - const upgradeUrl = getUpgradeUrl(slug ?? '', subscription) + const upgradeUrl = getUpgradeUrl(slug ?? '', subscription, attribute.key) const usageRatio = (usageMeta?.usage ?? 0) / (usageMeta?.pricing_free_units ?? 0) const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.pricing_free_units ?? 0) const usageBasedBilling = subscription?.usage_billing_enabled diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx index c9fdfb6778a..585b5b9d3b0 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx @@ -6,10 +6,12 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { formatDatabaseID } from 'data/read-replicas/replicas.utils' import { executeSql } from 'data/sql/execute-sql-query' import { DbQueryHook } from 'hooks/analytics/useDbQuery' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { LOCAL_STORAGE_KEYS } from 'lib/constants' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Button, @@ -22,7 +24,7 @@ import { TooltipTrigger, cn, } from 'ui' -import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { Markdown } from '../Markdown' import { useQueryPerformanceQuery } from '../Reports/Reports.queries' @@ -46,6 +48,8 @@ export const QueryPerformance = ({ const state = useDatabaseSelectorStateSnapshot() const { isLoading, isRefetching } = queryPerformanceQuery + const isPrimaryDatabase = state.selectedDatabaseId === ref + const formattedDatabaseId = formatDatabaseID(state.selectedDatabaseId ?? '') const [page, setPage] = useState( (preset as QUERY_PERFORMANCE_REPORT_TYPES) ?? QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING @@ -62,6 +66,8 @@ export const QueryPerformance = ({ queryHitRate.runQuery() } + const { data: databases } = useReadReplicasQuery({ projectRef: ref }) + const { data: mostTimeConsumingQueries, isLoading: isLoadingMTC } = useQueryPerformanceQuery({ preset: 'mostTimeConsuming', }) @@ -135,7 +141,7 @@ export const QueryPerformance = ({ tab.id !== QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT ? tabMax > 1000 ? (tabMax / 1000).toFixed(2) - : tabMax.toLocaleString() + : tabMax.toFixed(0) : tabMax.toLocaleString() return ( @@ -174,7 +180,6 @@ export const QueryPerformance = ({ ) : ( - {/* {Number(tab.max).toLocaleString()} */} {maxValue} {tab.id !== QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT ? tabMax > 1000 @@ -246,21 +251,27 @@ export const QueryPerformance = ({
- setShowResetgPgStatStatements(false)} - onSelectConfirm={async () => { + confirmLabel="Reset report" + confirmLabelLoading="Resetting report" + onCancel={() => setShowResetgPgStatStatements(false)} + onConfirm={async () => { + const connectionString = databases?.find( + (db) => db.identifier === state.selectedDatabaseId + )?.connectionString + + if (IS_PLATFORM && !connectionString) { + return toast.error('Unable to run query: Connection string is missing') + } + try { await executeSql({ projectRef: project?.ref, - connectionString: project?.connectionString, + connectionString, sql: `SELECT pg_stat_statements_reset();`, }) handleRefresh() @@ -269,7 +280,16 @@ export const QueryPerformance = ({ toast.error(`Failed to reset analysis: ${error.message}`) } }} - /> + > +

+ This will reset the pg_stat_statements table in the extensions schema on your{' '} + + {isPrimaryDatabase ? 'primary database' : `read replica (ID: ${formattedDatabaseId})`} + + , which is used to calculate query performance. This data will repopulate immediately + after. +

+ ) } diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 06d0bd6eefe..e38b84243c2 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -71,9 +71,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance renderCell: (props) => { const value = props.row?.[col.id] const isTime = col.name.includes('time') - const formattedValue = isTime - ? `${Number(value.toFixed(2)).toLocaleString()}ms` - : value.toLocaleString() + const formattedValue = isTime ? `${value.toFixed(0)}ms` : value.toLocaleString() return (
{ snapV2.updateSnippet({ id: snippet.id, - snippet: { ...snippet, folder_id: selectedId === 'root' ? null : selectedId }, + snippet: { ...snippet, folder_id: selectedId === 'root' ? null : folderId }, skipSave: true, }) }) diff --git a/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx b/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx index 6136b0d5eab..aaf067caba0 100644 --- a/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/OngoingQueriesPanel.tsx @@ -30,10 +30,6 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { useAppStateSnapshot } from 'state/app-state' import { useParams } from 'common' -interface OngoingQueriesPanel { - onClose: () => void -} - export const OngoingQueriesPanel = () => { const [_, setParams] = useUrlState({ replace: true }) const { viewOngoingQueries } = useParams() diff --git a/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx b/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx index e73f464d5f7..deafd573c5e 100644 --- a/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx +++ b/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx @@ -16,6 +16,7 @@ import { } from 'lucide-react' import { Dispatch, SetStateAction, useState } from 'react' import { toast } from 'sonner' +import { number, object } from 'yup' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' @@ -23,6 +24,8 @@ import Panel from 'components/ui/Panel' import { useJwtSecretUpdateMutation } from 'data/config/jwt-secret-update-mutation' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useAuthConfigQuery } from 'data/auth/auth-config-query' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { uuidv4 } from 'lib/helpers' import { @@ -36,8 +39,10 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, Input, + InputNumber, Modal, WarningIcon, + Form, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { @@ -45,6 +50,15 @@ import { JWT_SECRET_UPDATE_PROGRESS_MESSAGES, } from './API.constants' import { Admonition } from 'ui-patterns' +import { FormActions } from 'components/ui/Forms/FormActions' +import { FormPanel } from 'components/ui/Forms/FormPanel' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' + +const schema = object({ + JWT_EXP: number() + .max(604800, 'Must be less than 604800') + .required('Must have a JWT expiry value'), +}) const JWTSettings = () => { const { ref: projectRef } = useParams() @@ -59,12 +73,22 @@ const JWTSettings = () => { PermissionAction.INFRA_EXECUTE, 'queue_job.projects.update_jwt' ) + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) const { data: config, isError } = useProjectPostgrestConfigQuery({ projectRef }) const { mutateAsync: updateJwt, isLoading: isSubmittingJwtSecretUpdateRequest } = useJwtSecretUpdateMutation() + const { + data: authConfig, + error: authConfigError, + isLoading: isLoadingAuthConfig, + isSuccess: isSuccessAuthConfig, + } = useAuthConfigQuery({ projectRef }) + const { mutate: updateAuthConfig, isLoading: isUpdatingAuthConfig } = + useAuthConfigUpdateMutation() + const { Failed, Updated, Updating } = JwtSecretUpdateStatus const isJwtSecretUpdateFailed = data?.jwtSecretUpdateStatus === Failed @@ -76,6 +100,27 @@ const JWTSettings = () => { const jwtSecretUpdateProgressMessage = JWT_SECRET_UPDATE_PROGRESS_MESSAGES[data?.jwtSecretUpdateProgress as JwtSecretUpdateProgress] + const INITIAL_VALUES = { + JWT_EXP: authConfig?.JWT_EXP ?? 3600, + } + + const onUpdateJwtExp = async (values: any, { resetForm }: any) => { + if (!projectRef) return console.error('Project ref is required') + + updateAuthConfig( + { projectRef, config: values }, + { + onError: (error) => { + toast.error(`Failed to update JWT expiry: ${error?.message}`) + }, + onSuccess: () => { + toast.success('Successfully updated JWT expiry') + resetForm({ values, initialValues: values }) + }, + } + ) + } + async function handleJwtSecretUpdate( jwt_secret: string, setModalVisibility: Dispatch> @@ -93,117 +138,161 @@ const JWTSettings = () => { return ( <> - JWT Settings}> - - {isError ? ( -
- -

Failed to retrieve JWT settings

-
- ) : ( - <> - -
-
- {isUpdatingJwtSecret ? ( -
- -

- Updating JWT secret: {jwtSecretUpdateProgressMessage} -

-
- ) : ( -
-
-
-

Generate a new JWT secret

-

- A random secret will be created, or you can create your own. -

-
-
- {isUpdatingJwtSecret ? ( - - ) : !canGenerateNewJWTSecret ? ( - } - tooltip={{ - content: { - side: 'bottom', - text: 'You need additional permissions to generate a new JWT secret', - }, - }} - > - Generate a new secret - - ) : ( - - - - - - setIsGeneratingKey(true)} - > - -

Generate a random secret

-
- - setIsCreatingKey(true)} - > - -

Create my own secret

-
-
-
- )} -
-
-
- )} +
+ {({ handleReset, values, initialValues }: any) => { + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + return ( + JWT Settings} + footer={ +
+
- {isJwtSecretUpdateFailed ? ( - - Please try again. If the failures persist, please contact Supabase support with - the following details:
- Change tracking ID: {data?.changeTrackingId}
- Error message: {jwtSecretUpdateErrorMessage} -
- ) : null} -
- - )} - - + } + > + + {isError ? ( +
+ +

Failed to retrieve JWT settings

+
+ ) : ( + <> + + + seconds} + disabled={!canUpdateConfig || isLoadingAuthConfig} + /> + +
+
+ {isUpdatingJwtSecret ? ( +
+ +

+ Updating JWT secret: {jwtSecretUpdateProgressMessage} +

+
+ ) : ( +
+
+
+

Generate a new JWT secret

+

+ A random secret will be created, or you can create your own. +

+
+
+ {isUpdatingJwtSecret ? ( + + ) : !canGenerateNewJWTSecret ? ( + } + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to generate a new JWT secret', + }, + }} + > + Generate a new secret + + ) : ( + + + + + + setIsGeneratingKey(true)} + > + +

Generate a random secret

+
+ + setIsCreatingKey(true)} + > + +

Create my own secret

+
+
+
+ )} +
+
+
+ )} +
+ {isJwtSecretUpdateFailed ? ( + + Please try again. If the failures persist, please contact Supabase support + with the following details:
+ Change tracking ID: {data?.changeTrackingId}
+ Error message: {jwtSecretUpdateErrorMessage} +
+ ) : null} +
+ + )} +
+ + ) + }} + { -
+
diff --git a/apps/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx index a7cd645cfce..01b7109c670 100644 --- a/apps/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/ComputeInstanceSidePanel.tsx @@ -282,7 +282,9 @@ const ComputeInstanceSidePanel = () => { title="Changing your compute size is only available on the Pro Plan" actions={ diff --git a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx index efc429764c8..c3c66febde3 100644 --- a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx @@ -229,7 +229,9 @@ const CustomDomainSidePanel = () => { title="Custom domains are unavailable on the Free Plan" actions={ diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 5958fdb3060..eaa3c0e1c42 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -222,7 +222,9 @@ const IPv4SidePanel = () => {

Upgrade your plan to enable a IPv4 address for your project

diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 62b21a77f8f..474d6d8dbef 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -279,7 +279,9 @@ const PITRSidePanel = () => { title="Changing your Point-In-Time-Recovery is only available on the Pro Plan" actions={ diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index a5b6073f66d..e1a5a7fc8d4 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -13,6 +13,7 @@ import { DocsButton } from 'components/ui/DocsButton' import { FormActions } from 'components/ui/Forms/FormActions' import Panel from 'components/ui/Panel' import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import UpgradeToPro from 'components/ui/UpgradeToPro' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' import { usePoolingConfigurationUpdateMutation } from 'data/database/pooling-configuration-update-mutation' @@ -35,8 +36,14 @@ import { Listbox, Separator, } from 'ui' +import { Admonition } from 'ui-patterns' import { SESSION_MODE_DESCRIPTION, TRANSACTION_MODE_DESCRIPTION } from '../Database.constants' import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' +import { useAuthConfigQuery } from 'data/auth/auth-config-query' +import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { IS_PLATFORM } from 'lib/constants' const formId = 'connection-pooling-form' @@ -58,12 +65,21 @@ const FormSchema = z.object({ default_pool_size: StringToPositiveNumber, pool_mode: z.union([z.literal('transaction'), z.literal('session')]), max_client_conn: StringToPositiveNumber, + DB_MAX_POOL_SIZE: StringToPositiveNumber, }) export const ConnectionPooling = () => { const { ref: projectRef } = useParams() const { project } = useProjectContext() const snap = useDatabaseSettingsStateSnapshot() + const organization = useSelectedOrganization() + const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({ + orgSlug: organization?.slug, + }) + + const isTeamsEnterprisePlan = + isSuccessSubscription && subscription.plan.id !== 'free' && subscription.plan.id !== 'pro' + const promptTeamsEnterpriseUpgrade = IS_PLATFORM && !isTeamsEnterprisePlan const { data: addons } = useProjectAddonsQuery({ projectRef }) const computeInstance = addons?.selected_addons.find((addon) => addon.type === 'compute_instance') @@ -83,7 +99,9 @@ export const ConnectionPooling = () => { isLoading, isError, isSuccess, - } = usePoolingConfigurationQuery({ projectRef: projectRef }) + } = usePoolingConfigurationQuery({ projectRef }) + + const { data: authConfig } = useAuthConfigQuery({ projectRef }) const { data: maxConnData } = useMaxConnectionsQuery({ projectRef: project?.ref, @@ -103,12 +121,15 @@ export const ConnectionPooling = () => { } ) + const canUpdateConfig = useCheckPermissions(PermissionAction.UPDATE, 'custom_config_gotrue') + const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { pool_mode: poolingConfiguration?.pool_mode as 'transaction' | 'session', default_pool_size: poolingConfiguration?.default_pool_size as number | undefined, max_client_conn: poolingConfiguration?.max_client_conn as number | undefined, + DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE ?? null, }, }) @@ -120,12 +141,15 @@ export const ConnectionPooling = () => { pool_mode: data.pool_mode, default_pool_size: data.default_pool_size, max_client_conn: poolingConfiguration?.max_client_conn, + DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE ?? null, }) } toast.success('Successfully saved settings') }, }) + const { mutate: updateAuthConfig } = useAuthConfigUpdateMutation() + const onSubmit: SubmitHandler> = async (data) => { if (!projectRef) return console.error('Project ref is required') if (!poolingInfo) return console.error('Pooling info required') @@ -135,6 +159,20 @@ export const ConnectionPooling = () => { default_pool_size: data.default_pool_size as number | undefined, pool_mode: data.pool_mode, }) + + if (data.DB_MAX_POOL_SIZE !== authConfig?.DB_MAX_POOL_SIZE) { + updateAuthConfig( + { projectRef, config: { DB_MAX_POOL_SIZE: data.DB_MAX_POOL_SIZE ?? undefined } }, + { + onError: (error: Error) => { + toast.error(`Failed to update auth max direct connections: ${error?.message}`) + }, + onSuccess: () => { + toast.success('Successfully updated auth max direct connections') + }, + } + ) + } } useEffect(() => { @@ -143,9 +181,10 @@ export const ConnectionPooling = () => { pool_mode: poolingConfiguration?.pool_mode as 'transaction' | 'session', default_pool_size: poolingConfiguration?.default_pool_size as number | undefined, max_client_conn: poolingConfiguration?.max_client_conn as number | undefined, + DB_MAX_POOL_SIZE: authConfig?.DB_MAX_POOL_SIZE ?? null, }) } - }, [isSuccess]) + }, [isSuccess, authConfig]) return (
@@ -259,29 +298,32 @@ export const ConnectionPooling = () => { <> {field.value === 'transaction' ? ( - - - Pool mode will be set to transaction permanently on port 6543 - - - This will take into effect once saved. You can use session mode by - pointing the pooler connection to use port 5432. - - + ) : ( - - - Set to transaction mode to use both pooling modes concurrently - - - Session mode can be used concurrently with transaction mode by + {/* [Joshen] Can probably remove this after Feb 28 */} + + )} @@ -397,6 +439,45 @@ export const ConnectionPooling = () => { )} /> + ( + + + Max Direct Auth Connections + +
+ {promptTeamsEnterpriseUpgrade && ( +
+ +
+ )} + + + + + 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. + + +
+
+ )} + />
diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseReadOnlyAlert.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseReadOnlyAlert.tsx index 3ecae543018..930aed2d448 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseReadOnlyAlert.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseReadOnlyAlert.tsx @@ -39,14 +39,18 @@ export const DatabaseReadOnlyAlert = () => { {subscription?.plan.id === 'free' ? (
  • - + Upgrade to the Pro Plan {' '} to increase your database size limit to 8GB.
  • ) : subscription?.plan.id === 'pro' && subscription?.usage_billing_enabled ? (
  • - + Disable your Spend Cap {' '} to allow your project to auto-scale and expand beyond the 8GB database size limit diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index fcfe9c4f4f3..9491f6c6d5d 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -90,6 +90,7 @@ const CustomDomainConfig = () => { : 'To configure a custom domain for your project, please enable the add-on' } addon="customDomain" + source="customDomain" disabled={customDomainsDisabledDueToQuota} /> ) : ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.constants.ts b/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.constants.ts index a936975a75d..73dff33764b 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.constants.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.constants.ts @@ -44,7 +44,10 @@ export const INFRA_ACTIVITY_METRICS: CategoryMeta[] = [ name: 'Compute Add-Ons', url: 'https://supabase.com/docs/guides/platform/compute-add-ons', }, - { name: 'High CPU Usage', url: 'https://supabase.com/docs/guides/platform/exhaust-cpu' }, + { + name: 'High CPU Usage', + url: 'https://supabase.com/docs/guides/troubleshooting/high-cpu-usage', + }, { name: 'Metrics', url: 'https://supabase.com/docs/guides/platform/metrics', @@ -65,7 +68,10 @@ export const INFRA_ACTIVITY_METRICS: CategoryMeta[] = [ name: 'Compute Add-Ons', url: 'https://supabase.com/docs/guides/platform/compute-add-ons', }, - { name: 'High RAM Usage', url: 'https://supabase.com/docs/guides/platform/exhaust-ram' }, + { + name: 'High RAM Usage', + url: 'https://supabase.com/docs/guides/troubleshooting/exhaust-ram', + }, { name: 'Metrics', url: 'https://supabase.com/docs/guides/platform/metrics', diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index 83f3adab5ad..1d52a9242d0 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -301,7 +301,7 @@ const DeployNewReplicaPanel = ({ { + if (!logDataRows.length || !selectedRow) return + + const currentIndex = logDataRows.findIndex((row) => isEqual(row, selectedRow)) + if (currentIndex === -1) return + + if (event.key === 'ArrowUp' && currentIndex > 0) { + const prevRow = logDataRows[currentIndex - 1] + onRowClick(prevRow) + } else if (event.key === 'ArrowDown' && currentIndex < logDataRows.length - 1) { + const nextRow = logDataRows[currentIndex + 1] + onRowClick(nextRow) + } + }, + [logDataRows, selectedRow, onRowClick] + ) + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [handleKeyDown]) + if (!data) return null return ( diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 82536a58575..c733e38a0e9 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -320,10 +320,17 @@ export const genChartQuery = ( filters: Filters ) => { const [startOffset, trunc] = calcChartStart(params) - const where = genWhereStatement(table, filters) + let where = genWhereStatement(table, filters) const errorCondition = getErrorCondition(table) const warningCondition = getWarningCondition(table) + // pg_cron logs are a subset of postgres logs + // to calculate the chart, we need to query postgres logs + if (table === LogsTableName.PG_CRON) { + table = LogsTableName.POSTGRES + where = `where (parsed.application_name = 'pg_cron' OR event_message LIKE '%cron job%')` + } + let joins = genCrossJoinUnnests(table) const q = ` @@ -597,6 +604,8 @@ function getErrorCondition(table: LogsTableName): string { return 'response.status_code >= 500' case 'function_logs': return "metadata.level IN ('error', 'fatal')" + case 'pg_cron_logs': + return "parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')" default: return 'false' } diff --git a/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx b/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx index 76958ba1955..c5cc8b3f471 100644 --- a/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx @@ -59,7 +59,11 @@ const UpgradePrompt: React.FC = ({ show, setShowUpgradePrompt }) => { Close diff --git a/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx b/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx index 994c70f5c16..cf4a34b5528 100644 --- a/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx +++ b/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx @@ -56,7 +56,11 @@ export const PlanExpectationInfoBox = ({
    -
    +
    {children}
    diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx index 5c09fc34e56..a59ab485bc7 100644 --- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -149,7 +149,7 @@ const AccountLayout = ({ children, title, breadcrumbs }: PropsWithChildren{title ? `${title} | Supabase` : 'Supabase'} -
    +
    {children} diff --git a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx index 4e0c983b360..7fcab1d904b 100644 --- a/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx +++ b/apps/studio/components/layouts/AccountLayout/WithSidebar.tsx @@ -2,11 +2,11 @@ import { isUndefined } from 'lodash' import { ArrowUpRight, LogOut } from 'lucide-react' import Link from 'next/link' import { PropsWithChildren, ReactNode, useState } from 'react' - -import { Badge, cn, Menu, Sheet, SheetContent } from 'ui' +import { Badge, cn, Menu } from 'ui' +import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' import { LayoutHeader } from '../ProjectLayout/LayoutHeader' import type { SidebarLink, SidebarSection } from './AccountLayout.types' -import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' +import { useAppStateSnapshot } from 'state/app-state' interface WithSidebarProps { title: string @@ -31,11 +31,7 @@ const WithSidebar = ({ customSidebarContent, }: PropsWithChildren) => { const noContent = !sections && !customSidebarContent - const [isSheetOpen, setIsSheetOpen] = useState(false) - - const handleMobileMenu = () => { - setIsSheetOpen(true) - } + const { mobileMenuOpen, setMobileMenuOpen } = useAppStateSnapshot() return (
    @@ -51,14 +47,10 @@ const WithSidebar = ({ /> )}
    - +
    {children}
    - + ) => { - return
    {children}
    + return
    {children}
    } export default AppLayout diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx index acc4b1c523c..6d671823230 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPlanNotice.tsx @@ -22,7 +22,7 @@ const BranchingPlanNotice = () => { diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx index 8727e522355..dd7b88091f4 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectContext.tsx @@ -1,6 +1,5 @@ import { createContext, PropsWithChildren, useContext, useMemo } from 'react' -import { useParams } from 'common' import { Project, useProjectDetailQuery } from 'data/projects/project-detail-query' import { PROJECT_STATUS } from 'lib/constants' import { DatabaseSelectorStateContextProvider } from 'state/database-selector' @@ -51,12 +50,6 @@ export const ProjectContextProvider = ({ ) } -export const ProjectContextFromParamsProvider = ({ children }: PropsWithChildren<{}>) => { - const { ref: projectRef } = useParams() - - return {children} -} - export const useIsProjectActive = () => { const { project } = useProjectContext() return project?.status === PROJECT_STATUS.ACTIVE_HEALTHY diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index d2aac7ea3e1..725aa101df9 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -1,7 +1,3 @@ -import Head from 'next/head' -import { useRouter } from 'next/router' -import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect, useState } from 'react' - import { useParams } from 'common' import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { AIAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' @@ -9,23 +5,22 @@ import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' import AISettingsModal from 'components/ui/AISettingsModal' import { Loading } from 'components/ui/Loading' import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' +import { AnimatePresence, motion } from 'framer-motion' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' -import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { PROJECT_STATUS } from 'lib/constants' +import Head from 'next/head' +import { useRouter } from 'next/router' +import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect, useState } from 'react' import { useAppStateSnapshot } from 'state/app-state' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' -import AppLayout from '../AppLayout/AppLayout' import EnableBranchingModal from '../AppLayout/EnableBranchingButton/EnableBranchingModal' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' -import { LayoutHeader } from './LayoutHeader' import LoadingState from './LoadingState' -import MobileNavigationBar from './NavigationBar/MobileNavigationBar' -import MobileViewNav from './NavigationBar/MobileViewNav' -import NavigationBar from './NavigationBar/NavigationBar' import { ProjectPausedState } from './PausedState/ProjectPausedState' import PauseFailedState from './PauseFailedState' import PausingState from './PausingState' @@ -92,11 +87,16 @@ const ProjectLayout = forwardRef { const router = useRouter() const [isClient, setIsClient] = useState(false) - const [isSheetOpen, setIsSheetOpen] = useState(false) const { ref: projectRef } = useParams() const selectedOrganization = useSelectedOrganization() const selectedProject = useSelectedProject() - const { aiAssistantPanel, setAiAssistantPanel, editorPanel } = useAppStateSnapshot() + const { + editorPanel, + aiAssistantPanel, + setAiAssistantPanel, + mobileMenuOpen, + setMobileMenuOpen, + } = useAppStateSnapshot() const { open } = aiAssistantPanel const projectName = selectedProject?.name @@ -113,10 +113,6 @@ const ProjectLayout = forwardRef { - setIsSheetOpen(true) - } - useEffect(() => { setIsClient(true) }, []) @@ -134,123 +130,125 @@ const ProjectLayout = forwardRef - - - - {title - ? `${title} | Supabase` - : selectedTable - ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase` - : projectName - ? `${projectName} | ${organizationName} | Supabase` - : organizationName - ? `${organizationName} | Supabase` - : 'Supabase'} - - - -
    - {/* Left-most navigation side bar to access products */} - {!hideIconBar && } - {/* Top Nav to access products from mobile */} - {!hideIconBar && } - {showProductMenu && productMenu && !(!hideHeader && IS_PLATFORM) && ( - - )} + const sideBarIsOpen = true // @mildtomato - var for later to use collapsible sidebar - {/* Product menu bar */} - - {/* Existing desktop menu */} + return ( + <> + + + {title + ? `${title} | Supabase` + : selectedTable + ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase` + : projectName + ? `${projectName} | ${organizationName} | Supabase` + : organizationName + ? `${organizationName} | Supabase` + : 'Supabase'} + + + +
    + + {showProductMenu && productMenu && ( - - {productMenu} - + {sideBarIsOpen && ( + + + + {productMenu} + + + + )} + )} + {showProductMenu && productMenu && sideBarIsOpen && ( - - {!hideHeader && IS_PLATFORM && ( - - )} - + + - -
    - {showPausedState ? ( -
    -
    - -
    +
    + {showPausedState ? ( +
    +
    +
    - ) : ( - - - {children} - +
    + ) : ( + + + {children} + + )} +
    + + {isClient && (aiAssistantPanel.open || editorPanel.open) && ( + <> + + - - {isClient && (aiAssistantPanel.open || editorPanel.open) && ( - <> - - - {aiAssistantPanel.open && } - {editorPanel.open && } - - - )} - - - -
    - - - - - - + > + {aiAssistantPanel.open && } + {editorPanel.open && } + + + )} + + + +
    + + + + {productMenu} - + ) } ) diff --git a/apps/studio/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty.tsx b/apps/studio/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty.tsx new file mode 100644 index 00000000000..09d1d0906b6 --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty.tsx @@ -0,0 +1,34 @@ +import { Pointer } from 'lucide-react' +import { TreeViewItem } from 'ui' +import { InnerSideBarEmptyPanel } from 'ui-patterns' + +export const EmptyPrivateQueriesPanel = () => ( + +
    +
    +
    +
    +
    +
    +
    + +
    + {[...Array(4)].map((_, i) => ( +
    + +
    + ))} +
    +
    + +) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx index cf36ca5afb0..931727c85b2 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx @@ -1,10 +1,8 @@ -import { ReactNode, useEffect, useMemo, useState } from 'react' - import { OngoingQueriesPanel } from 'components/interfaces/SQLEditor/OngoingQueriesPanel' import { withAuth } from 'hooks/misc/withAuth' +import { ReactNode, useMemo } from 'react' import ProjectLayout from '../ProjectLayout/ProjectLayout' import { SQLEditorMenu } from './SQLEditorMenu' -import { useParams } from 'common' export interface SQLEditorLayoutProps { title: string diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 202b0c29de2..52b458484e9 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -1,9 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { FilePlus, FolderPlus, Plus, X } from 'lucide-react' -import { useRouter } from 'next/router' -import { useEffect, useState } from 'react' -import { toast } from 'sonner' - import { useDebounce } from '@uidotdev/usehooks' import { useParams } from 'common' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -11,6 +6,11 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useProfile } from 'lib/profile' +import { FilePlus, FolderPlus, Plus, X } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { getAppStateSnapshot } from 'state/app-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { Button, @@ -27,11 +27,10 @@ import { InnerSideBarFilterSearchInput, InnerSideBarFilterSortDropdown, InnerSideBarFilterSortDropdownItem, - InnerSideMenuItem, } from 'ui-patterns/InnerSideMenu' -import { SQLEditorNav } from './SQLEditorNavV2/SQLEditorNav' +import { SqlEditorMenuStaticLinks } from './SqlEditorMenuStaticLinks' import { SearchList } from './SQLEditorNavV2/SearchList' -import { getAppStateSnapshot } from 'state/app-state' +import { SQLEditorNav } from './SQLEditorNavV2/SQLEditorNav' export const SQLEditorMenu = () => { const router = useRouter() @@ -157,23 +156,7 @@ export const SQLEditorMenu = () => { ) : ( <> -
    - - Templates - - - Quickstarts - -
    - + )} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index fad9e81ac60..865976ba24d 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -9,6 +9,8 @@ import { MoveQueryModal } from 'components/interfaces/SQLEditor/MoveQueryModal' import RenameQueryModal from 'components/interfaces/SQLEditor/RenameQueryModal' import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants' import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import { EmptyPrivateQueriesPanel } from 'components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty' +import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' import { useContentCountQuery } from 'data/content/content-count-query' import { useContentDeleteMutation } from 'data/content/content-delete-mutation' import { getContentById } from 'data/content/content-id-query' @@ -53,6 +55,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const project = useSelectedProject() const { ref: projectRef, id } = useParams() const snapV2 = useSqlEditorV2StateSnapshot() + // const tabStore = getTabsStore(projectRef) const [sectionVisibility, setSectionVisibility] = useLocalStorage( LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''), DEFAULT_SECTION_STATE @@ -487,14 +490,14 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { useEffect(() => { if (projectRef && privateSnippetsPages) { privateSnippetsPages.pages.forEach((page) => { - page.contents?.forEach((snippet) => { + page.contents?.forEach((snippet: Snippet) => { snapV2.addSnippet({ projectRef, snippet, }) }) - page.folders?.forEach((folder) => snapV2.addFolder({ projectRef, folder })) + page.folders?.forEach((folder: SnippetFolder) => snapV2.addFolder({ projectRef, folder })) }) } }, [projectRef, privateSnippetsPages?.pages]) @@ -527,58 +530,58 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { return ( <> - - { setSectionVisibility({ ...(sectionVisibility ?? DEFAULT_SECTION_STATE), shared: value }) }} - className="px-0" > 0 ? ` (${numProjectSnippets})` : ''}`} /> - {isLoadingSharedSqlSnippets ? ( - - ) : numProjectSnippets === 0 ? ( + {numProjectSnippets === 0 ? ( ) : ( ( - { - setShowDeleteModal(true) - setSelectedSnippets([element.metadata as unknown as Snippet]) - }} - onSelectRename={() => { - setShowRenameModal(true) - setSelectedSnippetToRename(element.metadata as Snippet) - }} - onSelectDownload={() => { - setSelectedSnippetToDownload(element.metadata as Snippet) - }} - onSelectDuplicate={() => { - onSelectDuplicate(element.metadata as Snippet) - }} - onSelectUnshare={() => { - setSelectedSnippetToUnshare(element.metadata as Snippet) - }} - isLastItem={projectSnippetsLastItemIds.has(element.id as string)} - hasNextPage={hasMoreSharedSqlSnippets} - fetchNextPage={fetchNextSharedSqlSnippets} - isFetchingNextPage={isFetchingMoreSharedSqlSnippets} - /> - )} + nodeRenderer={({ element, ...props }) => { + const isActive = element.metadata?.id === id + return ( + { + setShowDeleteModal(true) + setSelectedSnippets([element.metadata as unknown as Snippet]) + }} + onSelectRename={() => { + setShowRenameModal(true) + setSelectedSnippetToRename(element.metadata as Snippet) + }} + onSelectDownload={() => { + setSelectedSnippetToDownload(element.metadata as Snippet) + }} + onSelectDuplicate={() => { + onSelectDuplicate(element.metadata as Snippet) + }} + onSelectUnshare={() => { + setSelectedSnippetToUnshare(element.metadata as Snippet) + }} + isLastItem={projectSnippetsLastItemIds.has(element.id as string)} + hasNextPage={hasMoreSharedSqlSnippets} + fetchNextPage={fetchNextSharedSqlSnippets} + isFetchingNextPage={isFetchingMoreSharedSqlSnippets} + /> + ) + }} /> )} @@ -615,34 +618,38 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { ( - { - setShowDeleteModal(true) - setSelectedSnippets([element.metadata as unknown as Snippet]) - }} - onSelectRename={() => { - setShowRenameModal(true) - setSelectedSnippetToRename(element.metadata as Snippet) - }} - onSelectDownload={() => { - setSelectedSnippetToDownload(element.metadata as Snippet) - }} - onSelectDuplicate={() => { - onSelectDuplicate(element.metadata as Snippet) - }} - onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} - onSelectUnshare={() => { - setSelectedSnippetToUnshare(element.metadata as Snippet) - }} - isLastItem={favoriteSnippetsLastItemIds.has(element.id as string)} - hasNextPage={hasMoreFavoriteSqlSnippets} - fetchNextPage={fetchNextFavoriteSqlSnippets} - isFetchingNextPage={isFetchingMoreFavoriteSqlSnippets} - /> - )} + nodeRenderer={({ element, ...props }) => { + const isActive = element.metadata?.id === id + return ( + { + setShowDeleteModal(true) + setSelectedSnippets([element.metadata as unknown as Snippet]) + }} + onSelectRename={() => { + setShowRenameModal(true) + setSelectedSnippetToRename(element.metadata as Snippet) + }} + onSelectDownload={() => { + setSelectedSnippetToDownload(element.metadata as Snippet) + }} + onSelectDuplicate={() => { + onSelectDuplicate(element.metadata as Snippet) + }} + onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} + onSelectUnshare={() => { + setSelectedSnippetToUnshare(element.metadata as Snippet) + }} + isLastItem={favoriteSnippetsLastItemIds.has(element.id as string)} + hasNextPage={hasMoreFavoriteSqlSnippets} + fetchNextPage={fetchNextFavoriteSqlSnippets} + isFetchingNextPage={isFetchingMoreFavoriteSqlSnippets} + /> + ) + }} /> )} @@ -663,13 +670,9 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { /> {isLoading ? ( - + ) : folders.length === 0 && numPrivateSnippets === 0 ? ( - + ) : ( { } }} expandedIds={expandedFolderIds} - nodeRenderer={({ element, ...props }) => ( - 1} - isLastItem={privateSnippetsLastItemIds.has(element.id as string)} - status={props.isBranch ? snapV2.folders[element.id].status : 'idle'} - onMultiSelect={onMultiSelect} - onSelectCreate={() => { - if (profile && project) { - const snippet = createSqlSnippetSkeletonV2({ - id: uuidv4(), - name: untitledSnippetTitle, - owner_id: profile?.id, - project_id: project?.id, - folder_id: element.id as string, - sql: '', - }) - snapV2.addSnippet({ projectRef: project.ref, snippet }) - router.push(`/project/${projectRef}/sql/${snippet.id}`) - } - }} - onSelectDelete={() => { - if (props.isBranch) { - setSelectedFolderToDelete(element.metadata as SnippetFolder) - } else { - setShowDeleteModal(true) - if (selectedSnippets.length === 0) { - setSelectedSnippets([element.metadata as unknown as Snippet]) + nodeRenderer={({ element, ...props }) => { + const isActive = element.metadata?.id === id + + return ( + 1} + isLastItem={privateSnippetsLastItemIds.has(element.id as string)} + status={props.isBranch ? snapV2.folders[element.id].status : 'idle'} + onMultiSelect={onMultiSelect} + onSelectCreate={() => { + if (profile && project) { + const snippet = createSqlSnippetSkeletonV2({ + id: uuidv4(), + name: untitledSnippetTitle, + owner_id: profile?.id, + project_id: project?.id, + folder_id: element.id as string, + sql: '', + }) + snapV2.addSnippet({ projectRef: project.ref, snippet }) + router.push(`/project/${projectRef}/sql/${snippet.id}`) } + }} + onSelectDelete={() => { + if (props.isBranch) { + setSelectedFolderToDelete(element.metadata as SnippetFolder) + } else { + setShowDeleteModal(true) + if (selectedSnippets.length === 0) { + setSelectedSnippets([element.metadata as unknown as Snippet]) + } + } + }} + onSelectRename={() => { + if (props.isBranch) { + snapV2.editFolder(element.id as string) + } else { + setShowRenameModal(true) + setSelectedSnippetToRename(element.metadata as Snippet) + } + }} + onSelectMove={() => { + setShowMoveModal(true) + if (selectedSnippets.length === 0) { + setSelectedSnippets([element.metadata as Snippet]) + } + }} + onSelectDownload={() => + setSelectedSnippetToDownload(element.metadata as Snippet) } - }} - onSelectRename={() => { - if (props.isBranch) { - snapV2.editFolder(element.id as string) - } else { - setShowRenameModal(true) - setSelectedSnippetToRename(element.metadata as Snippet) - } - }} - onSelectMove={() => { - setShowMoveModal(true) - if (selectedSnippets.length === 0) { - setSelectedSnippets([element.metadata as Snippet]) - } - }} - onSelectDownload={() => setSelectedSnippetToDownload(element.metadata as Snippet)} - onSelectDuplicate={() => onSelectDuplicate(element.metadata as Snippet)} - onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} - onEditSave={(name: string) => { - // [Joshen] Inline editing only for folders for now - if (name.length === 0 && element.id === 'new-folder') { - snapV2.removeFolder(element.id as string) - } else if (name.length > 0) { - snapV2.saveFolder({ id: element.id as string, name }) - } - }} - hasNextPage={hasNextPage} - fetchNextPage={fetchNextPage} - isFetchingNextPage={isFetchingNextPage} - sort={sort} - onFolderContentsChange={({ isLoading, snippets }) => { - setSubResults((prev) => ({ - ...prev, - [element.id as string]: { snippets, isLoading }, - })) - }} - /> - )} + onSelectDuplicate={() => onSelectDuplicate(element.metadata as Snippet)} + onSelectShare={() => setSelectedSnippetToShare(element.metadata as Snippet)} + onEditSave={(name: string) => { + // [Joshen] Inline editing only for folders for now + if (name.length === 0 && element.id === 'new-folder') { + snapV2.removeFolder(element.id as string) + } else if (name.length > 0) { + snapV2.saveFolder({ id: element.id as string, name }) + } + }} + hasNextPage={hasNextPage} + fetchNextPage={fetchNextPage} + isFetchingNextPage={isFetchingNextPage} + sort={sort} + onFolderContentsChange={({ isLoading, snippets }) => { + setSubResults((prev) => ({ + ...prev, + [element.id as string]: { snippets, isLoading }, + })) + }} + /> + ) + }} /> )} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 8c71003b1c1..cef866cb945 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -1,9 +1,9 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' import { Copy, Download, Edit, ExternalLink, Lock, Move, Plus, Share, Trash } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect } from 'react' +import { ComponentProps, useEffect } from 'react' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { IS_PLATFORM } from 'common' import { useParams } from 'common/hooks/useParams' import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query' @@ -22,12 +22,9 @@ import { TreeViewItem, } from 'ui' -interface SQLEditorTreeViewItemProps { +interface SQLEditorTreeViewItemProps + extends Omit, 'name' | 'xPadding'> { element: any - level: number - isBranch: boolean - isSelected: boolean - isExpanded: boolean isMultiSelected?: boolean status?: 'editing' | 'saving' | 'idle' getNodeProps: () => any @@ -162,13 +159,11 @@ export const SQLEditorTreeViewItem = ({ { @@ -190,9 +185,12 @@ export const SQLEditorTreeViewItem = ({ return } + // When the item is a folder, we want to expand/close it onClick(e) } }} + name={element.name} + xPadding={16} /> e.stopPropagation()}> diff --git a/apps/studio/components/layouts/SQLEditorLayout/SqlEditorMenuStaticLinks.tsx b/apps/studio/components/layouts/SQLEditorLayout/SqlEditorMenuStaticLinks.tsx new file mode 100644 index 00000000000..088d1d7fd3f --- /dev/null +++ b/apps/studio/components/layouts/SQLEditorLayout/SqlEditorMenuStaticLinks.tsx @@ -0,0 +1,34 @@ +import { useParams } from 'common' +import { useRouter } from 'next/router' +import { InnerSideMenuDataItem } from 'ui-patterns/InnerSideMenu' + +const OPTIONS = ['templates', 'quickstarts'] as const + +export function SqlEditorMenuStaticLinks() { + const { ref } = useParams() + const router = useRouter() + + function isPageActive(key: string): boolean { + return router.asPath === `/project/${ref}/sql/${key}` + } + + return ( +
    + {OPTIONS.map((pageId) => { + const active = isPageActive(pageId) + return ( + + {pageId} + + ) + })} +
    + ) +} diff --git a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx index 1d208dc484d..c9dd782a25f 100644 --- a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx +++ b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx @@ -1,17 +1,14 @@ import { useQueryClient } from '@tanstack/react-query' -import { FileText } from 'lucide-react' +import { DocsButton } from 'components/ui/DocsButton' +import { useFlag } from 'hooks/ui/useFlag' +import { BASE_PATH } from 'lib/constants' +import { auth, buildPathWithParams, getAccessToken, getReturnToPath } from 'lib/gotrue' import { useTheme } from 'next-themes' import Image from 'next/legacy/image' import Link from 'next/link' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect, useState } from 'react' - -import { useFlag } from 'hooks/ui/useFlag' -import { BASE_PATH } from 'lib/constants' -import { auth, buildPathWithParams, getAccessToken, getReturnToPath } from 'lib/gotrue' import { tweets } from 'shared-data' -import { Button } from 'ui' -import { DocsButton } from 'components/ui/DocsButton' type SignInLayoutProps = { heading: string @@ -85,7 +82,7 @@ const SignInLayout = ({ return ( <> -
    +
    -
    +
    diff --git a/apps/studio/components/layouts/TableEditorLayout/EditorMenuListSkeleton.tsx b/apps/studio/components/layouts/TableEditorLayout/EditorMenuListSkeleton.tsx new file mode 100644 index 00000000000..163d653cdc3 --- /dev/null +++ b/apps/studio/components/layouts/TableEditorLayout/EditorMenuListSkeleton.tsx @@ -0,0 +1,25 @@ +import { memo } from 'react' +import { Skeleton } from 'ui' + +const EditorMenuListSkeleton = memo(function EditorMenuListSkeleton() { + const items = [ + { width: 'w-40', opacity: 'opacity-100' }, + { width: 'w-32', opacity: 'opacity-100' }, + { width: 'w-20', opacity: 'opacity-75' }, + { width: 'w-40', opacity: 'opacity-50' }, + { width: 'w-20', opacity: 'opacity-25' }, + ] + + return ( +
    + {items.map((item, index) => ( +
    + + +
    + ))} +
    + ) +}) + +export default EditorMenuListSkeleton diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index ea2530ffa04..8dca2baec40 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -1,16 +1,5 @@ import saveAs from 'file-saver' -import { - Clipboard, - Copy, - Download, - Edit, - Eye, - Lock, - MoreHorizontal, - Table2, - Trash, - Unlock, -} from 'lucide-react' +import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock } from 'lucide-react' import Link from 'next/link' import Papa from 'papaparse' import { toast } from 'sonner' @@ -25,6 +14,7 @@ import { formatTableRowsToSQL, getEntityLintDetails, } from 'components/interfaces/TableGridEditor/TableEntity.utils' +import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' import type { ItemRenderer } from 'components/ui/InfiniteList' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { Entity } from 'data/entity-types/entity-types-infinite-query' @@ -49,13 +39,15 @@ import { Tooltip, TooltipContent, TooltipTrigger, + TreeViewItemVariant, } from 'ui' import { useProjectContext } from '../ProjectLayout/ProjectContext' export interface EntityListItemProps { - id: number + id: number | string projectRef: string isLocked: boolean + isActive?: boolean } const EntityListItem: ItemRenderer = ({ @@ -63,6 +55,7 @@ const EntityListItem: ItemRenderer = ({ projectRef, item: entity, isLocked, + isActive: _isActive, }) => { const { project } = useProjectContext() const snap = useTableEditorStateSnapshot() @@ -212,106 +205,25 @@ const EntityListItem: ItemRenderer = ({ } } - const EntityTooltipTrigger = ({ entity }: { entity: Entity }) => { - let tooltipContent = null - - switch (entity.type) { - case ENTITY_TYPE.TABLE: - if (tableHasLints) { - tooltipContent = 'RLS disabled' - } - break - case ENTITY_TYPE.VIEW: - if (viewHasLints) { - tooltipContent = 'Security definer view' - } - break - case ENTITY_TYPE.MATERIALIZED_VIEW: - if (materializedViewHasLints) { - tooltipContent = 'Security definer view' - } - break - case ENTITY_TYPE.FOREIGN_TABLE: - tooltipContent = 'RLS is not enforced on foreign tables' - break - default: - break - } - - if (tooltipContent) { - return ( - - - - - {tooltipContent} - - ) - } - - return null - } - return ( -
    - + + <> + {isActive &&
    } - - {entity.type === ENTITY_TYPE.TABLE ? ( - - ) : entity.type === ENTITY_TYPE.VIEW ? ( - - ) : ( -
    - {Object.entries(ENTITY_TYPE) - .find(([, value]) => value === entity.type)?.[0]?.[0] - ?.toUpperCase()} -
    - )} + + {formatTooltipText(entity.type)}
    @@ -332,12 +244,18 @@ const EntityListItem: ItemRenderer = ({ > {entity.name} - +
    {canEdit && ( - + @@ -435,9 +353,67 @@ const EntityListItem: ItemRenderer = ({ )} -
    -
    + + ) } +const EntityTooltipTrigger = ({ + entity, + isActive, + tableHasLints, + viewHasLints, + materializedViewHasLints, +}: { + entity: Entity + isActive: boolean + tableHasLints: boolean + viewHasLints: boolean + materializedViewHasLints: boolean +}) => { + let tooltipContent = '' + + switch (entity.type) { + case ENTITY_TYPE.TABLE: + if (tableHasLints) { + tooltipContent = 'RLS disabled' + } + break + case ENTITY_TYPE.VIEW: + if (viewHasLints) { + tooltipContent = 'Security definer view' + } + break + case ENTITY_TYPE.MATERIALIZED_VIEW: + if (materializedViewHasLints) { + tooltipContent = 'Security definer view' + } + break + case ENTITY_TYPE.FOREIGN_TABLE: + tooltipContent = 'RLS is not enforced on foreign tables' + break + default: + break + } + + if (tooltipContent) { + return ( + + + + + + {tooltipContent} + + + ) + } + + return null +} + export default EntityListItem diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index be47257ac42..4e331ac57bb 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -4,8 +4,10 @@ import { Filter, Plus } from 'lucide-react' import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' -import { useBreakpoint, useParams } from 'common' +import { useParams } from 'common' +import { useBreakpoint } from 'common/hooks/useBreakpoint' import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning' +import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import InfiniteList from 'components/ui/InfiniteList' @@ -36,10 +38,10 @@ import { InnerSideBarFilterSortDropdown, InnerSideBarFilterSortDropdownItem, InnerSideBarFilters, - InnerSideBarShimmeringLoaders, } from 'ui-patterns/InnerSideMenu' import { useProjectContext } from '../ProjectLayout/ProjectContext' import EntityListItem from './EntityListItem' +import { TableMenuEmptyState } from './TableMenuEmptyState' const TableEditorMenu = () => { const { id: _id } = useParams() @@ -114,10 +116,7 @@ const TableEditorMenu = () => { return ( <> -
    +
    { onSelectSchema={(name: string) => { setSearchText('') setSelectedSchema(name) - router.push(`/project/${project?.ref}/editor`) + router.push(`/project/${project?.ref}/editor?schema=${name}`) }} onSelectCreateSchema={() => snap.onAddSchema()} /> @@ -170,8 +169,8 @@ const TableEditorMenu = () => { )}
    -
    - +
    + { - {isLoading && } + {isLoading && } {isError && ( - +
    + +
    )} {isSuccess && ( <> {searchText.length === 0 && (entityTypes?.length ?? 0) <= 0 && ( - + )} {searchText.length > 0 && (entityTypes?.length ?? 0) <= 0 && ( { /> )} {(entityTypes?.length ?? 0) > 0 && ( -
    +
    { + return ( + +
    +
    +
    +
    +
    +
    +
    + +
    + {[...Array(4)].map((_, i) => ( +
    +
    + {i === 2 &&
    } + + {`postgres_table_${i}`} +
    +
    + ))} +
    +
    + + ) +} diff --git a/apps/studio/components/layouts/VercelIntegrationLayout.tsx b/apps/studio/components/layouts/VercelIntegrationLayout.tsx deleted file mode 100644 index cd57fb5bbb1..00000000000 --- a/apps/studio/components/layouts/VercelIntegrationLayout.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Head from 'next/head' -import type { PropsWithChildren } from 'react' - -import { BASE_PATH } from 'lib/constants' -import { Separator } from 'ui' - -const VercelIntegrationLayout = ({ children }: PropsWithChildren<{}>) => { - return ( - <> - - Supabase + Vercel - -
    -
    -
    -
    -
    -
    - Supabase - -
    -
    -
    -
    -
    - -
    -
    -
    {children}
    -
    -
    -
    - - ) -} -export default VercelIntegrationLayout diff --git a/apps/studio/components/to-be-cleaned/Integration/Vercel.utils.ts b/apps/studio/components/to-be-cleaned/Integration/Vercel.utils.ts deleted file mode 100644 index b9ae4c8c945..00000000000 --- a/apps/studio/components/to-be-cleaned/Integration/Vercel.utils.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { post, get } from 'lib/common/fetch' -import { INTEGRATION_ENVS_ALIAS } from 'lib/vercelConfigs' -import { API_URL } from 'lib/constants' - -async function doFetchVercelProjects({ - vercelToken, - vercelTeamId, - id, -}: { - vercelToken: string - vercelTeamId?: string - id?: string -}) { - let url = `${API_URL}/vercel/projects` - if (id) { - url += `/${id}` - } - if (vercelTeamId && vercelTeamId != 'null') { - const query = new URLSearchParams({ teamId: vercelTeamId }).toString() - url = `${url}?${query}` - } - const data = await get(url, { - headers: { vercel_authorization: `Bearer ${vercelToken}` }, - }) - if (data.error != null) { - return { error: `Retrieve vercel project${id ? '' : 's'} failed. ${data.error.message}` } - } - return { data } -} - -export async function fetchVercelProjects(params: { vercelToken: string; vercelTeamId?: string }) { - return (await doFetchVercelProjects(params)) as { - data?: Record[] - error?: string - } -} - -export async function fetchVercelProject(params: { - id: string - vercelToken: string - vercelTeamId?: string -}) { - return (await doFetchVercelProjects(params)) as { - data?: Record - error?: string - } -} - -export async function fetchVercelProjectEnvs({ - id, - vercelTeamId, - vercelToken, -}: { - id: string - vercelTeamId?: string - vercelToken: string -}): Promise<{ data?: Record[]; error?: string }> { - let url = `${API_URL}/vercel/projects/envs` - if (vercelTeamId && vercelTeamId != 'null') { - const query = new URLSearchParams({ projectId: id, teamId: vercelTeamId }).toString() - url = `${url}?${query}` - } else { - const query = new URLSearchParams({ projectId: id }).toString() - url = `${url}?${query}` - } - const response = await get(url, { - headers: { vercel_authorization: `Bearer ${vercelToken}` }, - }) - if (response.error != null) { - return { error: `Retrieve vercel project envs failed. ${response.error?.message}` } - } - return { data: response } -} - -export async function createVercelEnv({ - key, - value, - type, - target, - vercelProjectId, - vercelTeamId, - vercelToken, -}: { - key: string - value: string - type: 'encrypted' | string - target: ('production' | 'development' | 'preview')[] - vercelProjectId: string - vercelTeamId?: string - vercelToken: string -}) { - let url = `${API_URL}/vercel/projects/envs` - if (vercelTeamId && vercelTeamId != 'null') { - const query = new URLSearchParams({ - projectId: vercelProjectId, - teamId: vercelTeamId, - }).toString() - url = `${url}?${query}` - } else { - const query = new URLSearchParams({ projectId: vercelProjectId }).toString() - url = `${url}?${query}` - } - return await post( - url, - { key, value, type, target }, - { - headers: { vercel_authorization: `Bearer ${vercelToken}` }, - } - ) -} - -export function prepareVercelEvns( - requiredEnvs: { - key: string - alias: string - type: 'encrypted' | string - }[], - project: { - db_host?: string - db_password?: string - endpoint: string - anon_key: string - service_key: string - } -) { - const envs: any = [] - - requiredEnvs.forEach((x) => { - let env: { - key: string - value: string - type: 'encrypted' | string - target: ('production' | 'development' | 'preview')[] - } = { key: x.key, type: x.type, target: ['production', 'development', 'preview'], value: '' } - - switch (x.alias) { - case INTEGRATION_ENVS_ALIAS.ENDPOINT: - env.value = project.endpoint - break - case INTEGRATION_ENVS_ALIAS.ANONKEY: - env.value = project.anon_key - break - case INTEGRATION_ENVS_ALIAS.SERVICEKEY: - env.value = project.service_key - break - case INTEGRATION_ENVS_ALIAS.DBHOST: - env.value = project.db_host ?? '' - break - case INTEGRATION_ENVS_ALIAS.DBPASSWORD: - env.value = project.db_password ?? '' - break - } - - envs.push(env) - }) - - return envs -} diff --git a/apps/studio/components/to-be-cleaned/ProductEmptyState.tsx b/apps/studio/components/to-be-cleaned/ProductEmptyState.tsx index 7255dde4c4c..2b7d675a58a 100644 --- a/apps/studio/components/to-be-cleaned/ProductEmptyState.tsx +++ b/apps/studio/components/to-be-cleaned/ProductEmptyState.tsx @@ -2,6 +2,7 @@ import { ExternalLink } from 'lucide-react' import { PropsWithChildren } from 'react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import Link from 'next/link' import { Button } from 'ui' interface ProductEmptyStateProps { @@ -14,6 +15,7 @@ interface ProductEmptyStateProps { loading?: boolean disabled?: boolean disabledMessage?: string + ctaUrl?: string } const ProductEmptyState = ({ @@ -27,6 +29,7 @@ const ProductEmptyState = ({ loading = false, disabled = false, disabledMessage = '', + ctaUrl, }: PropsWithChildren) => { const hasAction = (ctaButtonLabel && onClickCta) || (infoButtonUrl && infoButtonLabel) @@ -40,7 +43,11 @@ const ProductEmptyState = ({
    {children}
    {hasAction && (
    - {ctaButtonLabel && onClickCta && ( + {ctaButtonLabel && !!ctaUrl ? ( + + ) : ctaButtonLabel && !!onClickCta ? ( {ctaButtonLabel} - )} + ) : null} {infoButtonUrl && infoButtonLabel ? (
    )} diff --git a/apps/studio/components/ui/AIAssistantPanel/hooks.ts b/apps/studio/components/ui/AIAssistantPanel/hooks.ts index 6de3b700fa7..507c0708e55 100644 --- a/apps/studio/components/ui/AIAssistantPanel/hooks.ts +++ b/apps/studio/components/ui/AIAssistantPanel/hooks.ts @@ -30,17 +30,21 @@ export function useAutoScroll({ enabled = true }: UseAutoScrollProps = {}) { useEffect(() => { if (!container || !enabled) return + let timeoutId: NodeJS.Timeout + const resizeObserver = new ResizeObserver(() => { - // Prevent duplicate scroll events from phantom height changes - if ( - lastScrollHeightRef.current !== undefined && - container.scrollHeight !== lastScrollHeightRef.current - ) { - lastScrollHeightRef.current = container.scrollHeight - if (isStickyRef.current) { - scrollToEnd() + clearTimeout(timeoutId) + timeoutId = setTimeout(() => { + if ( + lastScrollHeightRef.current !== undefined && + container.scrollHeight !== lastScrollHeightRef.current + ) { + lastScrollHeightRef.current = container.scrollHeight + if (isStickyRef.current) { + scrollToEnd() + } } - } + }, 100) }) const handleScroll = () => { @@ -59,6 +63,7 @@ export function useAutoScroll({ enabled = true }: UseAutoScrollProps = {}) { container.addEventListener('scroll', handleScroll) return () => { + clearTimeout(timeoutId) resizeObserver.disconnect() container.removeEventListener('scroll', handleScroll) } diff --git a/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts b/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts index e8e14e7ab49..5540d9d1456 100644 --- a/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts +++ b/apps/studio/components/ui/CodeEditor/CodeEditor.utils.ts @@ -8,24 +8,3 @@ export const alignEditor = (editor: any) => { }) }) } - -export const getTheme = (theme: string) => { - const isDarkMode = theme.includes('dark') - // [TODO] Probably need better theming for light mode - return { - base: isDarkMode ? 'vs-dark' : 'vs', // can also be vs-dark or hc-black - inherit: true, // can also be false to completely replace the builtin rules - rules: [ - { background: isDarkMode ? '1f1f1f' : 'f0f0f0' }, - { - token: '', - background: isDarkMode ? '1f1f1f' : 'f0f0f0', - foreground: isDarkMode ? 'd4d4d4' : '444444', - }, - { token: 'string.sql', foreground: '24b47e' }, - { token: 'comment', foreground: '666666' }, - { token: 'predefined.sql', foreground: isDarkMode ? 'D4D4D4' : '444444' }, - ], - colors: { 'editor.background': isDarkMode ? '#1f1f1f' : '#f0f0f0' }, - } -} diff --git a/apps/studio/components/ui/EdgeFunctionBlock/EdgeFunctionBlock.tsx b/apps/studio/components/ui/EdgeFunctionBlock/EdgeFunctionBlock.tsx index 5ff6189ad71..39c9274ebca 100644 --- a/apps/studio/components/ui/EdgeFunctionBlock/EdgeFunctionBlock.tsx +++ b/apps/studio/components/ui/EdgeFunctionBlock/EdgeFunctionBlock.tsx @@ -5,6 +5,10 @@ import { useEdgeFunctionDeployMutation } from 'data/edge-functions/edge-function import { ReportBlockContainer } from 'components/interfaces/Reports/ReportBlock/ReportBlockContainer' import { Button, CodeBlock, cn, CodeBlockLang } from 'ui' import { Code } from 'lucide-react' +import Link from 'next/link' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' +import { Admonition } from 'ui-patterns' interface EdgeFunctionBlockProps { /** Title of the EdgeFunctionBlock */ @@ -35,6 +39,9 @@ export const EdgeFunctionBlock = ({ }: EdgeFunctionBlockProps) => { const { ref } = useParams() const [isDeployed, setIsDeployed] = useState(false) + const [showWarning, setShowWarning] = useState(false) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) + const { data: existingFunction } = useEdgeFunctionQuery({ projectRef: ref, slug: functionName }) const { mutateAsync: deployFunction, isLoading: isDeploying } = useEdgeFunctionDeployMutation({ onSuccess: () => { @@ -46,13 +53,17 @@ export const EdgeFunctionBlock = ({ const handleDeploy = async () => { if (!code || isDeploying || !ref) return + if (existingFunction) { + return setShowWarning(true) + } + try { const deployResult = await deployFunction({ projectRef: ref, metadata: { entrypoint_path: 'index.ts', name: functionName, - verify_jwt: false, + verify_jwt: true, }, files: [{ name: 'index.ts', content: code }], }) @@ -63,38 +74,86 @@ export const EdgeFunctionBlock = ({ } } - const functionUrl = ref ? `https://${ref}.supabase.red/functions/v1/${functionName}` : '' - + let functionUrl = 'Function URL not available' + const endpoint = settings?.app_config?.endpoint + if (endpoint) { + const restUrl = `https://${endpoint}` + const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' + functionUrl = + ref && functionName && restUrlTld + ? `https://${ref}.supabase.${restUrlTld}/functions/v1/${functionName}` + : 'Function URL will be available after deployment' + } return ( - } + icon={} label={label} actions={ - <> - + ref && functionName ? ( + <> + - {actions} - + {actions} + + ) : null } > + {showWarning && ref && functionName && ( + +

    An edge function with the name "{functionName}" already exists.

    +

    + Deploying will replace the existing function. Are you sure you want to proceed? +

    +
    + + +
    +
    + )} +

    - Function deployed successfully! You can now invoke it at: + The{' '} + + new function + {' '} + is now live at:

    - +

    To download and work on this function locally, use the CLI command:

    diff --git a/apps/studio/components/ui/EntityTypeIcon.tsx b/apps/studio/components/ui/EntityTypeIcon.tsx new file mode 100644 index 00000000000..4538f1ffdb3 --- /dev/null +++ b/apps/studio/components/ui/EntityTypeIcon.tsx @@ -0,0 +1,80 @@ +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { Eye, GitBranch, Table2 } from 'lucide-react' +import { cn, SQL_ICON } from 'ui' + +interface EntityTypeIconProps { + type: 'sql' | 'schema' | 'new' | 'r' | 'v' | 'm' | 'f' | 'p' + size?: number + strokeWidth?: number + isActive?: boolean +} + +export const EntityTypeIcon = ({ + type, + size = 15, + strokeWidth = 1.5, + isActive, +}: EntityTypeIconProps) => { + if (type === 'sql') { + return ( + + ) + } + + if (type === ENTITY_TYPE.TABLE) { + return ( + + ) + } + + if (type === 'schema') { + return + } + + if (type === ENTITY_TYPE.VIEW) { + return ( + + ) + } + + return ( +
    + {Object.entries(ENTITY_TYPE) + .find(([, value]) => value === type)?.[0]?.[0] + ?.toUpperCase()} +
    + ) +} diff --git a/apps/studio/components/ui/InfiniteList.tsx b/apps/studio/components/ui/InfiniteList.tsx index 5d318a97bcd..2a3ece04e04 100644 --- a/apps/studio/components/ui/InfiniteList.tsx +++ b/apps/studio/components/ui/InfiniteList.tsx @@ -1,11 +1,10 @@ +import { propsAreEqual } from 'lib/helpers' import memoize from 'memoize-one' import { CSSProperties, ComponentType, MutableRefObject, ReactNode, memo, useRef } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' import { VariableSizeList, areEqual } from 'react-window' import InfiniteLoader from 'react-window-infinite-loader' - -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { propsAreEqual } from 'lib/helpers' +import { Skeleton } from 'ui' /** * Note that the loading more logic of this component works best with a cursor-based @@ -58,9 +57,28 @@ const Item = memo(({ data, index, style }: ItemProps) => {
    {LoaderComponent}
    ) : (
    - - - +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    ) }, areEqual) @@ -92,35 +110,34 @@ function InfiniteList({ const itemData = createItemData(items, { itemProps, ItemComponent, LoaderComponent, listRef }) return ( -
    -
    - - {({ height, width }: { height: number; width: number }) => ( - - {({ onItemsRendered, ref }) => ( - { - ref(refy) - listRef.current = refy - }} - height={height ?? 0} - width={width ?? 0} - itemCount={itemCount} - itemData={itemData} - itemSize={getItemSize} - onItemsRendered={onItemsRendered} - > - {Item} - - )} - - )} - -
    + <> + + {({ height, width }: { height: number; width: number }) => ( + + {({ onItemsRendered, ref }) => ( + { + ref(refy) + listRef.current = refy + }} + height={height ?? 0} + width={width ?? 0} + itemCount={itemCount} + itemData={itemData} + itemSize={getItemSize} + onItemsRendered={onItemsRendered} + > + {Item} + + )} + + )} + +
    ({ pointerEvents: 'none', //https://github.com/bvaughn/react-window/issues/455 }} /> -
    + ) } diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index fd3724b6372..dbc51b7dfa0 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -1,6 +1,7 @@ import { Megaphone } from 'lucide-react' import { forwardRef, PropsWithChildren, ReactNode } from 'react' -import { Badge, Button, Loading, cn } from 'ui' + +import { Badge, Button, cn, Loading } from 'ui' interface PanelProps { className?: string @@ -62,71 +63,78 @@ const PanelNotice = forwardRef< description?: string href?: string buttonText?: string + layout?: 'horizontal' | 'vertical' badgeLabel?: string } ->(({ className, title, description, href, buttonText, badgeLabel, ...props }, ref) => { - return ( -
    +>( + ( + { + className, + title, + description, + href, + buttonText, + layout = 'horizontal', + badgeLabel, + ...props + }, + ref + ) => { + return ( + ) + } +) PanelNotice.displayName = 'PanelNotice' diff --git a/apps/studio/components/ui/ProductMenu/ProductMenu.tsx b/apps/studio/components/ui/ProductMenu/ProductMenu.tsx index 798118f10c3..67cc2c49e39 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenu.tsx +++ b/apps/studio/components/ui/ProductMenu/ProductMenu.tsx @@ -27,19 +27,25 @@ const ProductMenu = ({ page, menu }: ProductMenuProps) => { } />
    - {group.items.map((item) => ( - - ))} + {group.items.map((item) => { + const isActive = !!item.pages + ? item.pages.includes(page ?? '') + : page === item.key + + return ( + + ) + })}
    diff --git a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts index d0a6d31523b..3bfb2cb77f9 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts +++ b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts @@ -17,4 +17,5 @@ export interface ProductMenuGroupItem { isExternal?: boolean disabled?: boolean label?: string + pages?: string[] } diff --git a/apps/studio/components/ui/ProfileImage.tsx b/apps/studio/components/ui/ProfileImage.tsx new file mode 100644 index 00000000000..da31b8d0c9e --- /dev/null +++ b/apps/studio/components/ui/ProfileImage.tsx @@ -0,0 +1,35 @@ +import Image from 'next/image' +import { ReactNode, useState } from 'react' + +import { User } from 'icons' +import { cn } from 'ui' + +interface ProfileImageProps { + alt?: string + src?: string + placeholder?: ReactNode + className?: string +} + +export const ProfileImage = ({ alt, src, placeholder, className }: ProfileImageProps) => { + const [hasInvalidImg, setHasInvalidImg] = useState(false) + + return !!src && !hasInvalidImg ? ( + {alt setHasInvalidImg(true)} + /> + ) : ( + placeholder ?? ( +
    + +
    + ) + ) +} diff --git a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts index e1fe17b52da..07915757f5b 100644 --- a/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts +++ b/apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts @@ -69,7 +69,7 @@ export const RESOURCE_WARNING_MESSAGES: Record = description: 'It may become unresponsive', }, }, - docsUrl: 'https://supabase.com/docs/guides/platform/exhaust-disk-io', + docsUrl: 'https://supabase.com/docs/guides/troubleshooting/exhaust-disk-io', buttonText: 'Check usage', metric: 'disk_io', }, @@ -124,7 +124,7 @@ export const RESOURCE_WARNING_MESSAGES: Record = description: `Performance is affected`, }, }, - docsUrl: 'https://supabase.com/docs/guides/platform/exhaust-cpu', + docsUrl: 'https://supabase.com/docs/guides/troubleshooting/high-cpu-usage', buttonText: 'Check usage', metric: 'cpu', }, @@ -152,7 +152,7 @@ export const RESOURCE_WARNING_MESSAGES: Record = description: `Performance is affected`, }, }, - docsUrl: 'https://supabase.com/docs/guides/platform/exhaust-ram', + docsUrl: 'https://supabase.com/docs/guides/troubleshooting/exhaust-ram', buttonText: 'Check usage', metric: 'ram', }, diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index 8ab840df5f4..df05ba0ceba 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -1,7 +1,7 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' import { Check, ChevronsUpDown, Plus } from 'lucide-react' import { useState } from 'react' -import { PermissionAction } from '@supabase/shared-types/out/constants' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -21,6 +21,7 @@ import { PopoverTrigger_Shadcn_, Popover_Shadcn_, ScrollArea, + Skeleton, } from 'ui' interface SchemaSelectorProps { @@ -69,8 +70,14 @@ const SchemaSelector = ({ return (
    {isSchemasLoading && ( - )} @@ -95,14 +102,15 @@ const SchemaSelector = ({ size={size} disabled={disabled} type="default" - className={`w-full [&>span]:w-full`} + data-testid="schema-selector" + className={`w-full [&>span]:w-full !pr-1 space-x-1`} iconRight={ } > {selectedSchemaName ? (
    -

    schema:

    +

    schema

    {selectedSchemaName === '*' ? 'All schemas' : selectedSchemaName}

    @@ -114,7 +122,12 @@ const SchemaSelector = ({ )} - + diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index c3b9e00301e..b1979f74866 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -16,6 +16,7 @@ interface UpgradeToProProps { secondaryText: string addon?: 'pitr' | 'customDomain' | 'computeInstance' buttonText?: string + source?: string disabled?: boolean } @@ -25,6 +26,7 @@ const UpgradeToPro = ({ secondaryText, addon, buttonText, + source = 'upgrade', disabled = false, }: UpgradeToProProps) => { const project = useSelectedProject() @@ -80,8 +82,8 @@ const UpgradeToPro = ({ {buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')} diff --git a/apps/studio/data/edge-functions/edge-function-query.ts b/apps/studio/data/edge-functions/edge-function-query.ts index ad6f54dee2a..c8d14919498 100644 --- a/apps/studio/data/edge-functions/edge-function-query.ts +++ b/apps/studio/data/edge-functions/edge-function-query.ts @@ -2,6 +2,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { components } from 'api-types' import { get, handleError } from 'data/fetchers' import { IS_PLATFORM } from 'lib/constants' +import { ResponseError } from 'types' import { edgeFunctionsKeys } from './keys' export type EdgeFunctionVariables = { @@ -28,7 +29,7 @@ export async function getEdgeFunction( } export type EdgeFunctionData = Awaited> -export type EdgeFunctionError = unknown +export type EdgeFunctionError = ResponseError export const useEdgeFunctionQuery = ( { projectRef, slug }: EdgeFunctionVariables, diff --git a/apps/studio/data/edge-functions/edge-functions-deploy-mutation.ts b/apps/studio/data/edge-functions/edge-functions-deploy-mutation.ts index fed2edda2bc..0f1b5bcaa57 100644 --- a/apps/studio/data/edge-functions/edge-functions-deploy-mutation.ts +++ b/apps/studio/data/edge-functions/edge-functions-deploy-mutation.ts @@ -1,8 +1,7 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' -import { handleError, constructHeaders } from 'data/fetchers' -import { API_URL } from 'lib/constants' +import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' import { edgeFunctionsKeys } from './keys' @@ -25,33 +24,29 @@ export async function deployEdgeFunction({ }: EdgeFunctionsDeployVariables) { if (!projectRef) throw new Error('projectRef is required') - const formData = new FormData() - formData.append('metadata', JSON.stringify(metadata)) + const { data, error } = await post(`/v1/projects/{ref}/functions/deploy`, { + params: { path: { ref: projectRef }, query: { slug: metadata.name } }, + body: { + file: files as any, + metadata: metadata, + }, + bodySerializer(body) { + const formData = new FormData() - files.forEach((file) => { - const blob = new Blob([file.content], { type: 'text/plain' }) - formData.append('file', blob, file.name) + formData.append('metadata', JSON.stringify(body.metadata)) + + body.file.forEach((f: any) => { + const file = f as { name: string; content: string } + const blob = new Blob([file.content], { type: 'text/plain' }) + formData.append('file', blob, file.name) + }) + + return formData + }, }) - const response = await fetch( - `${API_URL?.replace('/platform', '')}/v1/projects/${projectRef}/functions/deploy?slug=${metadata.name}`, - { - method: 'POST', - body: formData, - headers: await constructHeaders(), - credentials: 'include', - referrerPolicy: 'no-referrer-when-downgrade', - } - ) - - const result = await response.json() - if (response.ok) { - return result - } - - result.code = response.status - result.requestId = response.headers.get('X-Request-Id') - handleError(result) + if (error) handleError(error) + return data } type EdgeFunctionsDeployData = Awaited> diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 5d472a1cc71..b9e42e1097c 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -7,7 +7,6 @@ import { ResponseError } from 'types' import type { paths } from './api' // generated from openapi-typescript const DEFAULT_HEADERS = { - 'Content-Type': 'application/json', Accept: 'application/json', } diff --git a/apps/studio/data/organizations/organization-update-mutation.ts b/apps/studio/data/organizations/organization-update-mutation.ts index ebd346df750..628ae5f8c12 100644 --- a/apps/studio/data/organizations/organization-update-mutation.ts +++ b/apps/studio/data/organizations/organization-update-mutation.ts @@ -10,6 +10,7 @@ export type OrganizationUpdateVariables = { slug: string name?: string billing_email?: string + additional_billing_emails?: string[] opt_in_tags?: string[] } @@ -18,12 +19,14 @@ export async function updateOrganization({ name, billing_email, opt_in_tags, + additional_billing_emails, }: OrganizationUpdateVariables) { // @ts-ignore [Joshen] API spec is wrong const payload: components['schemas']['UpdateOrganizationBody'] = {} if (name) payload.name = name if (billing_email) payload.billing_email = billing_email if (opt_in_tags) payload.opt_in_tags = opt_in_tags + if (additional_billing_emails) payload.additional_billing_emails = additional_billing_emails const { data, error } = await patch('/platform/organizations/{slug}', { params: { path: { slug } }, @@ -50,8 +53,57 @@ export const useOrganizationUpdateMutation = ({ (vars) => updateOrganization(vars), { async onSuccess(data, variables, context) { - // [Joshen] Not sure if necessary to refresh the organizations list though - await queryClient.invalidateQueries(organizationKeys.list()) + queryClient.setQueriesData( + { + queryKey: organizationKeys.list(), + exact: true, + }, + (prev: components['schemas']['OrganizationResponse'][] | undefined) => { + if (!prev) return prev + + return prev.map((org) => { + if (org.slug !== variables.slug) return org + + return { + ...org, + name: variables.name || org.name, + billing_email: variables.billing_email || org.billing_email, + opt_in_tags: variables.opt_in_tags || org.opt_in_tags, + } + }) + } + ) + + queryClient.setQueriesData( + { + queryKey: organizationKeys.customerProfile(data.slug), + exact: true, + }, + (prev: components['schemas']['CustomerResponse'] | undefined) => { + if (!prev) return prev + return { + ...prev, + additional_emails: variables.additional_billing_emails || prev.additional_emails, + } + } + ) + + queryClient.setQueriesData( + { + queryKey: organizationKeys.detail(data.slug), + exact: true, + }, + (prev: components['schemas']['OrganizationSlugResponse'] | undefined) => { + if (!prev) return prev + return { + ...prev, + name: variables.name || prev.name, + billing_email: variables.billing_email || prev.billing_email, + opt_in_tags: variables.opt_in_tags || prev.opt_in_tags, + } + } + ) + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/profile/types.ts b/apps/studio/data/profile/types.ts index c16127203bb..4b756ae7e08 100644 --- a/apps/studio/data/profile/types.ts +++ b/apps/studio/data/profile/types.ts @@ -1,5 +1,7 @@ import type { components } from 'data/api' -export type Profile = components['schemas']['ProfileResponse'] +export type Profile = components['schemas']['ProfileResponse'] & { + profileImageUrl?: string +} export type Feature = Profile['disabled_features'][number] diff --git a/apps/studio/lib/github.tsx b/apps/studio/lib/github.tsx index e3c8702cb3c..8de3ef52368 100644 --- a/apps/studio/lib/github.tsx +++ b/apps/studio/lib/github.tsx @@ -63,3 +63,7 @@ export function openInstallGitHubIntegrationWindow(type: 'install' | 'authorize' newWindow.focus() } } + +export const getGitHubProfileImgUrl = (username: string) => { + return `https://github.com/${username}.png?size=80` +} diff --git a/apps/studio/lib/profile.tsx b/apps/studio/lib/profile.tsx index e303b69d0ba..97664b3756a 100644 --- a/apps/studio/lib/profile.tsx +++ b/apps/studio/lib/profile.tsx @@ -12,6 +12,7 @@ import type { Profile } from 'data/profile/types' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import type { ResponseError } from 'types' import { useSignOut } from './auth' +import { getGitHubProfileImgUrl } from './github' export type ProfileContextType = { profile: Profile | undefined @@ -74,10 +75,12 @@ export const ProfileProvider = ({ children }: PropsWithChildren<{}>) => { const value = useMemo(() => { const isLoading = isLoadingProfile || isCreatingProfile || isLoadingPermissions + const isGHUser = !!profile && 'auth0_id' in profile && profile?.auth0_id.startsWith('github') + const profileImageUrl = isGHUser ? getGitHubProfileImgUrl(profile.username) : undefined return { error, - profile, + profile: !!profile ? { ...profile, profileImageUrl } : undefined, isLoading, isError, isSuccess, diff --git a/apps/studio/lib/vercelConfigs.ts b/apps/studio/lib/vercelConfigs.ts deleted file mode 100644 index 1a4473da6a0..00000000000 --- a/apps/studio/lib/vercelConfigs.ts +++ /dev/null @@ -1,545 +0,0 @@ -import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' - -const QuickStart = SQL_TEMPLATES.filter((template) => template.type === 'quickstart') - -export const INTEGRATION_ENVS_ALIAS = { - ENDPOINT: 'SUPABASE_ENDPOINT', - ANONKEY: 'SUPABASE_ANON_KEY', - SERVICEKEY: 'SUPABASE_SERVICE_KEY', - DBHOST: 'SUPABASE_DB_HOST', - DBPASSWORD: 'SUPABASE_DB_PASSWORD', -} -export const VERCEL_DEFAULT_EXTERNAL_ID = 'nextjs-initial-project' -/* -These values have to match our integrations setup (per project) -under https://vercel.com/dashboard/supabase/integrations/console - -'id' needs to be the same as deploy button External ID -https://vercel.com/docs/more/deploy-button#external-id -*/ -export const VERCEL_INTEGRATION_CONFIGS = [ - { - id: 'supabase_realtime_map', - name: 'Realtime Map Database', - template: { - sql: `CREATE TABLE locations ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - latitude numeric NOT NULL, - longitude numeric NOT NULL, - ref VARCHAR(40) - );`, - }, - envs: [ - { - key: 'NEXT_PUBLIC_SUPABASE_ENDPOINT', - alias: INTEGRATION_ENVS_ALIAS.ENDPOINT, - type: 'encrypted', - }, - { - key: 'NEXT_PUBLIC_SUPABASE_APIKEY', - alias: INTEGRATION_ENVS_ALIAS.ANONKEY, - type: 'encrypted', - }, - ], - }, - { - id: 'nextjs-subscription-payments', - name: 'Next.js Subscription Payments Starter', - template: QuickStart.find((x) => x.title == 'Stripe Subscriptions'), - envs: [ - { - key: 'NEXT_PUBLIC_SUPABASE_URL', - alias: INTEGRATION_ENVS_ALIAS.ENDPOINT, - type: 'encrypted', - }, - { - key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', - alias: INTEGRATION_ENVS_ALIAS.ANONKEY, - type: 'encrypted', - }, - { - key: 'SUPABASE_SERVICE_ROLE_KEY', - alias: INTEGRATION_ENVS_ALIAS.SERVICEKEY, - type: 'encrypted', - }, - ], - }, - { - id: 'nextjs-open-ai-doc-search', - name: 'Next.js OpenAI Doc Search Starter', - template: QuickStart.find((x) => x.title == 'OpenAI Vector Search'), - envs: [ - { - key: 'NEXT_PUBLIC_SUPABASE_URL', - alias: INTEGRATION_ENVS_ALIAS.ENDPOINT, - type: 'encrypted', - }, - { - key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', - alias: INTEGRATION_ENVS_ALIAS.ANONKEY, - type: 'encrypted', - }, - { - key: 'SUPABASE_SERVICE_ROLE_KEY', - alias: INTEGRATION_ENVS_ALIAS.SERVICEKEY, - type: 'encrypted', - }, - ], - }, - { - id: 'supabase-todo-list', - name: 'Todo List Database', - template: QuickStart.find((x) => x.title == 'Todo List'), - envs: [ - { - key: 'NEXT_PUBLIC_SUPABASE_URL', - alias: INTEGRATION_ENVS_ALIAS.ENDPOINT, - type: 'encrypted', - }, - { - key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', - alias: INTEGRATION_ENVS_ALIAS.ANONKEY, - type: 'encrypted', - }, - ], - }, - { - id: 'nextjs-initial-project', - name: 'New Empty Database', - template: { - sql: '', - }, - envs: [ - { - key: 'NEXT_PUBLIC_SUPABASE_URL', - alias: INTEGRATION_ENVS_ALIAS.ENDPOINT, - type: 'encrypted', - }, - { - key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', - alias: INTEGRATION_ENVS_ALIAS.ANONKEY, - type: 'encrypted', - }, - { - key: 'SUPABASE_SERVICE_ROLE_KEY', - alias: INTEGRATION_ENVS_ALIAS.SERVICEKEY, - type: 'encrypted', - }, - { - key: 'SUPABASE_DB_HOST', - alias: INTEGRATION_ENVS_ALIAS.DBHOST, - type: 'encrypted', - }, - { - key: 'SUPABASE_DB_PASSWORD', - alias: INTEGRATION_ENVS_ALIAS.DBPASSWORD, - type: 'encrypted', - }, - ], - }, - { - id: 'nextjs-user-management', - name: 'User Management Starter', - template: QuickStart.find((x) => x.title == 'User Management Starter'), - envs: [ - { - key: 'NEXT_PUBLIC_SUPABASE_URL', - alias: INTEGRATION_ENVS_ALIAS.ENDPOINT, - type: 'encrypted', - }, - { - key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', - alias: INTEGRATION_ENVS_ALIAS.ANONKEY, - type: 'encrypted', - }, - { - key: 'SUPABASE_SERVICE_ROLE_KEY', - alias: INTEGRATION_ENVS_ALIAS.SERVICEKEY, - type: 'encrypted', - }, - ], - }, - { - id: 'threaded-comments', - name: 'Threaded Comments', - template: { - sql: ` --- Create a table for Public Profiles -create table profiles ( - id uuid references auth.users not null, - updated_at timestamp with time zone, - username text unique, - full_name text, - avatar_url text, - website text, - - primary key (id), - unique (username), - constraint username_length check (char_length(username) >= 3) -); - -alter table profiles enable row level security; - -create policy "Public profiles are viewable by everyone." - on profiles for select - using ( true ); - -create policy "Users can insert their own profile." - on profiles for insert - with check ( (select auth.uid()) = id ); - -create policy "Users can update own profile." - on profiles for update - using ( (select auth.uid()) = id ); - --- Create a trigger to sync profiles and auth.users -create function public.handle_new_user() -returns trigger -set search_path = '' -as $$ -begin - insert into public.profiles (id, full_name, avatar_url) - values (new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url'); - return new; -end; -$$ language plpgsql security definer; - -create trigger on_auth_user_created - after insert on auth.users - for each row execute procedure public.handle_new_user(); - - --- Create a table for posts -create table posts ( - id bigserial not null, - slug text not null unique, - "createdAt" timestamp with time zone default now() not null, - "updatedAt" timestamp with time zone default now() null, - title text null, - content text null, - "isPublished" boolean default false not null, - "authorId" uuid not null references profiles (id), - "parentId" bigint null references posts (id), - live boolean default true null, - "siteId" bigint default '1' ::bigint not null, - "isPinned" boolean default false not null, - "isDeleted" boolean default false not null, - "isApproved" boolean default false not null, - - primary key (id), - unique (slug) -); - -alter table posts enable row level security; - --- Create root user and initial post. Triggers update in profiles. -insert into auth.users (id) values ('00000000-0000-0000-0000-000000000000'::uuid); - -update profiles -set - full_name = 'Admin', - avatar_url = 'https://assets3.thrillist.com/v1/image/1875552/414x310/crop;jpeg_quality=65.jpg' -where - id = '00000000-0000-0000-0000-000000000000'::uuid; - -insert into posts (slug, title, content, "authorId") values ('root', 'Root post', 'root post', '00000000-0000-0000-0000-000000000000'::uuid); -insert into posts (slug, title, content, "authorId", "parentId") values ('threaded-comments-123', 'threaded-comments', 'Threaded comments, built on top of Supabase and Next.js. Visit GitHub to deploy your own: https://github.com/lawrencecchen/threaded-comments', '00000000-0000-0000-0000-000000000000'::uuid, 1); - -create policy "Posts are viewable by everyone." - on posts for select - using ( true ); - -create policy "Users can post as themselves." - on posts for insert - with check ( (select auth.uid()) = "authorId" ); - --- Create a table for sites -create table sites ( - id bigserial not null, - "siteDomain" text not null, - "ownerId" uuid not null, - "name" text not null, - - primary key (id) -); - -alter table sites enable row level security; - -create policy "Sites are viewable by everyone." - on sites for select - using ( true ); - -create policy "Users can create their own sites." - on sites for insert - with check ( (select auth.uid()) = "ownerId" ); - --- Create a table for votes -create table votes ( - "postId" bigint not null references posts (id), - "userId" uuid not null references profiles (id), - "value" int not null, - - primary key ("postId", "userId"), - constraint vote_quantity check (value <= 1 and value >= -1) -); - -alter table votes enable row level security; - -create policy "Votes are viewable by everyone" - on votes for select - using ( true ); - -create policy "Users can vote as themselves" - on votes for insert - with check ((select auth.uid()) = "userId"); - -create policy "Users can update their own votes" - on votes for update - using ( (select auth.uid()) = "userId" ); - - --- Set up Realtime! -begin; - drop publication if exists supabase_realtime; - create publication supabase_realtime; -commit; -alter publication supabase_realtime add table posts, sites, votes, profiles; - --- Set up Storage! -insert into storage.buckets (id, name) -values ('avatars', 'avatars'); - -create policy "Avatar images are publicly accessible." - on storage.objects for select - using ( bucket_id = 'avatars' ); - -create policy "Anyone can upload an avatar." - on storage.objects for insert - with check ( bucket_id = 'avatars' ); - -drop view if exists comments_thread_with_user_vote; -drop view if exists comments_thread; -drop view if exists comments_with_author_votes; -drop view if exists comments_linear_view; -drop view if exists comment_with_author; - -create view comment_with_author as - select - p.id, - p.slug, - p."createdAt", - p."updatedAt", - p.title, - p.content, - p."isPublished", - p."authorId", - p."parentId", - p.live, - p."siteId", - p."isPinned", - p."isDeleted", - p."isApproved", - to_jsonb(u) as author - from - posts p - inner join profiles u on p."authorId" = u.id; - -create view comments_linear_view as - select - root_c.*, - to_jsonb(parent_c) as parent, - coalesce(json_agg(children_c) filter (where children_c.id is not null), '[]') as responses - from - comment_with_author root_c - inner join comment_with_author parent_c on root_c."parentId" = parent_c.id - inner join sites s1 on s1.id = root_c."siteId" - left join comment_with_author children_c on children_c."parentId" = root_c.id - group by - root_c.id, - root_c.slug, - root_c."createdAt", - root_c."updatedAt", - root_c.title, - root_c.content, - root_c."isPublished", - root_c."authorId", - root_c."parentId", - root_c.live, - root_c."siteId", - root_c."isPinned", - root_c."isDeleted", - root_c."isApproved", - root_c.author, - parent_c.*; - -create or replace view comments_with_author_votes as - select - p.id, - p.slug, - p."createdAt", - p."updatedAt", - p.title, - p.content, - p."isPublished", - p."authorId", - p."parentId", - p.live, - p."siteId", - p."isPinned", - p."isDeleted", - p."isApproved", - p."author", - coalesce ( - sum (v.value) over w, - 0 - ) as "votes", - sum (case when v.value > 0 then 1 else 0 end) over w as "upvotes", - sum (case when v.value < 0 then 1 else 0 end) over w as "downvotes" - -- (select case when auth.uid() = v."userId" then v.value else 0 end) as "userVoteValue" - from - comment_with_author p - left join votes v on p.id = v."postId" - window w as ( - partition by v."postId" - ); - -create recursive view comments_thread ( - id, - slug, - "createdAt", - "updatedAt", - title, - content, - "isPublished", - "authorId", - "parentId", - live, - "siteId", - "isPinned", - "isDeleted", - "isApproved", - "author", - "votes", - "upvotes", - "downvotes", - "depth", - "path", - "pathVotesRecent", - "pathLeastRecent", - "pathMostRecent" -) as - select - id, - slug, - "createdAt", - "updatedAt", - title, - content, - "isPublished", - "authorId", - "parentId", - live, - "siteId", - "isPinned", - "isDeleted", - "isApproved", - "author", - "votes", - "upvotes", - "downvotes", - 0 as depth, - array[id] as "path", - array[id] as "pathVotesRecent", - array[id] as "pathLeastRecent", - array[id] as "pathMostRecent" - from - comments_with_author_votes - where - "parentId" is null - union all - select - p1.id, - p1.slug, - p1."createdAt", - p1."updatedAt", - p1.title, - p1.content, - p1."isPublished", - p1."authorId", - p1."parentId", - p1.live, - p1."siteId", - p1."isPinned", - p1."isDeleted", - p1."isApproved", - p1."author", - p1."votes", - p1."upvotes", - p1."downvotes", - p2.depth + 1 as depth, - p2."path" || p1.id::bigint as "path", - p2."pathVotesRecent" || -p1."votes"::bigint || -extract(epoch from p1."createdAt")::bigint || p1.id as "pathVotesRecent", - p2."pathLeastRecent" || extract(epoch from p1."createdAt")::bigint || p1.id as "pathLeastRecent", - p2."pathMostRecent" || -extract(epoch from p1."createdAt")::bigint || p1.id as "pathMostRecent" - from - comments_with_author_votes p1 - join comments_thread p2 on p1."parentId" = p2.id; - -create or replace view comments_thread_with_user_vote as - select distinct on (id) - id, - slug, - "createdAt", - "updatedAt", - title, - content, - "isPublished", - "authorId", - "parentId", - live, - "siteId", - "isPinned", - "isDeleted", - "isApproved", - "author", - "votes", - "upvotes", - "downvotes", - "depth", - "path", - "pathVotesRecent", - "pathLeastRecent", - "pathMostRecent", - coalesce( - ( - select - v."value" - from - votes v - where - auth.uid() = v."userId" and v."postId" = id - ), - 0 - ) as "userVoteValue" - from comments_thread; - `, - }, - envs: [ - { - key: 'NEXT_PUBLIC_SUPABASE_URL', - alias: INTEGRATION_ENVS_ALIAS.ENDPOINT, - type: 'encrypted', - }, - { - key: 'NEXT_PUBLIC_SUPABASE_ANON_KEY', - alias: INTEGRATION_ENVS_ALIAS.ANONKEY, - type: 'encrypted', - }, - { - key: 'SUPABASE_SERVICE_ROLE_KEY', - alias: INTEGRATION_ENVS_ALIAS.SERVICEKEY, - type: 'encrypted', - }, - ], - }, -] diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index c20e9860915..a0b80c406bb 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -506,11 +506,19 @@ const nextConfig = { }, { source: '/img/:slug*', - headers: [{ key: 'cache-control', value: 'max-age=2592000' }], + headers: [{ key: 'cache-control', value: 'public, max-age=2592000' }], + }, + { + source: '/css/fonts.css', + headers: [{ key: 'cache-control', value: 'public, max-age=86400' }], }, { source: '/fonts/:slug*', - headers: [{ key: 'cache-control', value: 'max-age=2592000' }], + headers: [{ key: 'cache-control', value: 'public, max-age=2592000' }], + }, + { + source: '/favicon/:slug*', + headers: [{ key: 'cache-control', value: 'public, max-age=86400' }], }, ] }, diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 308771daa3d..9560ed5aaab 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -32,11 +32,12 @@ import { ErrorBoundary } from 'react-error-boundary' import { FeatureFlagProvider, PageTelemetry, ThemeProvider, useThemeSandbox } from 'common' import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router' -import { AppBannerWrapper, RouteValidationWrapper } from 'components/interfaces/App' +import { RouteValidationWrapper } from 'components/interfaces/App' import { AppBannerContextProvider } from 'components/interfaces/App/AppBannerWrapperContext' import { StudioCommandMenu } from 'components/interfaces/App/CommandMenu' import { FeaturePreviewContextProvider } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' +import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvider' import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' import { ErrorBoundaryState } from 'components/ui/ErrorBoundaryState' import GroupsTelemetry from 'components/ui/GroupsTelemetry' @@ -123,15 +124,14 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { > - - - {getLayout()} - - - - - + + {getLayout()} + + + + + diff --git a/apps/studio/pages/api/ai/sql/tools.ts b/apps/studio/pages/api/ai/sql/tools.ts index 397d1d01d51..d3b212ce83a 100644 --- a/apps/studio/pages/api/ai/sql/tools.ts +++ b/apps/studio/pages/api/ai/sql/tools.ts @@ -391,7 +391,6 @@ export const getTools = ({ description: 'Get knowledge about how to write edge functions for Supabase', parameters: z.object({}), execute: async ({}) => { - console.log('schemas', 'edge function called...') return stripIndent` # Writing Supabase Edge Functions @@ -400,27 +399,28 @@ export const getTools = ({ ## Guidelines 1. Try to use Web APIs and Deno's core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws) - 2. If you are reusing utility methods between Edge Functions, add them to \`supabase/functions/_shared\` and import using a relative path. Do NOT have cross dependencies between Edge Functions. - 3. Do NOT use bare specifiers when importing dependecnies. If you need to use an external dependency, make sure it's prefixed with either \`npm:\` or \`jsr:\`. For example, \`@supabase/supabase-js\` should be written as \`npm:@supabase/supabase-js\`. - 4. For external imports, always define a version. For example, \`npm:@express\` should be written as \`npm:express@4.18.2\`. - 5. For external dependencies, importing via \`npm:\` and \`jsr:\` is preferred. Minimize the use of imports from @\`deno.land/x\` , \`esm.sh\` and @\`unpkg.com\` . If you have a package from one of those CDNs, you can replace the CDN hostname with \`npm:\` specifier. - 6. You can also use Node built-in APIs. You will need to import them using \`node:\` specifier. For example, to import Node process: \`import process from "node:process"\`. Use Node APIs when you find gaps in Deno APIs. - 7. Do NOT use \`import { serve } from "https://deno.land/std@0.168.0/http/server.ts"\`. Instead use the built-in \`Deno.serve\`. - 8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them: + 2. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either \`npm:\` or \`jsr:\`. For example, \`@supabase/supabase-js\` should be written as \`npm:@supabase/supabase-js\`. + 3. For external imports, always define a version. For example, \`npm:@express\` should be written as \`npm:express@4.18.2\`. + 4. For external dependencies, importing via \`npm:\` and \`jsr:\` is preferred. Minimize the use of imports from @\`deno.land/x\` , \`esm.sh\` and @\`unpkg.com\` . If you have a package from one of those CDNs, you can replace the CDN hostname with \`npm:\` specifier. + 5. You can also use Node built-in APIs. You will need to import them using \`node:\` specifier. For example, to import Node process: \`import process from "node:process"\`. Use Node APIs when you find gaps in Deno APIs. + 6. Do NOT use \`import { serve } from "https://deno.land/std@0.168.0/http/server.ts"\`. Instead use the built-in \`Deno.serve\`. + 7. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them: * SUPABASE_URL * SUPABASE_ANON_KEY * SUPABASE_SERVICE_ROLE_KEY * SUPABASE_DB_URL - 9. To set other environment variables (ie. secrets) users can put them in a env file and run the \`supabase secrets set --env-file path/to/env-file\` - 10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with \`/function-name\` so they are routed correctly. - 11. File write operations are ONLY permitted on \`/tmp\` directory. You can use either Deno or Node File APIs. - 12. Use \`EdgeRuntime.waitUntil(promise)\` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context. + 8. To set other environment variables the user can go to project settings then edge functions to set them + 9. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with \`/function-name\` so they are routed correctly. + 10. File write operations are ONLY permitted on \`/tmp\` directory. You can use either Deno or Node File APIs. + 11. Use \`EdgeRuntime.waitUntil(promise)\` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context. ## Example Templates ### Simple Hello World Function \`\`\`edge + // Setup type definitions for built-in Supabase Runtime APIs + import "jsr:@supabase/functions-js/edge-runtime.d.ts"; interface reqPayload { name: string; } @@ -443,6 +443,8 @@ export const getTools = ({ ### Example Function using Node built-in API \`\`\`edge + // Setup type definitions for built-in Supabase Runtime APIs + import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import process from "node:process"; @@ -466,6 +468,8 @@ export const getTools = ({ ### Using npm packages in Functions \`\`\`edge + // Setup type definitions for built-in Supabase Runtime APIs + import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import express from "npm:express@4.18.2"; const app = express(); @@ -480,6 +484,8 @@ export const getTools = ({ ### Generate embeddings using built-in @Supabase.ai API \`\`\`edge + // Setup type definitions for built-in Supabase Runtime APIs + import "jsr:@supabase/functions-js/edge-runtime.d.ts"; const model = new Supabase.ai.Session('gte-small'); Deno.serve(async (req: Request) => { @@ -501,6 +507,8 @@ export const getTools = ({ ## Integrating with Supabase Auth \`\`\`edge + // Setup type definitions for built-in Supabase Runtime APIs + import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from \\'jsr:@supabase/supabase-js@2\\' import { corsHeaders } from \\'../_shared/cors.ts\\' @@ -516,9 +524,9 @@ export const getTools = ({ // Create a Supabase client with the Auth context of the logged in user. const supabaseClient = createClient( // Supabase API URL - env var exported by default. - Deno.env.get(\\'SUPABASE_URL\\') ?? \\'\\', + Deno.env.get('SUPABASE_URL')!, // Supabase API ANON KEY - env var exported by default. - Deno.env.get(\\'SUPABASE_ANON_KEY\\') ?? \\'\\', + Deno.env.get('SUPABASE_ANON_KEY')!, // Create client with Auth context of the user that called the function. // This way your row-level-security (RLS) policies are applied. { diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index e122648eb46..5d3d5e2a23e 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -5,6 +5,7 @@ import { ChangeEvent, useRef, useState } from 'react' import { toast } from 'sonner' import { Alert, Button, Checkbox, Input, Listbox } from 'ui' +import { isVercelUrl } from 'components/interfaces/Integrations/Vercel/VercelIntegration.utils' import { Markdown } from 'components/interfaces/Markdown' import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' import { ScaffoldColumn, ScaffoldContainer } from 'components/layouts/Scaffold' @@ -200,7 +201,7 @@ const CreateProject = () => { snapshot.setLoading(false) - if (next) { + if (next && isVercelUrl(next)) { window.location.href = next } }, diff --git a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx index 640aea4062b..5bd457b5e20 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Vercel/Integrations-Vercel.constants' +import { isVercelUrl } from 'components/interfaces/Integrations/Vercel/VercelIntegration.utils' import ProjectLinker, { ForeignProject, } from 'components/interfaces/Integrations/VercelGithub/ProjectLinker' @@ -26,7 +27,7 @@ const VERCEL_ICON = ( ) const VercelIntegration: NextPageWithLayout = () => { - const { slug, configurationId, next, organizationSlug } = useParams() + const { slug, configurationId, next } = useParams() /** * Fetch the list of organization based integration installations for Vercel. @@ -106,7 +107,7 @@ const VercelIntegration: NextPageWithLayout = () => { const { mutate: createConnections, isLoading: isCreatingConnection } = useIntegrationVercelConnectionsCreateMutation({ onSuccess() { - if (next) { + if (next && isVercelUrl(next)) { snapshot.setLoading(false) window.location.href = next } @@ -164,7 +165,7 @@ This Supabase integration manages your environment variables automatically to pr getForeignProjectIcon={getForeignProjectIcon} choosePrompt="Choose Vercel Project" onSkip={() => { - if (next) { + if (next && isVercelUrl(next)) { window.location.href = next } }} diff --git a/apps/studio/pages/project/[ref]/advisors/performance.tsx b/apps/studio/pages/project/[ref]/advisors/performance.tsx index 9f5e33c643c..f9ad3a6ecca 100644 --- a/apps/studio/pages/project/[ref]/advisors/performance.tsx +++ b/apps/studio/pages/project/[ref]/advisors/performance.tsx @@ -13,6 +13,7 @@ import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' import { LoadingLine } from 'ui' +import DefaultLayout from 'components/layouts/DefaultLayout' const ProjectLints: NextPageWithLayout = () => { const project = useSelectedProject() @@ -94,6 +95,10 @@ const ProjectLints: NextPageWithLayout = () => { ) } -ProjectLints.getLayout = (page) => {page} +ProjectLints.getLayout = (page) => ( + + {page} + +) export default ProjectLints diff --git a/apps/studio/pages/project/[ref]/advisors/query-performance.tsx b/apps/studio/pages/project/[ref]/advisors/query-performance.tsx index 6ec244ce9b1..be76267a2f2 100644 --- a/apps/studio/pages/project/[ref]/advisors/query-performance.tsx +++ b/apps/studio/pages/project/[ref]/advisors/query-performance.tsx @@ -14,6 +14,7 @@ import AdvisorsLayout from 'components/layouts/AdvisorsLayout/AdvisorsLayout' import DatabaseSelector from 'components/ui/DatabaseSelector' import { FormHeader } from 'components/ui/Forms/FormHeader' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const PRESET_MAP = { [QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING]: 'mostTimeConsuming', @@ -58,7 +59,9 @@ const QueryPerformanceReport: NextPageWithLayout = () => { } QueryPerformanceReport.getLayout = (page) => ( - {page} + + {page} + ) export default QueryPerformanceReport diff --git a/apps/studio/pages/project/[ref]/advisors/security.tsx b/apps/studio/pages/project/[ref]/advisors/security.tsx index 588c5cc4ab5..e4051838a5f 100644 --- a/apps/studio/pages/project/[ref]/advisors/security.tsx +++ b/apps/studio/pages/project/[ref]/advisors/security.tsx @@ -13,6 +13,7 @@ import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' import { LoadingLine } from 'ui' +import DefaultLayout from 'components/layouts/DefaultLayout' const ProjectLints: NextPageWithLayout = () => { const project = useSelectedProject() @@ -96,6 +97,10 @@ const ProjectLints: NextPageWithLayout = () => { ) } -ProjectLints.getLayout = (page) => {page} +ProjectLints.getLayout = (page) => ( + + {page} + +) export default ProjectLints diff --git a/apps/studio/pages/project/[ref]/api/index.tsx b/apps/studio/pages/project/[ref]/api/index.tsx index db96b907935..9e34e08a2b7 100644 --- a/apps/studio/pages/project/[ref]/api/index.tsx +++ b/apps/studio/pages/project/[ref]/api/index.tsx @@ -9,12 +9,17 @@ import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query' import { snakeToCamel } from 'lib/helpers' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const PageConfig: NextPageWithLayout = () => { return } -PageConfig.getLayout = (page) => {page} +PageConfig.getLayout = (page) => ( + + {page} + +) export default PageConfig diff --git a/apps/studio/pages/project/[ref]/auth/advanced.tsx b/apps/studio/pages/project/[ref]/auth/advanced.tsx new file mode 100644 index 00000000000..5d39023c395 --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/advanced.tsx @@ -0,0 +1,51 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { AdvancedAuthSettingsForm, SmtpForm, ThirdPartyAuthForm } from 'components/interfaces/Auth' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { + ScaffoldContainer, + ScaffoldDescription, + ScaffoldHeader, + ScaffoldTitle, +} from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import type { NextPageWithLayout } from 'types' + +const PageLayout: NextPageWithLayout = () => { + const isPermissionsLoaded = usePermissionsLoaded() + // TODO: check if these permissions cover third party auth as well + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + + return ( + <> + + + Advanced + + + + {!isPermissionsLoaded ? ( + + ) : !canReadAuthSettings ? ( + + ) : ( + <> + + + )} + + + ) +} + +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) +} +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/hooks.tsx b/apps/studio/pages/project/[ref]/auth/hooks.tsx index 51b3f1f414f..e939b807163 100644 --- a/apps/studio/pages/project/[ref]/auth/hooks.tsx +++ b/apps/studio/pages/project/[ref]/auth/hooks.tsx @@ -2,11 +2,16 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { HooksListing } from 'components/interfaces/Auth/Hooks/HooksListing' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' -import { FormsContainer } from 'components/ui/Forms/FormsContainer' +import { + ScaffoldHeader, + ScaffoldContainer, + ScaffoldTitle, + ScaffoldDescription, +} from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' - +import DefaultLayout from 'components/layouts/DefaultLayout' const Hooks: NextPageWithLayout = () => { const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') const isPermissionsLoaded = usePermissionsLoaded() @@ -15,15 +20,29 @@ const Hooks: NextPageWithLayout = () => { return } else { return ( - - - +
    + + + Auth Hooks + + Use Postgres functions or HTTP endpoints to customize the behavior of Supabase Auth to + meet your needs + + + + + + + +
    ) } } -Hooks.getLayout = (page) => { - return {page} -} +Hooks.getLayout = (page) => ( + + {page} + +) export default Hooks diff --git a/apps/studio/pages/project/[ref]/auth/index.tsx b/apps/studio/pages/project/[ref]/auth/index.tsx index 4e05c781762..30ffe3662c9 100644 --- a/apps/studio/pages/project/[ref]/auth/index.tsx +++ b/apps/studio/pages/project/[ref]/auth/index.tsx @@ -1,10 +1,15 @@ import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' const Authentication: NextPageWithLayout = () => { return <>{/*

    Use this as a template for authentication pages

    */} } -Authentication.getLayout = (page) => {page} +Authentication.getLayout = (page) => ( + + {page} + +) export default Authentication diff --git a/apps/studio/pages/project/[ref]/auth/mfa.tsx b/apps/studio/pages/project/[ref]/auth/mfa.tsx new file mode 100644 index 00000000000..8bdcd91c193 --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/mfa.tsx @@ -0,0 +1,54 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { MfaAuthSettingsForm } from 'components/interfaces/Auth' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { + ScaffoldContainer, + ScaffoldDescription, + ScaffoldHeader, + ScaffoldTitle, +} from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import type { NextPageWithLayout } from 'types' + +const PageLayout: NextPageWithLayout = () => { + const isPermissionsLoaded = usePermissionsLoaded() + // TODO: check if these permissions cover third party auth as well + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + + return ( + <> + + + Multi-Factor Authentication (MFA) + + Requires users to provide additional verification factors to authenticate + + + + + {!isPermissionsLoaded ? ( + + ) : !canReadAuthSettings ? ( + + ) : ( + <> + + + )} + + + ) +} + +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) +} +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index bf56fbe8f6f..4e52ee86078 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -21,6 +21,7 @@ import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import type { NextPageWithLayout } from 'types' import { Input } from 'ui' +import DefaultLayout from 'components/layouts/DefaultLayout' /** * Filter tables by table name and policy name @@ -174,9 +175,11 @@ const AuthPoliciesPage: NextPageWithLayout = () => { } AuthPoliciesPage.getLayout = (page) => ( - -
    {page}
    -
    + + +
    {page}
    +
    +
    ) export default AuthPoliciesPage diff --git a/apps/studio/pages/project/[ref]/auth/protection.tsx b/apps/studio/pages/project/[ref]/auth/protection.tsx new file mode 100644 index 00000000000..6f7bbddef9d --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/protection.tsx @@ -0,0 +1,45 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { + AuthProvidersForm, + ProtectionAuthSettingsForm, + ThirdPartyAuthForm, +} from 'components/interfaces/Auth' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { ScaffoldHeader, ScaffoldContainer, ScaffoldTitle } from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import type { NextPageWithLayout } from 'types' + +const PageLayout: NextPageWithLayout = () => { + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + const isPermissionsLoaded = usePermissionsLoaded() + + if (isPermissionsLoaded && !canReadAuthSettings) { + return + } else { + return ( +
    + + + Attack Protection + + + + + + +
    + ) + } +} + +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) +} +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/providers.tsx b/apps/studio/pages/project/[ref]/auth/providers.tsx index 0d43687ae8e..63c5d4e6cd6 100644 --- a/apps/studio/pages/project/[ref]/auth/providers.tsx +++ b/apps/studio/pages/project/[ref]/auth/providers.tsx @@ -1,29 +1,80 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { AuthProvidersForm } from 'components/interfaces/Auth' +import { useParams } from 'common' +import { AuthProvidersForm, BasicAuthSettingsForm } from 'components/interfaces/Auth' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' -import { FormsContainer } from 'components/ui/Forms/FormsContainer' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { + ScaffoldContainer, + ScaffoldDescription, + ScaffoldDivider, + ScaffoldHeader, + ScaffoldTitle, +} from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { useCurrentPath } from 'hooks/misc/useCurrentPath' +import Link from 'next/link' import type { NextPageWithLayout } from 'types' +import { NavMenu, NavMenuItem } from 'ui' const PageLayout: NextPageWithLayout = () => { const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') const isPermissionsLoaded = usePermissionsLoaded() + const { ref } = useParams() + const currentPath = useCurrentPath() + + const navMenuItems = [ + { + label: 'Supabase Auth', + href: `/project/${ref}/auth/providers`, + }, + { + label: 'Third Party Auth', + href: `/project/${ref}/auth/third-party`, + }, + ] if (isPermissionsLoaded && !canReadAuthSettings) { return - } else { - return ( - - - - ) } + + return ( +
    + + + Sign In / Up + + Configure authentication providers and login methods for your users + + + {navMenuItems.map((item) => ( + + {item.label} + + ))} + + + + + + + + + + +
    + ) } PageLayout.getLayout = (page) => { - return {page} + return ( + + {page} + + ) } export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/rate-limits.tsx b/apps/studio/pages/project/[ref]/auth/rate-limits.tsx index adbe75a115f..2043aec5ba0 100644 --- a/apps/studio/pages/project/[ref]/auth/rate-limits.tsx +++ b/apps/studio/pages/project/[ref]/auth/rate-limits.tsx @@ -2,10 +2,17 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import RateLimits from 'components/interfaces/Auth/RateLimits/RateLimits' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' -import { FormsContainer } from 'components/ui/Forms/FormsContainer' +import { + ScaffoldHeader, + ScaffoldContainer, + ScaffoldDescription, + ScaffoldTitle, +} from 'components/layouts/Scaffold' +import { DocsButton } from 'components/ui/DocsButton' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const PageLayout: NextPageWithLayout = () => { const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') @@ -15,15 +22,33 @@ const PageLayout: NextPageWithLayout = () => { return } else { return ( - - - +
    + + +
    + Rate Limits + + Safeguard against bursts of incoming traffic to prevent abuse and maximize stability + +
    + +
    +
    + + + + +
    ) } } PageLayout.getLayout = (page) => { - return {page} + return ( + + {page} + + ) } export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/sessions.tsx b/apps/studio/pages/project/[ref]/auth/sessions.tsx new file mode 100644 index 00000000000..7dd24438315 --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/sessions.tsx @@ -0,0 +1,44 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { SessionsAuthSettingsForm, SmtpForm, ThirdPartyAuthForm } from 'components/interfaces/Auth' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import type { NextPageWithLayout } from 'types' + +const PageLayout: NextPageWithLayout = () => { + const isPermissionsLoaded = usePermissionsLoaded() + // TODO: check if these permissions cover third party auth as well + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + + return ( + <> + + + Sessions + + + + {!isPermissionsLoaded ? ( + + ) : !canReadAuthSettings ? ( + + ) : ( + + )} + + + ) +} + +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) +} +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/smtp.tsx b/apps/studio/pages/project/[ref]/auth/smtp.tsx new file mode 100644 index 00000000000..51fe769af7c --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/smtp.tsx @@ -0,0 +1,79 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { SmtpForm } from 'components/interfaces/Auth' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { useParams } from 'common' +import { useCurrentPath } from 'hooks/misc/useCurrentPath' +import Link from 'next/link' +import { NavMenu, NavMenuItem } from 'ui' +import { + ScaffoldContainer, + ScaffoldDescription, + ScaffoldDivider, + ScaffoldHeader, + ScaffoldTitle, +} from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' + +const PageLayout: NextPageWithLayout = () => { + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + const isPermissionsLoaded = usePermissionsLoaded() + const { ref } = useParams() + const currentPath = useCurrentPath() + + const navMenuItems = [ + { + label: 'Templates', + href: `/project/${ref}/auth/templates`, + }, + { + label: 'SMTP Settings', + href: `/project/${ref}/auth/smtp`, + }, + ] + + if (isPermissionsLoaded && !canReadAuthSettings) { + return + } + + return ( +
    + + + Emails + + Configure what emails your users receive and how they are sent + + + {navMenuItems.map((item) => ( + + {item.label} + + ))} + + + + + + + + + +
    + ) +} + +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) +} +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/templates.tsx b/apps/studio/pages/project/[ref]/auth/templates.tsx index 2ea86a103fe..a9a136d4d2a 100644 --- a/apps/studio/pages/project/[ref]/auth/templates.tsx +++ b/apps/studio/pages/project/[ref]/auth/templates.tsx @@ -1,29 +1,76 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - +import { useParams } from 'common' import { EmailTemplates } from 'components/interfaces/Auth' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' -import { FormsContainer } from 'components/ui/Forms/FormsContainer' -import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { useCurrentPath } from 'hooks/misc/useCurrentPath' +import Link from 'next/link' +import { NavMenu, NavMenuItem } from 'ui' +import { + ScaffoldContainer, + ScaffoldDescription, + ScaffoldDivider, + ScaffoldHeader, + ScaffoldTitle, +} from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' import type { NextPageWithLayout } from 'types' - +import DefaultLayout from 'components/layouts/DefaultLayout' const PageLayout: NextPageWithLayout = () => { const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') const isPermissionsLoaded = usePermissionsLoaded() + const { ref } = useParams() + const currentPath = useCurrentPath() + + const navMenuItems = [ + { + label: 'Templates', + href: `/project/${ref}/auth/templates`, + }, + { + label: 'SMTP Settings', + href: `/project/${ref}/auth/smtp`, + }, + ] if (isPermissionsLoaded && !canReadAuthSettings) { return - } else { - return ( - - - - ) } + + return ( +
    + + + Emails + + Configure what emails your users receive and how they are sent + + + {navMenuItems.map((item) => ( + + {item.label} + + ))} + + + + + + + + + +
    + ) } -PageLayout.getLayout = (page) => { - return {page} -} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/third-party.tsx b/apps/studio/pages/project/[ref]/auth/third-party.tsx new file mode 100644 index 00000000000..f48b3eabf6a --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/third-party.tsx @@ -0,0 +1,79 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { ThirdPartyAuthForm } from 'components/interfaces/Auth' +import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { useParams } from 'common' +import { useCurrentPath } from 'hooks/misc/useCurrentPath' +import Link from 'next/link' +import { NavMenu, NavMenuItem } from 'ui' +import { + ScaffoldContainer, + ScaffoldDescription, + ScaffoldDivider, + ScaffoldHeader, + ScaffoldTitle, +} from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' + +const PageLayout: NextPageWithLayout = () => { + const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') + const isPermissionsLoaded = usePermissionsLoaded() + const { ref } = useParams() + const currentPath = useCurrentPath() + + const navMenuItems = [ + { + label: 'Supabase auth', + href: `/project/${ref}/auth/providers`, + }, + { + label: 'Third Party auth', + href: `/project/${ref}/auth/third-party`, + }, + ] + + if (isPermissionsLoaded && !canReadAuthSettings) { + return + } + + return ( + <> + + + Sign in / up + + Configure authentication providers and login methods for your users + + + {navMenuItems.map((item) => ( + + {item.label} + + ))} + + + + + + + + + + + ) +} + +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) +} +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/url-configuration.tsx b/apps/studio/pages/project/[ref]/auth/url-configuration.tsx index 36aae336e66..0eb6ebb8f4b 100644 --- a/apps/studio/pages/project/[ref]/auth/url-configuration.tsx +++ b/apps/studio/pages/project/[ref]/auth/url-configuration.tsx @@ -3,11 +3,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { RedirectUrls } from 'components/interfaces/Auth/RedirectUrls/RedirectUrls' import SiteUrl from 'components/interfaces/Auth/SiteUrl/SiteUrl' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' import { FormsContainer } from 'components/ui/Forms/FormsContainer' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' - +import DefaultLayout from 'components/layouts/DefaultLayout' const URLConfiguration: NextPageWithLayout = () => { const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') const isPermissionsLoaded = usePermissionsLoaded() @@ -16,16 +17,26 @@ const URLConfiguration: NextPageWithLayout = () => { return } else { return ( - - - - +
    + + + URL Configuration + + + + + + + +
    ) } } -URLConfiguration.getLayout = (page) => { - return {page} -} +URLConfiguration.getLayout = (page) => ( + + {page} + +) export default URLConfiguration diff --git a/apps/studio/pages/project/[ref]/auth/users.tsx b/apps/studio/pages/project/[ref]/auth/users.tsx index 8093df24dd6..6fe46239939 100644 --- a/apps/studio/pages/project/[ref]/auth/users.tsx +++ b/apps/studio/pages/project/[ref]/auth/users.tsx @@ -1,11 +1,16 @@ import { UsersV2 } from 'components/interfaces/Auth/Users/UsersV2' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' const UsersPage: NextPageWithLayout = () => { return } -UsersPage.getLayout = (page) => {page} +UsersPage.getLayout = (page) => ( + + {page} + +) export default UsersPage diff --git a/apps/studio/pages/project/[ref]/branches.tsx b/apps/studio/pages/project/[ref]/branches.tsx index 252de784cdf..355bee32e2a 100644 --- a/apps/studio/pages/project/[ref]/branches.tsx +++ b/apps/studio/pages/project/[ref]/branches.tsx @@ -1,11 +1,16 @@ import BranchManagement from 'components/interfaces/BranchManagement/BranchManagement' import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' const BranchManagementPage: NextPageWithLayout = () => { return } -BranchManagementPage.getLayout = (page) => {page} +BranchManagementPage.getLayout = (page) => ( + + {page} + +) export default BranchManagementPage diff --git a/apps/studio/pages/project/[ref]/building.tsx b/apps/studio/pages/project/[ref]/building.tsx index e1e600e0089..907c2777980 100644 --- a/apps/studio/pages/project/[ref]/building.tsx +++ b/apps/studio/pages/project/[ref]/building.tsx @@ -1,4 +1,5 @@ import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { useRouter } from 'next/router' import { useEffect } from 'react' import type { NextPageWithLayout } from 'types' @@ -8,7 +9,9 @@ const ProjectBuildingPage: NextPageWithLayout = () => { } ProjectBuildingPage.getLayout = (page) => ( - {page} + + {page} + ) export default ProjectBuildingPage diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index e6db1c7bde4..2a38ea3c293 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -22,6 +22,7 @@ import { PROJECT_STATUS } from 'lib/constants' import type { NextPageWithLayout } from 'types' import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui' import { Admonition } from 'ui-patterns' +import DefaultLayout from 'components/layouts/DefaultLayout' const DatabasePhysicalBackups: NextPageWithLayout = () => { return ( @@ -42,7 +43,9 @@ const DatabasePhysicalBackups: NextPageWithLayout = () => { } DatabasePhysicalBackups.getLayout = (page) => ( - {page} + + {page} + ) const PITR = () => { @@ -86,6 +89,7 @@ const PITR = () => { {!isEnabled ? ( { return ( @@ -54,7 +55,9 @@ const RestoreToNewProjectPage: NextPageWithLayout = () => { } RestoreToNewProjectPage.getLayout = (page) => ( - {page} + + {page} + ) const RestoreToNewProject = () => { @@ -192,6 +195,7 @@ const RestoreToNewProject = () => { return ( diff --git a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx index 46f2c0d8761..276542a60b4 100644 --- a/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -17,6 +17,7 @@ import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPe import { useIsOrioleDb } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' import { Admonition } from 'ui-patterns' +import DefaultLayout from 'components/layouts/DefaultLayout' const DatabaseScheduledBackups: NextPageWithLayout = () => { const { ref: projectRef } = useParams() @@ -102,7 +103,9 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => { } DatabaseScheduledBackups.getLayout = (page) => ( - {page} + + {page} + ) export default DatabaseScheduledBackups diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx index 666008e679f..4111c0b4076 100644 --- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx +++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx @@ -31,6 +31,7 @@ import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useAppStateSnapshot } from 'state/app-state' import type { NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import DefaultLayout from 'components/layouts/DefaultLayout' const EDITABLE_ROLES = ['authenticated', 'anon', 'service_role'] @@ -361,7 +362,9 @@ const PrivilegesPage: NextPageWithLayout = () => { } PrivilegesPage.getLayout = (page) => ( - {page} + + {page} + ) export default PrivilegesPage diff --git a/apps/studio/pages/project/[ref]/database/extensions.tsx b/apps/studio/pages/project/[ref]/database/extensions.tsx index c07dad1d7c8..d1c7a3565f4 100644 --- a/apps/studio/pages/project/[ref]/database/extensions.tsx +++ b/apps/studio/pages/project/[ref]/database/extensions.tsx @@ -2,6 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Extensions } from 'components/interfaces/Database' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' @@ -31,6 +32,10 @@ const DatabaseExtensions: NextPageWithLayout = () => { ) } -DatabaseExtensions.getLayout = (page) => {page} +DatabaseExtensions.getLayout = (page) => ( + + {page} + +) export default DatabaseExtensions diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index 35e1d3b726b..0a4b183bc82 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -10,6 +10,7 @@ import NoPermission from 'components/ui/NoPermission' import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const FunctionsPage: NextPageWithLayout = () => { const [selectedFunction, setSelectedFunction] = useState() @@ -69,6 +70,10 @@ const FunctionsPage: NextPageWithLayout = () => { ) } -FunctionsPage.getLayout = (page) => {page} +FunctionsPage.getLayout = (page) => ( + + {page} + +) export default FunctionsPage diff --git a/apps/studio/pages/project/[ref]/database/index.tsx b/apps/studio/pages/project/[ref]/database/index.tsx index 1c9ade0c442..adbea34ac88 100644 --- a/apps/studio/pages/project/[ref]/database/index.tsx +++ b/apps/studio/pages/project/[ref]/database/index.tsx @@ -1,10 +1,15 @@ import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' const Database: NextPageWithLayout = () => { return <>{/*

    Use this as a page template for database

    */} } -Database.getLayout = (page) => {page} +Database.getLayout = (page) => ( + + {page} + +) export default Database diff --git a/apps/studio/pages/project/[ref]/database/indexes.tsx b/apps/studio/pages/project/[ref]/database/indexes.tsx index 78904d4fb72..b8f936f391f 100644 --- a/apps/studio/pages/project/[ref]/database/indexes.tsx +++ b/apps/studio/pages/project/[ref]/database/indexes.tsx @@ -12,6 +12,7 @@ import { DocsButton } from 'components/ui/DocsButton' import { FormHeader } from 'components/ui/Forms/FormHeader' import type { NextPageWithLayout } from 'types' import { Button } from 'ui' +import DefaultLayout from 'components/layouts/DefaultLayout' const IndexesPage: NextPageWithLayout = () => { return ( @@ -48,6 +49,10 @@ const IndexesPage: NextPageWithLayout = () => { ) } -IndexesPage.getLayout = (page) => {page} +IndexesPage.getLayout = (page) => ( + + {page} + +) export default IndexesPage diff --git a/apps/studio/pages/project/[ref]/database/migrations.tsx b/apps/studio/pages/project/[ref]/database/migrations.tsx index d1e4b56ccb5..6824c121b0e 100644 --- a/apps/studio/pages/project/[ref]/database/migrations.tsx +++ b/apps/studio/pages/project/[ref]/database/migrations.tsx @@ -12,6 +12,7 @@ import { FormHeader } from 'components/ui/Forms/FormHeader' import type { NextPageWithLayout } from 'types' import { Button } from 'ui' import { DocsButton } from 'components/ui/DocsButton' +import DefaultLayout from 'components/layouts/DefaultLayout' const MigrationsPage: NextPageWithLayout = () => { return ( @@ -38,6 +39,10 @@ const MigrationsPage: NextPageWithLayout = () => { ) } -MigrationsPage.getLayout = (page) => {page} +MigrationsPage.getLayout = (page) => ( + + {page} + +) export default MigrationsPage diff --git a/apps/studio/pages/project/[ref]/database/publications.tsx b/apps/studio/pages/project/[ref]/database/publications.tsx index f5dfecfdb82..e153e6e41c3 100644 --- a/apps/studio/pages/project/[ref]/database/publications.tsx +++ b/apps/studio/pages/project/[ref]/database/publications.tsx @@ -10,6 +10,7 @@ import NoPermission from 'components/ui/NoPermission' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' // [Joshen] Technically, best that we have these as separate URLs // makes it easier to manage state, but foresee that this page might @@ -56,6 +57,10 @@ const DatabasePublications: NextPageWithLayout = () => { ) } -DatabasePublications.getLayout = (page) => {page} +DatabasePublications.getLayout = (page) => ( + + {page} + +) export default DatabasePublications diff --git a/apps/studio/pages/project/[ref]/database/roles.tsx b/apps/studio/pages/project/[ref]/database/roles.tsx index f5a38eea1aa..3336d719610 100644 --- a/apps/studio/pages/project/[ref]/database/roles.tsx +++ b/apps/studio/pages/project/[ref]/database/roles.tsx @@ -1,5 +1,6 @@ import { RolesList } from 'components/interfaces/Database' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { FormHeader } from 'components/ui/Forms/FormHeader' import type { NextPageWithLayout } from 'types' @@ -20,6 +21,10 @@ const DatabaseRoles: NextPageWithLayout = () => { ) } -DatabaseRoles.getLayout = (page) => {page} +DatabaseRoles.getLayout = (page) => ( + + {page} + +) export default DatabaseRoles diff --git a/apps/studio/pages/project/[ref]/database/schemas.tsx b/apps/studio/pages/project/[ref]/database/schemas.tsx index b68017a0b0f..d653dd912e8 100644 --- a/apps/studio/pages/project/[ref]/database/schemas.tsx +++ b/apps/studio/pages/project/[ref]/database/schemas.tsx @@ -3,6 +3,7 @@ import { ReactFlowProvider } from 'reactflow' import { SchemaGraph } from 'components/interfaces/Database/Schemas/SchemaGraph' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const SchemasPage: NextPageWithLayout = () => { return ( @@ -14,6 +15,10 @@ const SchemasPage: NextPageWithLayout = () => { ) } -SchemasPage.getLayout = (page) => {page} +SchemasPage.getLayout = (page) => ( + + {page} + +) export default SchemasPage diff --git a/apps/studio/pages/project/[ref]/database/tables/[id].tsx b/apps/studio/pages/project/[ref]/database/tables/[id].tsx index 683523862f5..9d1508ea9e1 100644 --- a/apps/studio/pages/project/[ref]/database/tables/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/tables/[id].tsx @@ -13,6 +13,7 @@ import { isTableLike } from 'data/table-editor/table-editor-types' import { useTableEditorStateSnapshot } from 'state/table-editor' import type { NextPageWithLayout } from 'types' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import DefaultLayout from 'components/layouts/DefaultLayout' const DatabaseTables: NextPageWithLayout = () => { const snap = useTableEditorStateSnapshot() @@ -59,6 +60,10 @@ const DatabaseTables: NextPageWithLayout = () => { ) } -DatabaseTables.getLayout = (page) => {page} +DatabaseTables.getLayout = (page) => ( + + {page} + +) export default DatabaseTables diff --git a/apps/studio/pages/project/[ref]/database/tables/index.tsx b/apps/studio/pages/project/[ref]/database/tables/index.tsx index 139056842b3..6767ddbf54b 100644 --- a/apps/studio/pages/project/[ref]/database/tables/index.tsx +++ b/apps/studio/pages/project/[ref]/database/tables/index.tsx @@ -9,6 +9,7 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { FormHeader } from 'components/ui/Forms/FormHeader' import { useTableEditorStateSnapshot } from 'state/table-editor' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const DatabaseTables: NextPageWithLayout = () => { const snap = useTableEditorStateSnapshot() @@ -45,6 +46,10 @@ const DatabaseTables: NextPageWithLayout = () => { ) } -DatabaseTables.getLayout = (page) => {page} +DatabaseTables.getLayout = (page) => ( + + {page} + +) export default DatabaseTables diff --git a/apps/studio/pages/project/[ref]/database/triggers.tsx b/apps/studio/pages/project/[ref]/database/triggers.tsx index c11b3877c1c..ecb8a6e03f8 100644 --- a/apps/studio/pages/project/[ref]/database/triggers.tsx +++ b/apps/studio/pages/project/[ref]/database/triggers.tsx @@ -9,6 +9,7 @@ import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const TriggersPage: NextPageWithLayout = () => { const [selectedTrigger, setSelectedTrigger] = useState() @@ -69,6 +70,10 @@ const TriggersPage: NextPageWithLayout = () => { ) } -TriggersPage.getLayout = (page) => {page} +TriggersPage.getLayout = (page) => ( + + {page} + +) export default TriggersPage diff --git a/apps/studio/pages/project/[ref]/database/types.tsx b/apps/studio/pages/project/[ref]/database/types.tsx index adb50596465..828b43f373d 100644 --- a/apps/studio/pages/project/[ref]/database/types.tsx +++ b/apps/studio/pages/project/[ref]/database/types.tsx @@ -2,6 +2,7 @@ import { ExternalLink } from 'lucide-react' import { EnumeratedTypes } from 'components/interfaces/Database' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldSection, @@ -33,7 +34,9 @@ const DatabaseEnumeratedTypes: NextPageWithLayout = () => { } DatabaseEnumeratedTypes.getLayout = (page) => ( - {page} + + {page} + ) export default DatabaseEnumeratedTypes diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx index a75964a6aa0..3c7f0a171e8 100644 --- a/apps/studio/pages/project/[ref]/editor/[id].tsx +++ b/apps/studio/pages/project/[ref]/editor/[id].tsx @@ -4,10 +4,8 @@ import { useRouter } from 'next/router' import { useParams } from 'common/hooks' import { TableGridEditor } from 'components/interfaces/TableGridEditor' import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs' -import { - ProjectContextFromParamsProvider, - useProjectContext, -} from 'components/layouts/ProjectLayout/ProjectContext' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' import type { NextPageWithLayout } from 'types' @@ -48,9 +46,9 @@ const TableEditorPage: NextPageWithLayout = () => { } TableEditorPage.getLayout = (page) => ( - + {page} - + ) export default TableEditorPage diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx index a7922e7f570..ca9392c8485 100644 --- a/apps/studio/pages/project/[ref]/editor/index.tsx +++ b/apps/studio/pages/project/[ref]/editor/index.tsx @@ -4,7 +4,7 @@ import { useParams } from 'common' import EmptyState from 'components/interfaces/TableGridEditor/EmptyState' import SidePanelEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' -import { ProjectContextFromParamsProvider } from 'components/layouts/ProjectLayout/ProjectContext' +import DefaultLayout from 'components/layouts/DefaultLayout' import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout' import type { NextPageWithLayout } from 'types' @@ -25,9 +25,9 @@ const TableEditorPage: NextPageWithLayout = () => { } TableEditorPage.getLayout = (page) => ( - + {page} - + ) export default TableEditorPage diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx index 893b8efb3ed..169b1c57e2c 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/details.tsx @@ -1,8 +1,13 @@ import { EdgeFunctionDetails } from 'components/interfaces/Functions' import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index b165afbd74b..077b1818dd3 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -30,6 +30,7 @@ import { Button, WarningIcon, } from 'ui' +import DefaultLayout from 'components/layouts/DefaultLayout' const CHART_INTERVALS: ChartIntervals[] = [ { @@ -330,6 +331,10 @@ const PageLayout: NextPageWithLayout = () => { ) } -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx index 5ecb05564fd..63cb65a8620 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/invocations.tsx @@ -2,6 +2,7 @@ import { useParams } from 'common' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import type { NextPageWithLayout } from 'types' @@ -28,6 +29,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx index e82144574d1..abf9396d1e8 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/logs.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common/hooks' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import type { NextPageWithLayout } from 'types' @@ -25,6 +26,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/functions/index.tsx b/apps/studio/pages/project/[ref]/functions/index.tsx index 684fa07eda2..acb63901d95 100644 --- a/apps/studio/pages/project/[ref]/functions/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/index.tsx @@ -7,6 +7,7 @@ import { TerminalInstructions, } from 'components/interfaces/Functions' import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' @@ -82,6 +83,10 @@ const PageLayout: NextPageWithLayout = () => { ) } -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx index 6182618a35f..2fc2c5b4ccc 100644 --- a/apps/studio/pages/project/[ref]/index.tsx +++ b/apps/studio/pages/project/[ref]/index.tsx @@ -26,6 +26,8 @@ import { TooltipContent, TooltipTrigger, } from 'ui' +import AppLayout from 'components/layouts/AppLayout/AppLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' const Home: NextPageWithLayout = () => { const organization = useSelectedOrganization() @@ -140,6 +142,10 @@ const Home: NextPageWithLayout = () => { ) } -Home.getLayout = (page) => {page} +Home.getLayout = (page) => ( + + {page} + +) export default Home diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx index 99f17904eb3..91321e9afde 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useRouter } from 'next/compat/router' @@ -58,6 +59,10 @@ const IntegrationPage: NextPageWithLayout = () => { return } -IntegrationPage.getLayout = (page) => {page} +IntegrationPage.getLayout = (page) => ( + + {page} + +) export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx index 99f17904eb3..91321e9afde 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useRouter } from 'next/compat/router' @@ -58,6 +59,10 @@ const IntegrationPage: NextPageWithLayout = () => { return } -IntegrationPage.getLayout = (page) => {page} +IntegrationPage.getLayout = (page) => ( + + {page} + +) export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx index 99f17904eb3..91321e9afde 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useRouter } from 'next/compat/router' @@ -58,6 +59,10 @@ const IntegrationPage: NextPageWithLayout = () => { return } -IntegrationPage.getLayout = (page) => {page} +IntegrationPage.getLayout = (page) => ( + + {page} + +) export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/index.tsx b/apps/studio/pages/project/[ref]/integrations/index.tsx index 58b1087c516..45364188a61 100644 --- a/apps/studio/pages/project/[ref]/integrations/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/index.tsx @@ -1,5 +1,6 @@ import { AvailableIntegrations } from 'components/interfaces/Integrations/Landing/AvailableIntegrations' import { InstalledIntegrations } from 'components/interfaces/Integrations/Landing/InstalledIntegrations' +import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' import type { NextPageWithLayout } from 'types' @@ -12,6 +13,10 @@ const IntegrationsPage: NextPageWithLayout = () => { ) } -IntegrationsPage.getLayout = (page) => {page} +IntegrationsPage.getLayout = (page) => ( + + {page} + +) export default IntegrationsPage diff --git a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx index b8cab60071b..92c24441abd 100644 --- a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx @@ -1,6 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import NoPermission from 'components/ui/NoPermission' @@ -19,6 +20,10 @@ const LogsPage: NextPageWithLayout = () => { ) } -LogsPage.getLayout = (page) => {page} +LogsPage.getLayout = (page) => ( + + {page} + +) export default LogsPage diff --git a/apps/studio/pages/project/[ref]/logs/collections/[collectionToken].tsx b/apps/studio/pages/project/[ref]/logs/collections/[collectionToken].tsx index 4fb57d4dd28..f10f5f2a3da 100644 --- a/apps/studio/pages/project/[ref]/logs/collections/[collectionToken].tsx +++ b/apps/studio/pages/project/[ref]/logs/collections/[collectionToken].tsx @@ -1,9 +1,14 @@ import { WarehouseCollectionDetail } from 'components/interfaces/DataWarehouse/WarehouseCollectionDetail' +import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/logs/cron-logs.tsx b/apps/studio/pages/project/[ref]/logs/cron-logs.tsx index d6a549a1394..44bb6f108d4 100644 --- a/apps/studio/pages/project/[ref]/logs/cron-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/cron-logs.tsx @@ -6,6 +6,7 @@ import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' +import DefaultLayout from 'components/layouts/DefaultLayout' export const LogPage: NextPageWithLayout = () => { const router = useRouter() @@ -21,6 +22,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx b/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx index f94e5795654..213544a74de 100644 --- a/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/edge-functions-logs.tsx @@ -4,7 +4,7 @@ import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constant import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' - +import DefaultLayout from 'components/layouts/DefaultLayout' export const LogPage: NextPageWithLayout = () => { const router = useRouter() const { ref } = router.query @@ -19,6 +19,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/edge-logs.tsx b/apps/studio/pages/project/[ref]/logs/edge-logs.tsx index 923dca7a204..21cbc34cc86 100644 --- a/apps/studio/pages/project/[ref]/logs/edge-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/edge-logs.tsx @@ -4,6 +4,7 @@ import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constant import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' export const LogPage: NextPageWithLayout = () => { const router = useRouter() @@ -19,6 +20,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index 587768ab428..bf885850642 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -53,6 +53,7 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import type { LogSqlSnippets, NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const PLACEHOLDER_WAREHOUSE_QUERY = '-- Fetch the last 10 logs in the last 7 days \nselect id, timestamp, event_message from `COLLECTION_NAME` \nwhere timestamp > timestamp_sub(current_timestamp(), interval 7 day) \norder by timestamp desc limit 10' @@ -467,6 +468,10 @@ export const LogsExplorerPage: NextPageWithLayout = () => { ) } -LogsExplorerPage.getLayout = (page) => {page} +LogsExplorerPage.getLayout = (page) => ( + + {page} + +) export default LogsExplorerPage diff --git a/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx b/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx index c4cd91cef4f..ec9ff6a5e50 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/recent.tsx @@ -9,6 +9,7 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage' import type { LogSqlSnippets, NextPageWithLayout } from 'types' import { Button } from 'ui' import { Clock } from 'lucide-react' +import DefaultLayout from 'components/layouts/DefaultLayout' export const LogsSavedPage: NextPageWithLayout = () => { const { ref } = useParams() @@ -58,6 +59,10 @@ export const LogsSavedPage: NextPageWithLayout = () => { ) } -LogsSavedPage.getLayout = (page) => {page} +LogsSavedPage.getLayout = (page) => ( + + {page} + +) export default LogsSavedPage diff --git a/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx b/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx index 5f772d240d7..19024f03005 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/saved.tsx @@ -9,6 +9,7 @@ import LogsExplorerHeader from 'components/ui/Logs/LogsExplorerHeader' import { useContentQuery } from 'data/content/content-query' import type { NextPageWithLayout } from 'types' import { Loading } from 'ui' +import DefaultLayout from 'components/layouts/DefaultLayout' // [Joshen] This page looks like its not longer in use from a UI POV - double checking and deprecate + add redirects export const LogsSavedPage: NextPageWithLayout = () => { @@ -65,6 +66,10 @@ export const LogsSavedPage: NextPageWithLayout = () => { ) } -LogsSavedPage.getLayout = (page) => {page} +LogsSavedPage.getLayout = (page) => ( + + {page} + +) export default LogsSavedPage diff --git a/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx b/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx index cc6fc90b3da..491604e25d3 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/templates.tsx @@ -9,6 +9,7 @@ import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import CardButton from 'components/ui/CardButton' import LogsExplorerHeader from 'components/ui/Logs/LogsExplorerHeader' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' export const LogsTemplatesPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() @@ -27,7 +28,11 @@ export const LogsTemplatesPage: NextPageWithLayout = () => { ) } -LogsTemplatesPage.getLayout = (page) => {page} +LogsTemplatesPage.getLayout = (page) => ( + + {page} + +) export default LogsTemplatesPage diff --git a/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx b/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx index 25ba5c01e35..fbb829360f7 100644 --- a/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pgcron-logs.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' export const LogPage: NextPageWithLayout = () => { @@ -19,6 +20,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx b/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx index 37c02c2e667..fd4885f9fc0 100644 --- a/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/pooler-logs.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import { Loading } from 'components/ui/Loading' import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' @@ -23,6 +24,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx b/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx index 90e6a292bb3..3f93fb25981 100644 --- a/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/postgres-logs.tsx @@ -4,6 +4,7 @@ import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' +import DefaultLayout from 'components/layouts/DefaultLayout' export const LogPage: NextPageWithLayout = () => { const router = useRouter() @@ -19,6 +20,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx b/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx index 37901dfe793..09c25060447 100644 --- a/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/postgrest-logs.tsx @@ -5,6 +5,7 @@ import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' import { LogsTableEmptyState } from 'components/interfaces/Settings/Logs/LogsTableEmptyState' +import DefaultLayout from 'components/layouts/DefaultLayout' export const LogPage: NextPageWithLayout = () => { const router = useRouter() @@ -26,6 +27,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx b/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx index 636ffa945da..ac99ca8a000 100644 --- a/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/realtime-logs.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/router' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' export const LogPage: NextPageWithLayout = () => { const router = useRouter() @@ -19,6 +20,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/logs/storage-logs.tsx b/apps/studio/pages/project/[ref]/logs/storage-logs.tsx index 41a65f98281..2be8683e54d 100644 --- a/apps/studio/pages/project/[ref]/logs/storage-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/storage-logs.tsx @@ -2,6 +2,7 @@ import { useRouter } from 'next/router' import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' export const LogPage: NextPageWithLayout = () => { @@ -19,6 +20,10 @@ export const LogPage: NextPageWithLayout = () => { ) } -LogPage.getLayout = (page) => {page} +LogPage.getLayout = (page) => ( + + {page} + +) export default LogPage diff --git a/apps/studio/pages/project/[ref]/realtime/inspector.tsx b/apps/studio/pages/project/[ref]/realtime/inspector.tsx index b180cf75bac..12c7968597f 100644 --- a/apps/studio/pages/project/[ref]/realtime/inspector.tsx +++ b/apps/studio/pages/project/[ref]/realtime/inspector.tsx @@ -3,6 +3,7 @@ import type { NextPageWithLayout } from 'types' import { RealtimeInspector } from 'components/interfaces/Realtime/Inspector' import RealtimeLayout from 'components/layouts/RealtimeLayout/RealtimeLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -17,7 +18,9 @@ export const InspectorPage: NextPageWithLayout = () => { } InspectorPage.getLayout = (page) => ( - {page} + + {page} + ) export default InspectorPage diff --git a/apps/studio/pages/project/[ref]/realtime/policies.tsx b/apps/studio/pages/project/[ref]/realtime/policies.tsx index 9f80d443f91..20555d9d134 100644 --- a/apps/studio/pages/project/[ref]/realtime/policies.tsx +++ b/apps/studio/pages/project/[ref]/realtime/policies.tsx @@ -2,11 +2,16 @@ import { RealtimePolicies } from 'components/interfaces/Realtime/Policies' import type { NextPageWithLayout } from 'types' import RealtimeLayout from 'components/layouts/RealtimeLayout/RealtimeLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' const RealtimePoliciesPage: NextPageWithLayout = () => { return } -RealtimePoliciesPage.getLayout = (page) => {page} +RealtimePoliciesPage.getLayout = (page) => ( + + {page} + +) export default RealtimePoliciesPage diff --git a/apps/studio/pages/project/[ref]/reports/[id].tsx b/apps/studio/pages/project/[ref]/reports/[id].tsx index ffac540da75..9f74349d499 100644 --- a/apps/studio/pages/project/[ref]/reports/[id].tsx +++ b/apps/studio/pages/project/[ref]/reports/[id].tsx @@ -1,5 +1,6 @@ import ReportPadding from 'components/interfaces/Reports/ReportPadding' import Reports from 'components/interfaces/Reports/Reports' +import DefaultLayout from 'components/layouts/DefaultLayout' import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout' import type { NextPageWithLayout } from 'types' @@ -11,6 +12,10 @@ const PageLayout: NextPageWithLayout = () => (
    ) -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/reports/api-overview.tsx b/apps/studio/pages/project/[ref]/reports/api-overview.tsx index 23c9610e2d1..08ed4ddc3a4 100644 --- a/apps/studio/pages/project/[ref]/reports/api-overview.tsx +++ b/apps/studio/pages/project/[ref]/reports/api-overview.tsx @@ -11,6 +11,7 @@ import { TotalRequestsChartRenderer, } from 'components/interfaces/Reports/renderers/ApiRenderers' import type { DatePickerToFrom } from 'components/interfaces/Settings/Logs/Logs.types' +import DefaultLayout from 'components/layouts/DefaultLayout' import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout' import ShimmerLine from 'components/ui/ShimmerLine' import { useApiReport } from 'data/reports/api-report-query' @@ -116,6 +117,10 @@ export const ApiReport: NextPageWithLayout = () => { ) } -ApiReport.getLayout = (page) => {page} +ApiReport.getLayout = (page) => ( + + {page} + +) export default ApiReport diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index cb5442ff7b3..1cf9e30c563 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -28,6 +28,7 @@ import { TIME_PERIODS_INFRA } from 'lib/constants/metrics' import { formatBytes } from 'lib/helpers' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const DatabaseReport: NextPageWithLayout = () => { return ( @@ -37,7 +38,11 @@ const DatabaseReport: NextPageWithLayout = () => { ) } -DatabaseReport.getLayout = (page) => {page} +DatabaseReport.getLayout = (page) => ( + + {page} + +) export default DatabaseReport diff --git a/apps/studio/pages/project/[ref]/reports/index.tsx b/apps/studio/pages/project/[ref]/reports/index.tsx index bda2e93bec5..c063658e31f 100644 --- a/apps/studio/pages/project/[ref]/reports/index.tsx +++ b/apps/studio/pages/project/[ref]/reports/index.tsx @@ -11,6 +11,7 @@ import { useContentQuery } from 'data/content/content-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useProfile } from 'lib/profile' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' export const UserReportPage: NextPageWithLayout = () => { const router = useRouter() @@ -74,6 +75,10 @@ export const UserReportPage: NextPageWithLayout = () => { ) } -UserReportPage.getLayout = (page) => {page} +UserReportPage.getLayout = (page) => ( + + {page} + +) export default UserReportPage diff --git a/apps/studio/pages/project/[ref]/reports/storage.tsx b/apps/studio/pages/project/[ref]/reports/storage.tsx index 9d8f711367a..8467d395946 100644 --- a/apps/studio/pages/project/[ref]/reports/storage.tsx +++ b/apps/studio/pages/project/[ref]/reports/storage.tsx @@ -18,6 +18,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { RefreshCw } from 'lucide-react' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' export const StorageReport: NextPageWithLayout = () => { const report = useStorageReport() @@ -83,6 +84,10 @@ export const StorageReport: NextPageWithLayout = () => { ) } -StorageReport.getLayout = (page) => {page} +StorageReport.getLayout = (page) => ( + + {page} + +) export default StorageReport diff --git a/apps/studio/pages/project/[ref]/settings/addons.tsx b/apps/studio/pages/project/[ref]/settings/addons.tsx index 9d906d289fc..d9bf4b63684 100644 --- a/apps/studio/pages/project/[ref]/settings/addons.tsx +++ b/apps/studio/pages/project/[ref]/settings/addons.tsx @@ -1,4 +1,5 @@ import Addons from 'components/interfaces/Settings/Addons/Addons' +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, @@ -22,5 +23,9 @@ const ProjectAddons: NextPageWithLayout = () => { ) } -ProjectAddons.getLayout = (page) => {page} +ProjectAddons.getLayout = (page) => ( + + {page} + +) export default ProjectAddons diff --git a/apps/studio/pages/project/[ref]/settings/api.tsx b/apps/studio/pages/project/[ref]/settings/api.tsx index 1b5d2c6211d..2b0ad1b5c19 100644 --- a/apps/studio/pages/project/[ref]/settings/api.tsx +++ b/apps/studio/pages/project/[ref]/settings/api.tsx @@ -1,4 +1,5 @@ import ServiceList from 'components/interfaces/Settings/API/ServiceList' +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' import type { NextPageWithLayout } from 'types' @@ -18,5 +19,9 @@ const ApiSettings: NextPageWithLayout = () => { ) } -ApiSettings.getLayout = (page) => {page} +ApiSettings.getLayout = (page) => ( + + {page} + +) export default ApiSettings diff --git a/apps/studio/pages/project/[ref]/settings/auth.tsx b/apps/studio/pages/project/[ref]/settings/auth.tsx index 4426c483d7a..cd528d2f017 100644 --- a/apps/studio/pages/project/[ref]/settings/auth.tsx +++ b/apps/studio/pages/project/[ref]/settings/auth.tsx @@ -1,53 +1,107 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { - AdvancedAuthSettingsForm, - BasicAuthSettingsForm, - SmtpForm, - ThirdPartyAuthForm, -} from 'components/interfaces/Auth' +import { useParams } from 'common' +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import NoPermission from 'components/ui/NoPermission' -import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' import type { NextPageWithLayout } from 'types' -const AuthSettings: NextPageWithLayout = () => { - const isPermissionsLoaded = usePermissionsLoaded() - // TODO: check if these permissions cover third party auth as well - const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') +const ProjectSettings: NextPageWithLayout = () => { + const { ref } = useParams() as { ref: string } return ( - <> - - - Auth settings - Configure security and user session settings - - - - {!isPermissionsLoaded ? ( - - ) : !canReadAuthSettings ? ( - - ) : ( - <> - - - - - - )} - - + +
    +

    + All settings are now under configuration within the Authentication page. All other product + settings will follow in the coming weeks. +

    + + General user signup + + + + Password settings in email provider + + + + User sessions + + + + Refresh tokens + + + + Bot and abuse protection + + + + SMTP settings + + + + Access token expiry + + + + Multifactor authentication + + + + Third party authentication + + + + Max request duration + + + + Max direct database connections + + +
    +
    ) } -AuthSettings.getLayout = (page) => {page} -export default AuthSettings +ProjectSettings.getLayout = (page) => ( + + {page} + +) + +export default ProjectSettings diff --git a/apps/studio/pages/project/[ref]/settings/billing/usage.tsx b/apps/studio/pages/project/[ref]/settings/billing/usage.tsx index 10f1ee0770e..95b05e37631 100644 --- a/apps/studio/pages/project/[ref]/settings/billing/usage.tsx +++ b/apps/studio/pages/project/[ref]/settings/billing/usage.tsx @@ -5,6 +5,7 @@ import { useParams } from 'common' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const ProjectBillingUsage: NextPageWithLayout = () => { // This component is only used for redirects, as nextjs cant redirect based on hash @@ -32,6 +33,10 @@ const ProjectBillingUsage: NextPageWithLayout = () => { return null } -ProjectBillingUsage.getLayout = (page) => {page} +ProjectBillingUsage.getLayout = (page) => ( + + {page} + +) export default ProjectBillingUsage diff --git a/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx b/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx index ec27c90d262..b7368afb060 100644 --- a/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx +++ b/apps/studio/pages/project/[ref]/settings/compute-and-disk.tsx @@ -1,4 +1,5 @@ import { DiskManagementForm } from 'components/interfaces/DiskManagement/DiskManagementForm' +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, @@ -24,5 +25,9 @@ const AuthSettings: NextPageWithLayout = () => { ) } -AuthSettings.getLayout = (page) => {page} +AuthSettings.getLayout = (page) => ( + + {page} + +) export default AuthSettings diff --git a/apps/studio/pages/project/[ref]/settings/database.tsx b/apps/studio/pages/project/[ref]/settings/database.tsx index 180d58743d6..e374b026ee8 100644 --- a/apps/studio/pages/project/[ref]/settings/database.tsx +++ b/apps/studio/pages/project/[ref]/settings/database.tsx @@ -7,6 +7,7 @@ import ResetDbPassword from 'components/interfaces/Settings/Database/DatabaseSet import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration' import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal' import SSLConfiguration from 'components/interfaces/Settings/Database/SSLConfiguration' +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' import { useSelectedProject } from 'hooks/misc/useSelectedProject' @@ -48,6 +49,10 @@ const ProjectSettings: NextPageWithLayout = () => { ) } -ProjectSettings.getLayout = (page) => {page} +ProjectSettings.getLayout = (page) => ( + + {page} + +) export default ProjectSettings diff --git a/apps/studio/pages/project/[ref]/settings/functions.tsx b/apps/studio/pages/project/[ref]/settings/functions.tsx index 46baeecc562..8ed9a74e353 100644 --- a/apps/studio/pages/project/[ref]/settings/functions.tsx +++ b/apps/studio/pages/project/[ref]/settings/functions.tsx @@ -1,4 +1,5 @@ import EdgeFunctionSecrets from 'components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets' +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, @@ -26,6 +27,10 @@ const PageLayout: NextPageWithLayout = () => { ) } -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/settings/general.tsx b/apps/studio/pages/project/[ref]/settings/general.tsx index 4ad1579cfce..c6e4981befa 100644 --- a/apps/studio/pages/project/[ref]/settings/general.tsx +++ b/apps/studio/pages/project/[ref]/settings/general.tsx @@ -6,6 +6,7 @@ import { General, TransferProjectPanel, } from 'components/interfaces/Settings/General' +import DefaultLayout from 'components/layouts/DefaultLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' @@ -47,5 +48,9 @@ const ProjectSettings: NextPageWithLayout = () => { ) } -ProjectSettings.getLayout = (page) => {page} +ProjectSettings.getLayout = (page) => ( + + {page} + +) export default ProjectSettings diff --git a/apps/studio/pages/project/[ref]/settings/index.tsx b/apps/studio/pages/project/[ref]/settings/index.tsx index e66b6c2ecc9..cc72e8e673c 100644 --- a/apps/studio/pages/project/[ref]/settings/index.tsx +++ b/apps/studio/pages/project/[ref]/settings/index.tsx @@ -1,3 +1,4 @@ +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import type { NextPageWithLayout } from 'types' @@ -5,6 +6,10 @@ const Settings: NextPageWithLayout = () => { return <>{/*

    Use this as a page template for settings pages

    */} } -Settings.getLayout = (page) => {page} +Settings.getLayout = (page) => ( + + {page} + +) export default Settings diff --git a/apps/studio/pages/project/[ref]/settings/infrastructure.tsx b/apps/studio/pages/project/[ref]/settings/infrastructure.tsx index 415339b51c0..13800cd0652 100644 --- a/apps/studio/pages/project/[ref]/settings/infrastructure.tsx +++ b/apps/studio/pages/project/[ref]/settings/infrastructure.tsx @@ -1,5 +1,6 @@ import InfrastructureActivity from 'components/interfaces/Settings/Infrastructure/InfrastructureActivity' import InfrastructureInfo from 'components/interfaces/Settings/Infrastructure/InfrastructureInfo' +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, @@ -29,7 +30,9 @@ const ProjectInfrastructure: NextPageWithLayout = () => { } ProjectInfrastructure.getLayout = (page) => ( - {page} + + {page} + ) export default ProjectInfrastructure diff --git a/apps/studio/pages/project/[ref]/settings/integrations.tsx b/apps/studio/pages/project/[ref]/settings/integrations.tsx index d2877d3fe1d..e6aa44922ea 100644 --- a/apps/studio/pages/project/[ref]/settings/integrations.tsx +++ b/apps/studio/pages/project/[ref]/settings/integrations.tsx @@ -1,4 +1,5 @@ import IntegrationSettings from 'components/interfaces/Settings/Integrations/IntegrationsSettings' +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, @@ -22,5 +23,9 @@ const OrgIntegrationSettings: NextPageWithLayout = () => { ) } -OrgIntegrationSettings.getLayout = (page) => {page} +OrgIntegrationSettings.getLayout = (page) => ( + + {page} + +) export default OrgIntegrationSettings diff --git a/apps/studio/pages/project/[ref]/settings/log-drains.tsx b/apps/studio/pages/project/[ref]/settings/log-drains.tsx index 5ed27057f7f..e84dcaea91f 100644 --- a/apps/studio/pages/project/[ref]/settings/log-drains.tsx +++ b/apps/studio/pages/project/[ref]/settings/log-drains.tsx @@ -21,6 +21,7 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import type { NextPageWithLayout } from 'types' import { Alert_Shadcn_, Button } from 'ui' +import DefaultLayout from 'components/layouts/DefaultLayout' const LogDrainsSettings: NextPageWithLayout = () => { const canManageLogDrains = useCheckPermissions(PermissionAction.ANALYTICS_ADMIN_WRITE, 'logflare') @@ -155,5 +156,9 @@ const LogDrainsSettings: NextPageWithLayout = () => { ) } -LogDrainsSettings.getLayout = (page) => {page} +LogDrainsSettings.getLayout = (page) => ( + + {page} + +) export default LogDrainsSettings diff --git a/apps/studio/pages/project/[ref]/settings/storage.tsx b/apps/studio/pages/project/[ref]/settings/storage.tsx index a2e1077d5dc..2d489c48362 100644 --- a/apps/studio/pages/project/[ref]/settings/storage.tsx +++ b/apps/studio/pages/project/[ref]/settings/storage.tsx @@ -1,3 +1,4 @@ +import DefaultLayout from 'components/layouts/DefaultLayout' import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' import { ScaffoldContainer, @@ -26,5 +27,9 @@ const PageLayout: NextPageWithLayout = () => { ) } -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 39874c355a4..38f310f2078 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -8,6 +8,7 @@ import { useContentIdQuery } from 'data/content/content-id-query' import { useAppStateSnapshot } from 'state/app-state' import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const SqlEditor: NextPageWithLayout = () => { const router = useRouter() @@ -57,6 +58,10 @@ const SqlEditor: NextPageWithLayout = () => { ) } -SqlEditor.getLayout = (page) => {page} +SqlEditor.getLayout = (page) => ( + + {page} + +) export default SqlEditor diff --git a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx index 17d1071ce34..7d53fea7325 100644 --- a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx +++ b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx @@ -1,4 +1,5 @@ import SQLQuickstarts from 'components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts' +import DefaultLayout from 'components/layouts/DefaultLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' import type { NextPageWithLayout } from 'types' @@ -6,6 +7,10 @@ const SqlEditorWelcome: NextPageWithLayout = () => { return } -SqlEditorWelcome.getLayout = (page) => {page} +SqlEditorWelcome.getLayout = (page) => ( + + {page} + +) export default SqlEditorWelcome diff --git a/apps/studio/pages/project/[ref]/sql/templates.tsx b/apps/studio/pages/project/[ref]/sql/templates.tsx index c988a373dcc..de06e63259a 100644 --- a/apps/studio/pages/project/[ref]/sql/templates.tsx +++ b/apps/studio/pages/project/[ref]/sql/templates.tsx @@ -1,4 +1,5 @@ import SQLTemplates from 'components/interfaces/SQLEditor/SQLTemplates/SQLTemplates' +import DefaultLayout from 'components/layouts/DefaultLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' import type { NextPageWithLayout } from 'types' @@ -6,6 +7,10 @@ const SqlEditorWelcome: NextPageWithLayout = () => { return } -SqlEditorWelcome.getLayout = (page) => {page} +SqlEditorWelcome.getLayout = (page) => ( + + {page} + +) export default SqlEditorWelcome diff --git a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx index 89726e6bcea..20f1c7a3b54 100644 --- a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx +++ b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx @@ -4,6 +4,7 @@ import { find } from 'lodash' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import StorageBucketsError from 'components/layouts/StorageLayout/StorageBucketsError' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { StorageExplorer } from 'components/to-be-cleaned/Storage' import { useBucketsQuery } from 'data/storage/buckets-query' import type { NextPageWithLayout } from 'types' @@ -37,6 +38,10 @@ const PageLayout: NextPageWithLayout = () => { ) } -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/storage/buckets/index.tsx b/apps/studio/pages/project/[ref]/storage/buckets/index.tsx index f65bd2b61f5..85ee5497522 100644 --- a/apps/studio/pages/project/[ref]/storage/buckets/index.tsx +++ b/apps/studio/pages/project/[ref]/storage/buckets/index.tsx @@ -3,6 +3,7 @@ import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import StorageBucketsError from 'components/layouts/StorageLayout/StorageBucketsError' import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import { useBucketsQuery } from 'data/storage/buckets-query' import type { NextPageWithLayout } from 'types' @@ -37,6 +38,10 @@ const PageLayout: NextPageWithLayout = () => { ) } -PageLayout.getLayout = (page) => {page} +PageLayout.getLayout = (page) => ( + + {page} + +) export default PageLayout diff --git a/apps/studio/pages/project/[ref]/storage/index.tsx b/apps/studio/pages/project/[ref]/storage/index.tsx index c3d9981ca6a..153afc04db5 100644 --- a/apps/studio/pages/project/[ref]/storage/index.tsx +++ b/apps/studio/pages/project/[ref]/storage/index.tsx @@ -1,10 +1,15 @@ import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import type { NextPageWithLayout } from 'types' const Storage: NextPageWithLayout = () => { return <>{/*

    Use this as a template for storage pages

    */} } -Storage.getLayout = (page) => {page} +Storage.getLayout = (page) => ( + + {page} + +) export default Storage diff --git a/apps/studio/pages/project/[ref]/storage/policies.tsx b/apps/studio/pages/project/[ref]/storage/policies.tsx index 18200a699cd..d2daedb8925 100644 --- a/apps/studio/pages/project/[ref]/storage/policies.tsx +++ b/apps/studio/pages/project/[ref]/storage/policies.tsx @@ -1,4 +1,5 @@ import StorageLayout from 'components/layouts/StorageLayout/StorageLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { StoragePolicies } from 'components/to-be-cleaned/Storage' import type { NextPageWithLayout } from 'types' @@ -10,6 +11,10 @@ const StoragePoliciesPage: NextPageWithLayout = () => { ) } -StoragePoliciesPage.getLayout = (page) => {page} +StoragePoliciesPage.getLayout = (page) => ( + + {page} + +) export default StoragePoliciesPage diff --git a/apps/studio/pages/sign-in-fly-tos.tsx b/apps/studio/pages/sign-in-fly-tos.tsx index 93cdf7d5520..5929127bceb 100644 --- a/apps/studio/pages/sign-in-fly-tos.tsx +++ b/apps/studio/pages/sign-in-fly-tos.tsx @@ -68,7 +68,7 @@ const SignInFlyTos = () => { } return ( -
    +