Merge branch 'master' into feat/auth-webauthn-fe

This commit is contained in:
Francesco Sansalvadore authored and GitHub committed 2025-10-02 11:23:33 +02:00
commit df96776f82
121 files changed
+3412 -1238

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: Trigger Nimbus Sync
on:
push:
branches: [master]
workflow_dispatch: # Allow manual triggering
jobs:
trigger-nimbus-sync:
runs-on: ubuntu-latest
steps:
- name: Trigger sync in supabase-nimbus
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.NIMBUS_SYNC_TOKEN }}
repository: supabase/supabase-nimbus
event-type: sync_from_upstream
@@ -1,156 +0,0 @@
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="mac"
queryGroup="os"
>
<TabPanel id="mac" label="macOS">
```json
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase",
"--read-only",
"--project-ref=<project-ref>"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "<access-token>"
}
}
}
}
```
Replace `<project-ref>` with your project ref, and `<access-token>` with your personal access token.
</TabPanel>
<TabPanel id="windows" label="Windows">
```json
{
"mcpServers": {
"supabase": {
"command": "cmd",
"args": [
"/c",
"npx",
"-y",
"@supabase/mcp-server-supabase",
"--read-only",
"--project-ref=<project-ref>"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "<access-token>"
}
}
}
}
```
Or, if using `pnpm` instead of `npm`
```json
{
"mcpServers": {
"supabase": {
"command": "cmd",
"args": [
"/c",
"pnpm",
"dlx",
"@supabase/mcp-server-supabase",
"--read-only",
"--project-ref=<project-ref>"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "<access-token>"
}
}
}
}
```
Replace `<project-ref>` with your project ref, and `<access-token>` with your personal access token.
<Admonition type="note">
Make sure that `node` and `npx` are available in your system `PATH`. Assuming `node` is installed, you can get the path by running:
```shell
npm config get prefix
```
Then add it to your system `PATH` by running:
```shell
setx PATH "%PATH%;<path-to-dir>"
```
Replacing `<path-to-dir>` with the path you got from the previous command.
Finally restart {{ .app }} for the changes to take effect.
</Admonition>
</TabPanel>
<TabPanel id="wsl" label="Windows (WSL)">
```json
{
"mcpServers": {
"supabase": {
"command": "wsl",
"args": [
"npx",
"-y",
"@supabase/mcp-server-supabase",
"--read-only",
"--project-ref=<project-ref>"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "<access-token>"
}
}
}
}
```
Replace `<project-ref>` with your project ref, and `<access-token>` with your personal access token.
This assumes you have Windows Subsystem for Linux (WSL) enabled and `node`/`npx` are installed within the WSL environment.
</TabPanel>
<TabPanel id="linux" label="Linux">
```json
{
"mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase",
"--read-only",
"--project-ref=<project-ref>"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "<access-token>"
}
}
}
}
```
Replace `<project-ref>` with your project ref, and `<access-token>` with your personal access token.
</TabPanel>
</Tabs>
@@ -1,143 +0,0 @@
<Tabs
scrollable
size="small"
type="underlined"
defaultActiveId="mac"
queryGroup="os"
>
<TabPanel id="mac" label="macOS">
```json
{
"inputs": [
{
"type": "promptString",
"id": "supabase-access-token",
"description": "Supabase personal access token",
"password": true
}
],
"servers": {
"supabase": {
"command": "npx",
"args": ["-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref=<project-ref>"],
"env": {
"SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
}
}
}
}
```
Replace `<project-ref>` with your project ref.
</TabPanel>
<TabPanel id="windows" label="Windows">
```json
{
"inputs": [
{
"type": "promptString",
"id": "supabase-access-token",
"description": "Supabase personal access token",
"password": true
}
],
"servers": {
"supabase": {
"command": "cmd",
"args": ["/c", "npx", "-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref=<project-ref>"],
"env": {
"SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
}
}
}
}
```
Replace `<project-ref>` with your project ref.
<Admonition type="note">
Make sure that `node` and `npx` are available in your system `PATH`. Assuming `node` is installed, you can get the path by running:
```shell
npm config get prefix
```
Then add it to your system `PATH` by running:
```shell
setx PATH "%PATH%;<path-to-dir>"
```
Replacing `<path-to-dir>` with the path you got from the previous command.
Finally restart VS Code for the changes to take effect.
</Admonition>
</TabPanel>
<TabPanel id="wsl" label="Windows (WSL)">
```json
{
"inputs": [
{
"type": "promptString",
"id": "supabase-access-token",
"description": "Supabase personal access token",
"password": true
}
],
"servers": {
"supabase": {
"command": "wsl",
"args": ["npx", "-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref=<project-ref>"],
"env": {
"SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
}
}
}
}
```
Replace `<project-ref>` with your project ref.
This assumes you have Windows Subsystem for Linux (WSL) enabled and `node`/`npx` are installed within the WSL environment.
</TabPanel>
<TabPanel id="linux" label="Linux">
```json
{
"inputs": [
{
"type": "promptString",
"id": "supabase-access-token",
"description": "Supabase personal access token",
"password": true
}
],
"servers": {
"supabase": {
"command": "npx",
"args": ["-y", "@supabase/mcp-server-supabase@latest", "--read-only", "--project-ref=<project-ref>"],
"env": {
"SUPABASE_ACCESS_TOKEN": "${input:supabase-access-token}"
}
}
}
}
```
Replace `<project-ref>` with your project ref.
</TabPanel>
</Tabs>
+11 -191
View File
@@ -6,211 +6,31 @@ description: 'Connect your AI tools to Supabase using MCP'
sidebar_label: 'Model context protocol (MCP)'
---
The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is a standard for connecting Large Language Models (LLMs) to platforms like Supabase. This guide covers how to connect Supabase to the following AI tools using MCP:
The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is a standard for connecting Large Language Models (LLMs) to platforms like Supabase. Once connected, your AI assistants can interact with and query your Supabase projects on your behalf.
- [Cursor](#cursor)
- [Windsurf](#windsurf) (Codium)
- [Visual Studio Code](#visual-studio-code-copilot) (Copilot)
- [Cline](#cline) (VS Code extension)
- [Claude desktop](#claude-desktop)
- [Claude code](#claude-code)
- [Amp](#amp)
## Remote MCP installation
Once connected, your AI assistants can interact with and query your Supabase projects on your behalf.
## Step 1: Create an access token
First, go to your [Supabase settings](/dashboard/account/tokens) and create an access token to authenticate the MCP server with your Supabase account. Give it a name that describes its purpose, like "Cursor MCP Server".
## Step 2: Follow our security best practices
### Step 1: Follow our security best practices
Before running the MCP server, we recommend you read our [security best practices](#security-risks) to understand the risks of connecting an LLM to your Supabase projects and how to mitigate them.
## Step 3: Configure your AI tool
### Step 2: Configure your AI tool
MCP compatible tools connect to Supabase using the [Supabase MCP server](https://github.com/supabase-community/supabase-mcp).
Choose your Supabase platform, project, and MCP client and follow the installation instructions:
Follow the instructions for your AI tool to connect the Supabase MCP server. The configuration below uses read-only, project-scoped mode by default. We recommend these settings to prevent the agent from making unintended changes to your database.
<McpConfigPanel />
<Admonition type="note" title="Read-only mode">
<Admonition type="note" title="Authentication" className="mt-3">
Read-only mode applies only to database operations. Write operations on project-management tools,
such as `create_project`, are still available.
Your MCP client will automatically prompt you to login to Supabase during setup. This will open a browser window where you can login to your Supabase account and grant access to the MCP client. Be sure to choose the organization that contains the project you wish to work with. In the future, we'll offer more fine grain control over these permissions.
Previously Supabase MCP required you to generate a personal access token (PAT), but this is no longer required.
</Admonition>
### Cursor
1. Open [Cursor](https://www.cursor.com/) and create a `.cursor` directory in your project root if it doesn't exist.
2. Create a `.cursor/mcp.json` file if it doesn't exist and open it.
3. Add the following configuration:
<$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Cursor" }} />
4. Save the configuration file.
5. Open Cursor and navigate to **Settings > Cursor Settings > MCP & Integrations**. You should see a green active status after the server is successfully connected.
### Windsurf
1. Open [Windsurf](https://docs.codeium.com/windsurf) and open the Cascade assistant.
2. Tap on the box (**Customizations**) icon, then the **Configure** icon in the top right of the panel to open the configuration file.
3. Add the following configuration:
<$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Windsurf" }} />
4. Save the configuration file and reload by tapping **Refresh** in the Cascade assistant.
5. You should see a green active status after the server is successfully connected.
### Visual Studio Code (Copilot)
<div className="mb-8 not-prose [&>p]:flex [&>p]:flex-col [&>p]:gap-2">
[![Install with NPX in VS
Code](https://img.shields.io/badge/VS_Code-NPM-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=supabase&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22supabase-access-token%22%2C%22description%22%3A%22Supabase%20personal%20access%20token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40supabase%2Fmcp-server-supabase%40latest%22%2C%22--readonly%22%2C%22--project-ref%3D%24SUPABASE_MCP_PROJECT_REF%22%5D%2C%22env%22%3A%7B%22SUPABASE_ACCESS_TOKEN%22%3A%22%24%7Binput%3Asupabase-access-token%7D%22%2C%22SUPABASE_MCP_PROJECT_REF%22%3A%22%24%7Binput%3Asupabase-project-ref%7D%22%7D%7D)
[![Install with NPX in VS Code
Insiders](https://img.shields.io/badge/VS_Code_Insiders-NPM-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=supabase&inputs=%5B%7B%22type%22%3A%22promptString%22%2C%22id%22%3A%22supabase-access-token%22%2C%22description%22%3A%22Supabase%20personal%20access%20token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40supabase%2Fmcp-server-supabase%40latest%22%2C%22--readonly%22%2C%22--project-ref%3D%24SUPABASE_MCP_PROJECT_REF%22%5D%2C%22env%22%3A%7B%22SUPABASE_ACCESS_TOKEN%22%3A%22%24%7Binput%3Asupabase-access-token%7D%22%2C%22SUPABASE_MCP_PROJECT_REF%22%3A%22%24%7Binput%3Asupabase-project-ref%7D%22%7D%7D&quality=insiders)
</div>
1. Open [VS Code](https://code.visualstudio.com/) and create a `.vscode` directory in your project root if it doesn't exist.
2. Create a `.vscode/mcp.json` file if it doesn't exist and open it.
3. Add the following configuration:
<$Partial path="mcp_supabase_vscode_config.mdx" />
4. Save the configuration file and click the **Start** button that appears inline above the Supabase server definition. VS Code prompts you to enter your personal access token. Enter the token that you created earlier.
5. Open Copilot chat and switch to "Agent" mode. You should see a tool icon that you can tap to confirm the MCP tools are available.
<Admonition type="note">
For more info on using MCP in VS Code, read the [Copilot documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers).
</Admonition>
### Cline
1. Open the [Cline](https://github.com/cline/cline) extension in VS Code and tap the **MCP Servers** icon.
2. Tap **MCP Servers**, open the **Installed** tab, then click "Configure MCP Servers" to open the configuration file.
3. Add the following configuration:
<$Partial path="mcp_supabase_config.mdx" variables={{ "app": "VS Code" }} />
4. Save the configuration file. Cline should automatically reload the configuration.
5. You should see a green active status after the server is successfully connected.
### Claude desktop
1. Open [Claude desktop](https://claude.ai/download) and navigate to **Settings**.
2. Under the **Developer** tab, tap **Edit Config** to open the configuration file.
3. Add the following configuration:
<$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Claude desktop" }} />
4. Save the configuration file and restart Claude desktop.
5. From the new chat screen, you should see a settings (Search and tools) icon appear with the new MCP server available.
### Claude code
You can add the Supabase MCP server to Claude Code in two ways:
#### Option 1: Project-scoped server (via .mcp.json file)
1. Create a `.mcp.json` file in your project root if it doesn't exist.
2. Add the following configuration:
<$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Claude code" }} />
3. Save the configuration file.
4. Restart [Claude code](https://claude.ai/code) to apply the new configuration.
#### Option 2: Locally-scoped server (via CLI command)
You can also add the Supabase MCP server as a locally-scoped server, which is only available to you in the current project:
1. Run the following command in your terminal:
```bash
claude mcp add supabase -s local -e SUPABASE_ACCESS_TOKEN=your_token_here -- npx -y @supabase/mcp-server-supabase@latest
```
Locally-scoped servers take precedence over project-scoped servers with the same name and are stored in your project-specific user settings.
### Amp
You can add the Supabase MCP server to [Amp](https://ampcode.com) in two ways:
#### Option 1: VSCode settings.json
1. Open VSCode's `settings.json` file.
2. Add the following configuration:
```json
{
"amp.mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase@latest",
"--read-only",
"--project-ref=<project-ref>"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "<personal-access-token>"
}
}
}
}
```
Replace `project-ref` and `personal-access-token` with your project ref and personal access token.
3. Save the configuration file.
4. Restart VS Code to apply the new configuration.
#### Option 2: Amp CLI
1. Edit `~/.config/amp/settings.json`
2. Add the following configuration:
```json
{
"amp.mcpServers": {
"supabase": {
"command": "npx",
"args": [
"-y",
"@supabase/mcp-server-supabase",
"--read-only",
"--project-ref=<project-ref>"
],
"env": {
"SUPABASE_ACCESS_TOKEN": "<personal-access-token>"
}
}
}
}
```
Replace `project-ref` and `personal-access-token` with your project ref and personal access token.
3. Save the configuration file.
4. Restart Amp to apply the new configuration.
### Qodo Gen
1. Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ.
2. Click **Connect more tools**.
3. Click **+ Add new MCP**.
4. Add the following configuration:
<$Partial path="mcp_supabase_config.mdx" variables={{ "app": "Qodo Gen" }} />
5. Click **Save**.
### Next steps
Your AI tool is now connected to Supabase using MCP. Try asking your AI assistant to create a new project, create a table, or fetch project config.
For a full list of tools available, see the [GitHub README](https://github.com/supabase-community/supabase-mcp#tools). If you experience any issues, [submit an bug report](https://github.com/supabase-community/supabase-mcp/issues/new?template=1.Bug_report.md).
Your AI tool is now connected to your Supabase project or account using remote MCP. Try asking the AI tool to query your database using natural language commands.
## Security risks
+3 -1
View File
@@ -1,11 +1,11 @@
import { ArrowDown, Check, X } from 'lucide-react'
import Link from 'next/link'
import { Button, Image } from 'ui'
import { Heading } from 'ui/src/components/CustomHTMLElements'
import { Admonition } from 'ui-patterns/admonition'
import { GlassPanel } from 'ui-patterns/GlassPanel'
import { IconPanel } from 'ui-patterns/IconPanel'
import SqlToRest from 'ui-patterns/SqlToRest'
import { Heading } from 'ui/src/components/CustomHTMLElements'
import { AiPromptsIndex } from '~/app/guides/getting-started/ai-prompts/[slug]/AiPromptsIndex'
import { AppleSecretGenerator } from '~/components/AppleSecretGenerator'
import AuthProviders from '~/components/AuthProviders'
@@ -29,6 +29,7 @@ import InfoTooltip from '~/features/ui/InfoTooltip'
import { ShowUntil } from '~/features/ui/ShowUntil'
import { TabPanel, Tabs } from '~/features/ui/Tabs'
import { ErrorCodes } from '../ui/ErrorCodes'
import { McpConfigPanel } from '../ui/McpConfigPanel'
const components = {
Accordion,
@@ -53,6 +54,7 @@ const components = {
Image: (props: any) => <Image fill alt="" className="object-contain" {...props} />,
JwtGenerator,
Link,
McpConfigPanel,
NamedCodeBlock,
NavData,
ProjectConfigVariables,
+220
View File
@@ -0,0 +1,220 @@
'use client'
import { useIsLoggedIn, useIsUserLoading } from 'common'
import { Check, ChevronDown } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useState } from 'react'
import {
Button,
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
} from 'ui'
import { McpConfigPanel as McpConfigPanelBase } from 'ui-patterns/McpUrlBuilder'
import { useProjectsQuery } from '~/lib/fetch/projects'
type PlatformType = (typeof PLATFORMS)[number]['value']
const PLATFORMS = [
{ value: 'hosted', label: 'Hosted' },
{ value: 'local', label: 'CLI' },
] as const satisfies Array<{ value: string; label: string }>
function ProjectSelector({
className,
selectedProject,
onProjectSelect,
}: {
className?: string
selectedProject?: { ref: string; name: string } | null
onProjectSelect?: (project: { ref: string; name: string } | null) => void
}) {
const isUserLoading = useIsUserLoading()
const isLoggedIn = useIsLoggedIn()
const { data: projects, isLoading, isError } = useProjectsQuery({ enabled: isLoggedIn })
const [open, setOpen] = useState(false)
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<div className={cn('flex', className)}>
<span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
Project
</span>
{!isUserLoading && !isLoggedIn ? (
<Button size="small" type="default" className="gap-0 rounded-l-none" asChild>
<Link href="https://supabase.com/dashboard" rel="noreferrer noopener" target="_blank">
<div className="flex items-center gap-2">Log in to choose a project</div>
</Link>
</Button>
) : (
<PopoverTrigger_Shadcn_ asChild disabled={isUserLoading || isLoading || isError}>
<Button
size="small"
type="default"
className="gap-0 rounded-l-none"
iconRight={
<ChevronDown
strokeWidth={1.5}
className={cn('transition-transform duration-200', open && 'rotate-180')}
/>
}
>
<div className="flex items-center gap-2">
{isUserLoading || isLoading
? 'Loading projects...'
: isError
? 'Error fetching projects'
: selectedProject?.name ?? 'Select a project'}
</div>
</Button>
</PopoverTrigger_Shadcn_>
)}
</div>
<PopoverContent_Shadcn_ className="mt-0 p-0 max-w-48" side="bottom" align="start">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No results found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{projects?.map((project) => (
<CommandItem_Shadcn_
key={project.ref}
value={project.ref}
onSelect={() => {
onProjectSelect?.(project.ref === selectedProject?.ref ? null : project)
setOpen(false)
}}
className="flex gap-2 items-center"
>
{project.name}
<Check
aria-label={project.ref === selectedProject?.ref ? 'selected' : undefined}
size={15}
className={cn(
'ml-auto',
project.ref === selectedProject?.ref ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
function PlatformSelector({
className,
selectedPlatform,
onPlatformSelect,
}: {
className?: string
selectedPlatform: PlatformType
onPlatformSelect?: (platform: PlatformType) => void
}) {
const [open, setOpen] = useState(false)
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<div className={cn('flex', className)}>
<span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
Platform
</span>
<PopoverTrigger_Shadcn_ asChild>
<Button
size="small"
type="default"
className="gap-0 rounded-l-none"
iconRight={
<ChevronDown
strokeWidth={1.5}
className={cn('transition-transform duration-200', open && 'rotate-180')}
/>
}
>
<div className="flex items-center gap-2">
{PLATFORMS.find((p) => p.value === selectedPlatform)?.label}
</div>
</Button>
</PopoverTrigger_Shadcn_>
</div>
<PopoverContent_Shadcn_ className="mt-0 p-0 max-w-48" side="bottom" align="start">
<Command_Shadcn_>
<CommandList_Shadcn_>
<CommandGroup_Shadcn_>
{PLATFORMS.map((platform) => (
<CommandItem_Shadcn_
key={platform.value}
value={platform.value}
onSelect={() => {
onPlatformSelect?.(platform.value)
setOpen(false)
}}
className="flex gap-2 items-center"
>
{platform.label}
<Check
aria-label={platform.value === selectedPlatform ? 'selected' : undefined}
size={15}
className={cn(
'ml-auto',
platform.value === selectedPlatform ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export function McpConfigPanel() {
const [selectedProject, setSelectedProject] = useState<{ ref: string; name: string } | null>(null)
const [selectedPlatform, setSelectedPlatform] = useState<'hosted' | 'local'>('hosted')
const { theme } = useTheme()
const isPlatform = selectedPlatform === 'hosted'
const project = isPlatform ? selectedProject : null
return (
<div className="not-prose">
<div className="flex gap-3 mb-3">
<PlatformSelector
selectedPlatform={selectedPlatform}
onPlatformSelect={setSelectedPlatform}
/>
{isPlatform && (
<ProjectSelector selectedProject={project} onProjectSelect={setSelectedProject} />
)}
</div>
<p className="text-xs text-foreground-lighter">
{isPlatform
? 'Scope the MCP server to a project. If no project is selected, all projects will be accessible.'
: 'Project selection is only available for the hosted platform.'}
</p>
<McpConfigPanelBase
basePath="/docs"
className="mt-6"
projectRef={project?.ref}
theme={theme as 'light' | 'dark'}
isPlatform={isPlatform}
/>
</div>
)
}
+1
View File
@@ -14,6 +14,7 @@ Ant Wilson
Ariuna K
Artur Zakirov
Artyom Borissov
Barco Fourie
Beng Eu
Bo Lu
Bobbie Soedirgo
@@ -0,0 +1,16 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
<style type="text/css">
.st0{fill:#faf9f5;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
</sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 715 B

@@ -0,0 +1,16 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
<style type="text/css">
.st0{fill:#181818;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
</sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 714 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="36" fill="none"><path fill="url(#a)" d="M0 0h36v36H0z"/><defs><pattern id="a" width="1" height="1" patternContentUnits="objectBoundingBox"><use xlink:href="#b" transform="scale(.0005)"/></pattern><image xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB9AAAAfQCAYAAACaOMR5AAAACXBIWXMAAAsTAAALEwEAmpwYABAAAElEQVR4nOz9edhl55mXhz5rD+VBLckqzYNlyZYnyZbVsixLtsqaSqoqqaHTEAgk0GmGQOgDDTTdTdNtS5ZMNz0EGprQDAnDARIydQYOkEOSQ0aSQBJCEgJR1TfUXJqH0lBV3x7W+aP2s+u3f9+zdsnG2pru+7rq2nuv4Z3W2uur77vf53mbtm0DAAAAAAAAAAAAAAAAAADg/U7v7W4AAAAAAAAAAAAAAAAAAADAOwEEOgAAAAAAAAAAAAAAAAAAQCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIiJi8HY3AOD9RtM0b3cTAAAAAADgnccFl1566fUvvPDCP3u7GwIAAAAAAO882rZ9u5sA8L6BCHQAAAAAAACAt5Fbb71170//9E//N1/96lf/0VVXXfXLH/7wh695u9sEAAAAAAAA8H6lYcYKwGohAh0AAAAAACIirr/++pt37979+N133/0bR6NR/Oqv/mo888wzcckllxw/dOjQzx8+fPjPR8SZt7udAAAAAADw9oPPA1gdpHAHAAAAAAAAWCEXXXTRznvvvfcPPfTQQz/yoQ996HvOnDkTL774YnvmzJk4ceJEXHTRRdd8+tOf/lPXXHPND66vrz/23HPP/Z23u80AAAAAAAAA7xcQ6AAAAAAAAACrofnKV77yQ4888sjXr7766o+Px+M4depUu7W11bz22mvNaDSKM2fOxMmTJ9ter9dcdNFFX/ziF7/4t0+cOPGr6+vr33zttdf+ydvdAQAAAAAAAID3Ogh0AAAAAAAAgLeYz3zmM/c++uijT95yyy1fm0wmcebMmXY6ncbW1lbzxhtvxGuvvRZbW1vRtm288sorzY4dO6Lf77fD4bC59tprf/1VV121d2Nj488cOHDgFyPi+be7PwAAAAAAAADvVRDoAAAAAAAAAG8RH/3oRz9x7733fuOee+75rcPhsLe1tRXT6bQdjUbNZDKJ8Xgcp0+fjjNnzkTbtjGdTmM0GsWpU6diOBw2ERFt27b9fv+CT33qUz9xzTXX/Mb19fWfPXr06F+KiMnb3D0AAAAAAACA9xwIdAAAAAAAAIDvMpdddtmFX/rSl37kwQcf/NGdO3funKVnbyOiGY/HzWQyiclkEqdOnYpTp07FaDSKtm2j1+vNpfqOHTsiIuKDH/xg07ZtO51O48Mf/vANt95661+49tprf3Btbe3xF1544e+9vT0FAAAAAAAAeG+BQAcAAAAAAAD4LvLlL3/5N+3bt++xj370o5+NiNja2mqn02kTEc10Oo3pdBqzNO6R6563bRtN08RkMom2bePMmTNx+vTp6Pf70e/3IyKaXq8Xo9Go7fV6zaWXXnrPzp07/6tjx4799YMHD37r5MmTB97WTgMAAAAAAAC8R0CgAwAAAAAAAHwX+OQnP/nlvXv3PnHbbbftmU6nMR6PYxY53rRtO5fj4/E4ptNp5LYU6m3bRkTMP29tbcUHPvCBGI1G0TRNNE0TvV6vmaV6b4fDYXPdddf91iuuuOLXbGxs/MmNjY0/GRGvvK2DAAAAAAAAAPAuB4EOAAAAAAAA8M/Btddee92uXbt+6r777vudw+FwqOI8hXeub55yPAX5aDSK0WgU0+l0Xl4eu7W1FWfOnNEo9BgMBtHr9aLf7zej0SgGg0G7Y8eOj3z2s5/95rXXXvsvr62tPXnixIl/5+0aCwAAAAAAAIB3Owh0AAAAAAAAgO+MDz744IM/vHfv3h/fuXPnVdPpNEajURsRzWQyaSIitra25uuaTyaTGI/HERExHo9jNBrF1tbWQkS6yvbJZDIX7CnOJ5PJPNV7v9+P8Xjc9Hq9ttfrxYUXXvip22677a9fd911v219ff2xF1988X98OwcHAAAAAAAA4N0IAh0AAAAAAADg2+R7v/d7f+0jjzzyxMc//vHbmqaJra2ttmmamEWdLxybkedN08w/pzSfTCbz43K/4gI9IuaSXT43M7HeRkRz1VVXPXjZZZfde+jQob+0sbHxs6dPnz70Fg0DAAAAAAAAwHsOBDoAAAAAAADAm+TGG2/8wu7du5+46667vj/XM2/PLl7epNROKZ7rmue/lOanT5+OWbT6fFv+UzQSPaPRk+FwGJPJJHq9Xkyn06y36ff7MRqN2l6vN7jxxht/11VXXfUDm5ubv7i5uflnIuKNFQ4VAAAAAAAAwLsSBDoAAAAAAADAebjiiiuuvOeee378wQcf/OEPfehDH5pMJjGdTtu2Line truncated

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="36" fill="none"><path fill="url(#a)" d="M0 0h36v36H0z"/><defs><pattern id="a" width="1" height="1" patternContentUnits="objectBoundingBox"><use xlink:href="#b" transform="scale(.0005)"/></pattern><image xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB9AAAAfQCAYAAACaOMR5AAAACXBIWXMAAAsTAAALEwEAmpwYABAAAElEQVR4nOz9edhl55mXhz5rD+VBLckqzYNlyZYnyZbVsixLtsqaSqoqqaHTEAgk0GmGQOgDDTTdTdNtS5ZMNz0EGprQDAnDARIydQYOkEOSQ0aSQBJCEgJR1TfUXJqH0lBV3x7W+aP2s+u3f9+zdsnG2pru+7rq2nuv4Z3W2uur77vf53mbtm0DAAAAAAAAAAAAAAAAAADg/U7v7W4AAAAAAAAAAAAAAAAAAADAOwEEOgAAAAAAAAAAAAAAAAAAQCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIiJi8HY3AOD9RtM0b3cTAAAAAADgnccFl1566fUvvPDCP3u7GwIAAAAAAO882rZ9u5sA8L6BCHQAAAAAAACAt5Fbb71170//9E//N1/96lf/0VVXXfXLH/7wh695u9sEAAAAAAAA8H6lYcYKwGohAh0AAAAAACIirr/++pt37979+N133/0bR6NR/Oqv/mo888wzcckllxw/dOjQzx8+fPjPR8SZt7udAAAAAADw9oPPA1gdpHAHAAAAAAAAWCEXXXTRznvvvfcPPfTQQz/yoQ996HvOnDkTL774YnvmzJk4ceJEXHTRRdd8+tOf/lPXXHPND66vrz/23HPP/Z23u80AAAAAAAAA7xcQ6AAAAAAAAACrofnKV77yQ4888sjXr7766o+Px+M4depUu7W11bz22mvNaDSKM2fOxMmTJ9ter9dcdNFFX/ziF7/4t0+cOPGr6+vr33zttdf+ydvdAQAAAAAAAID3Ogh0AAAAAAAAgLeYz3zmM/c++uijT95yyy1fm0wmcebMmXY6ncbW1lbzxhtvxGuvvRZbW1vRtm288sorzY4dO6Lf77fD4bC59tprf/1VV121d2Nj488cOHDgFyPi+be7PwAAAAAAAADvVRDoAAAAAAAAAG8RH/3oRz9x7733fuOee+75rcPhsLe1tRXT6bQdjUbNZDKJ8Xgcp0+fjjNnzkTbtjGdTmM0GsWpU6diOBw2ERFt27b9fv+CT33qUz9xzTXX/Mb19fWfPXr06F+KiMnb3D0AAAAAAACA9xwIdAAAAAAAAIDvMpdddtmFX/rSl37kwQcf/NGdO3funKVnbyOiGY/HzWQyiclkEqdOnYpTp07FaDSKtm2j1+vNpfqOHTsiIuKDH/xg07ZtO51O48Mf/vANt95661+49tprf3Btbe3xF1544e+9vT0FAAAAAAAAeG+BQAcAAAAAAAD4LvLlL3/5N+3bt++xj370o5+NiNja2mqn02kTEc10Oo3pdBqzNO6R6563bRtN08RkMom2bePMmTNx+vTp6Pf70e/3IyKaXq8Xo9Go7fV6zaWXXnrPzp07/6tjx4799YMHD37r5MmTB97WTgMAAAAAAAC8R0CgAwAAAAAAAHwX+OQnP/nlvXv3PnHbbbftmU6nMR6PYxY53rRtO5fj4/E4ptNp5LYU6m3bRkTMP29tbcUHPvCBGI1G0TRNNE0TvV6vmaV6b4fDYXPdddf91iuuuOLXbGxs/MmNjY0/GRGvvK2DAAAAAAAAAPAuB4EOAAAAAAAA8M/Btddee92uXbt+6r777vudw+FwqOI8hXeub55yPAX5aDSK0WgU0+l0Xl4eu7W1FWfOnNEo9BgMBtHr9aLf7zej0SgGg0G7Y8eOj3z2s5/95rXXXvsvr62tPXnixIl/5+0aCwAAAAAAAIB3Owh0AAAAAAAAgO+MDz744IM/vHfv3h/fuXPnVdPpNEajURsRzWQyaSIitra25uuaTyaTGI/HERExHo9jNBrF1tbWQkS6yvbJZDIX7CnOJ5PJPNV7v9+P8Xjc9Hq9ttfrxYUXXvip22677a9fd911v219ff2xF1988X98OwcHAAAAAAAA4N0IAh0AAAAAAADg2+R7v/d7f+0jjzzyxMc//vHbmqaJra2ttmmamEWdLxybkedN08w/pzSfTCbz43K/4gI9IuaSXT43M7HeRkRz1VVXPXjZZZfde+jQob+0sbHxs6dPnz70Fg0DAAAAAAAAwHsOBDoAAAAAAADAm+TGG2/8wu7du5+46667vj/XM2/PLl7epNROKZ7rmue/lOanT5+OWbT6fFv+UzQSPaPRk+FwGJPJJHq9Xkyn06y36ff7MRqN2l6vN7jxxht/11VXXfUDm5ubv7i5uflnIuKNFQ4VAAAAAAAAwLsSBDoAAAAAAADAebjiiiuuvOeee378wQcf/OEPfehDH5pMJjGdTtu2Line truncated

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,41 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.9119 99.3171C72.4869 99.9307 74.2828 99.8914 75.8725 99.1264L96.4608 89.2197C98.6242 88.1787 100 85.9892 100 83.5872V16.4133C100 14.0113 98.6243 11.8218 96.4609 10.7808L75.8725 0.873756C73.7862 -0.130129 71.3446 0.11576 69.5135 1.44695C69.252 1.63711 69.0028 1.84943 68.769 2.08341L29.3551 38.0415L12.1872 25.0096C10.589 23.7965 8.35363 23.8959 6.86933 25.2461L1.36303 30.2549C-0.452552 31.9064 -0.454633 34.7627 1.35853 36.417L16.2471 50.0001L1.35853 63.5832C-0.454633 65.2374 -0.452552 68.0938 1.36303 69.7453L6.86933 74.7541C8.35363 76.1043 10.589 76.2037 12.1872 74.9905L29.3551 61.9587L68.769 97.9167C69.3925 98.5406 70.1246 99.0104 70.9119 99.3171ZM75.0152 27.2989L45.1091 50.0001L75.0152 72.7012V27.2989Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<path d="M96.4614 10.7962L75.8569 0.875542C73.4719 -0.272773 70.6217 0.211611 68.75 2.08333L1.29858 63.5832C-0.515693 65.2373 -0.513607 68.0937 1.30308 69.7452L6.81272 74.754C8.29793 76.1042 10.5347 76.2036 12.1338 74.9905L93.3609 13.3699C96.086 11.3026 100 13.2462 100 16.6667V16.4275C100 14.0265 98.6246 11.8378 96.4614 10.7962Z" fill="#0065A9"/>
<g filter="url(#filter0_d)">
<path d="M96.4614 89.2038L75.8569 99.1245C73.4719 100.273 70.6217 99.7884 68.75 97.9167L1.29858 36.4169C-0.515693 34.7627 -0.513607 31.9063 1.30308 30.2548L6.81272 25.246C8.29793 23.8958 10.5347 23.7964 12.1338 25.0095L93.3609 86.6301C96.086 88.6974 100 86.7538 100 83.3334V83.5726C100 85.9735 98.6246 88.1622 96.4614 89.2038Z" fill="#007ACC"/>
</g>
<g filter="url(#filter1_d)">
<path d="M75.8578 99.1263C73.4721 100.274 70.6219 99.7885 68.75 97.9166C71.0564 100.223 75 98.5895 75 95.3278V4.67213C75 1.41039 71.0564 -0.223106 68.75 2.08329C70.6219 0.211402 73.4721 -0.273666 75.8578 0.873633L96.4587 10.7807C98.6234 11.8217 100 14.0112 100 16.4132V83.5871C100 85.9891 98.6234 88.1786 96.4586 89.2196L75.8578 99.1263Z" fill="#1F9CF0"/>
</g>
<g style="mix-blend-mode:overlay" opacity="0.25">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.8511 99.3171C72.4261 99.9306 74.2221 99.8913 75.8117 99.1264L96.4 89.2197C98.5634 88.1787 99.9392 85.9892 99.9392 83.5871V16.4133C99.9392 14.0112 98.5635 11.8217 96.4001 10.7807L75.8117 0.873695C73.7255 -0.13019 71.2838 0.115699 69.4527 1.44688C69.1912 1.63705 68.942 1.84937 68.7082 2.08335L29.2943 38.0414L12.1264 25.0096C10.5283 23.7964 8.29285 23.8959 6.80855 25.246L1.30225 30.2548C-0.513334 31.9064 -0.515415 34.7627 1.29775 36.4169L16.1863 50L1.29775 63.5832C-0.515415 65.2374 -0.513334 68.0937 1.30225 69.7452L6.80855 74.754C8.29285 76.1042 10.5283 76.2036 12.1264 74.9905L29.2943 61.9586L68.7082 97.9167C69.3317 98.5405 70.0638 99.0104 70.8511 99.3171ZM74.9544 27.2989L45.0483 50L74.9544 72.7012V27.2989Z" fill="url(#paint0_linear)"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="-8.39411" y="15.8291" width="116.727" height="92.2456" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="60.4167" y="-8.07558" width="47.9167" height="116.151" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="49.9392" y1="0.257812" x2="49.9392" y2="99.7423" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,41 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.9119 99.3171C72.4869 99.9307 74.2828 99.8914 75.8725 99.1264L96.4608 89.2197C98.6242 88.1787 100 85.9892 100 83.5872V16.4133C100 14.0113 98.6243 11.8218 96.4609 10.7808L75.8725 0.873756C73.7862 -0.130129 71.3446 0.11576 69.5135 1.44695C69.252 1.63711 69.0028 1.84943 68.769 2.08341L29.3551 38.0415L12.1872 25.0096C10.589 23.7965 8.35363 23.8959 6.86933 25.2461L1.36303 30.2549C-0.452552 31.9064 -0.454633 34.7627 1.35853 36.417L16.2471 50.0001L1.35853 63.5832C-0.454633 65.2374 -0.452552 68.0938 1.36303 69.7453L6.86933 74.7541C8.35363 76.1043 10.589 76.2037 12.1872 74.9905L29.3551 61.9587L68.769 97.9167C69.3925 98.5406 70.1246 99.0104 70.9119 99.3171ZM75.0152 27.2989L45.1091 50.0001L75.0152 72.7012V27.2989Z" fill="white"/>
</mask>
<g mask="url(#mask0)">
<path d="M96.4614 10.7962L75.8569 0.875542C73.4719 -0.272773 70.6217 0.211611 68.75 2.08333L1.29858 63.5832C-0.515693 65.2373 -0.513607 68.0937 1.30308 69.7452L6.81272 74.754C8.29793 76.1042 10.5347 76.2036 12.1338 74.9905L93.3609 13.3699C96.086 11.3026 100 13.2462 100 16.6667V16.4275C100 14.0265 98.6246 11.8378 96.4614 10.7962Z" fill="#0065A9"/>
<g filter="url(#filter0_d)">
<path d="M96.4614 89.2038L75.8569 99.1245C73.4719 100.273 70.6217 99.7884 68.75 97.9167L1.29858 36.4169C-0.515693 34.7627 -0.513607 31.9063 1.30308 30.2548L6.81272 25.246C8.29793 23.8958 10.5347 23.7964 12.1338 25.0095L93.3609 86.6301C96.086 88.6974 100 86.7538 100 83.3334V83.5726C100 85.9735 98.6246 88.1622 96.4614 89.2038Z" fill="#007ACC"/>
</g>
<g filter="url(#filter1_d)">
<path d="M75.8578 99.1263C73.4721 100.274 70.6219 99.7885 68.75 97.9166C71.0564 100.223 75 98.5895 75 95.3278V4.67213C75 1.41039 71.0564 -0.223106 68.75 2.08329C70.6219 0.211402 73.4721 -0.273666 75.8578 0.873633L96.4587 10.7807C98.6234 11.8217 100 14.0112 100 16.4132V83.5871C100 85.9891 98.6234 88.1786 96.4586 89.2196L75.8578 99.1263Z" fill="#1F9CF0"/>
</g>
<g style="mix-blend-mode:overlay" opacity="0.25">
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.8511 99.3171C72.4261 99.9306 74.2221 99.8913 75.8117 99.1264L96.4 89.2197C98.5634 88.1787 99.9392 85.9892 99.9392 83.5871V16.4133C99.9392 14.0112 98.5635 11.8217 96.4001 10.7807L75.8117 0.873695C73.7255 -0.13019 71.2838 0.115699 69.4527 1.44688C69.1912 1.63705 68.942 1.84937 68.7082 2.08335L29.2943 38.0414L12.1264 25.0096C10.5283 23.7964 8.29285 23.8959 6.80855 25.246L1.30225 30.2548C-0.513334 31.9064 -0.515415 34.7627 1.29775 36.4169L16.1863 50L1.29775 63.5832C-0.515415 65.2374 -0.513334 68.0937 1.30225 69.7452L6.80855 74.754C8.29285 76.1042 10.5283 76.2036 12.1264 74.9905L29.2943 61.9586L68.7082 97.9167C69.3317 98.5405 70.0638 99.0104 70.8511 99.3171ZM74.9544 27.2989L45.0483 50L74.9544 72.7012V27.2989Z" fill="url(#paint0_linear)"/>
</g>
</g>
<defs>
<filter id="filter0_d" x="-8.39411" y="15.8291" width="116.727" height="92.2456" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<filter id="filter1_d" x="60.4167" y="-8.07558" width="47.9167" height="116.151" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
<feOffset/>
<feGaussianBlur stdDeviation="4.16667"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
</filter>
<linearGradient id="paint0_linear" x1="49.9392" y1="0.257812" x2="49.9392" y2="99.7423" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,3 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M897.246 286.869H889.819C850.735 286.808 819.017 318.46 819.017 357.539V515.589C819.017 547.15 792.93 572.716 761.882 572.716C743.436 572.716 725.02 563.433 714.093 547.85L552.673 317.304C539.28 298.16 517.486 286.747 493.895 286.747C457.094 286.747 423.976 318.034 423.976 356.657V515.619C423.976 547.181 398.103 572.746 366.842 572.746C348.335 572.746 329.949 563.463 319.021 547.881L138.395 289.882C134.316 284.038 125.154 286.93 125.154 294.052V431.892C125.154 438.862 127.285 445.619 131.272 451.34L309.037 705.2C319.539 720.204 335.033 731.344 352.9 735.392C397.616 745.557 438.77 711.135 438.77 667.278V508.406C438.77 476.845 464.339 451.279 495.904 451.279H495.995C515.02 451.279 532.857 460.562 543.785 476.145L705.235 706.661C718.659 725.835 739.327 737.218 763.983 737.218C801.606 737.218 833.841 705.9 833.841 667.308V508.376C833.841 476.815 859.41 451.249 890.975 451.249H897.276C901.233 451.249 904.43 448.053 904.43 444.097V294.021C904.43 290.065 901.233 286.869 897.276 286.869H897.246Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,3 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M897.246 286.869H889.819C850.735 286.808 819.017 318.46 819.017 357.539V515.589C819.017 547.15 792.93 572.716 761.882 572.716C743.436 572.716 725.02 563.433 714.093 547.85L552.673 317.304C539.28 298.16 517.486 286.747 493.895 286.747C457.094 286.747 423.976 318.034 423.976 356.657V515.619C423.976 547.181 398.103 572.746 366.842 572.746C348.335 572.746 329.949 563.463 319.021 547.881L138.395 289.882C134.316 284.038 125.154 286.93 125.154 294.052V431.892C125.154 438.862 127.285 445.619 131.272 451.34L309.037 705.2C319.539 720.204 335.033 731.344 352.9 735.392C397.616 745.557 438.77 711.135 438.77 667.278V508.406C438.77 476.845 464.339 451.279 495.904 451.279H495.995C515.02 451.279 532.857 460.562 543.785 476.145L705.235 706.661C718.659 725.835 739.327 737.218 763.983 737.218C801.606 737.218 833.841 705.9 833.841 667.308V508.376C833.841 476.815 859.41 451.249 890.975 451.249H897.276C901.233 451.249 904.43 448.053 904.43 444.097V294.021C904.43 290.065 901.233 286.869 897.276 286.869H897.246Z" fill="#0B100F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -36,6 +36,7 @@
"NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID",
"NEXT_PUBLIC_GOTRUE_URL",
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
"NEXT_PUBLIC_MCP_URL",
// These envs are technically passthrough env vars because they're only used on the server side of Nextjs
"ASSET_CDN_S3_ENDPOINT",
"AWS_ACCESS_KEY_ID",
@@ -131,7 +131,8 @@ export function loadTableEditorStateFromLocalStorage(
schema?: string | null
): SavedState | undefined {
const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
const jsonStr = localStorage.getItem(storageKey)
// Prefer sessionStorage (scoped to current tab) over localStorage
const jsonStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey)
if (!jsonStr) return
const json = JSON.parse(jsonStr)
const tableKey = !schema || schema == 'public' ? tableName : `${schema}.${tableName}`
@@ -154,7 +155,7 @@ export function saveTableEditorStateToLocalStorage({
filters?: string[]
}) {
const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef)
const savedStr = localStorage.getItem(storageKey)
const savedStr = sessionStorage.getItem(storageKey) ?? localStorage.getItem(storageKey)
const tableKey = !schema || schema == 'public' ? tableName : `${schema}.${tableName}`
const config = {
@@ -171,7 +172,9 @@ export function saveTableEditorStateToLocalStorage({
} else {
savedJson = { [tableKey]: config }
}
// Save to both localStorage and sessionStorage so it's consistent to current tab
localStorage.setItem(storageKey, JSON.stringify(savedJson))
sessionStorage.setItem(storageKey, JSON.stringify(savedJson))
}
export const saveTableEditorStateToLocalStorageDebounced = AwesomeDebouncePromise(
@@ -124,7 +124,7 @@ export const RedirectUrlList = ({
<div
className={[
'flex items-center border-overlay bg-studio text-foreground',
'justify-center gap-2 rounded border px-6 py-8 text-sm',
'justify-center gap-2 rounded-b border px-6 py-8 text-sm',
].join(' ')}
>
<EmptyListState
@@ -270,15 +270,6 @@ export const SessionsAuthSettingsForm = () => {
>
<Card>
<CardContent>
{promptProPlanUpgrade && (
<div className="mb-4">
<UpgradeToPro
primaryText="Upgrade to Pro"
secondaryText="Configuring user sessions requires the Pro Plan."
/>
</div>
)}
<FormField_Shadcn_
control={userSessionsForm.control}
name="SESSIONS_SINGLE_PER_USER"
@@ -352,6 +343,15 @@ export const SessionsAuthSettingsForm = () => {
)}
/>
</CardContent>
{promptProPlanUpgrade && (
<UpgradeToPro
fullWidth
primaryText="Configuring user sessions is only available on the Pro Plan and above"
secondaryText="Upgrade to the Pro plan to configure settings for user sessions"
/>
)}
<CardFooter className="justify-end space-x-2">
{userSessionsForm.formState.isDirty && (
<Button type="default" onClick={() => userSessionsForm.reset()}>
@@ -15,7 +15,7 @@ import {
copyToClipboard,
} from 'ui'
import { PROVIDERS_SCHEMAS } from '../AuthProvidersFormValidation'
import { ColumnConfiguration, USERS_TABLE_COLUMNS } from './Users.constants'
import { ColumnConfiguration, UsersTableColumn } from './Users.constants'
import { HeaderCell } from './UsersGridComponents'
const GITHUB_AVATAR_URL = 'https://avatars.githubusercontent.com'
@@ -250,21 +250,23 @@ export function getAvatarUrl(user: User): string | undefined {
}
export const formatUserColumns = ({
columns,
config,
users,
visibleColumns = [],
setSortByValue,
onSelectDeleteUser,
}: {
columns: UsersTableColumn[]
config: ColumnConfiguration[]
users: User[]
visibleColumns?: string[]
setSortByValue: (val: string) => void
onSelectDeleteUser: (user: User) => void
}) => {
const columnOrder = config.map((c) => c.id) ?? USERS_TABLE_COLUMNS.map((c) => c.id)
const columnOrder = config.map((c) => c.id) ?? columns.map((c) => c.id)
let gridColumns = USERS_TABLE_COLUMNS.map((col) => {
let gridColumns = columns.map((col) => {
const savedConfig = config.find((c) => c.id === col.id)
const res: Column<any> = {
key: col.id,
@@ -86,13 +86,25 @@ export const UsersV2 = () => {
authenticationShowSortByEmail: showSortByEmail,
authenticationShowSortByPhone: showSortByPhone,
authenticationShowUserTypeFilter: showUserTypeFilter,
authenticationShowEmailPhoneColumns: showEmailPhoneColumns,
} = useIsFeatureEnabled([
'authentication:show_provider_filter',
'authentication:show_sort_by_email',
'authentication:show_sort_by_phone',
'authentication:show_user_type_filter',
'authentication:show_email_phone_columns',
])
const userTableColumns = useMemo(() => {
if (showEmailPhoneColumns) return USERS_TABLE_COLUMNS
else {
return USERS_TABLE_COLUMNS.filter((col) => {
if (col.id === 'email' || col.id === 'phone') return false
return true
})
}
}, [showEmailPhoneColumns])
const [columns, setColumns] = useState<Column<any>[]>([])
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<Filter>('all')
@@ -265,6 +277,7 @@ export const UsersV2 = () => {
(isErrorStorage && (errorStorage as Error).message.includes('data is undefined')))
) {
const columns = formatUserColumns({
columns: userTableColumns,
config: columnConfiguration ?? [],
users: users ?? [],
visibleColumns: selectedColumns,
@@ -272,7 +285,7 @@ export const UsersV2 = () => {
onSelectDeleteUser: setSelectedUserToDelete,
})
setColumns(columns)
if (columns.length < USERS_TABLE_COLUMNS.length) {
if (columns.length < userTableColumns.length) {
setSelectedColumns(columns.filter((col) => col.key !== 'img').map((col) => col.key))
}
}
@@ -386,7 +399,7 @@ export const UsersV2 = () => {
name={selectedColumns.length === 0 ? 'All columns' : 'Columns'}
title="Select columns to show"
buttonType={selectedColumns.length === 0 ? 'dashed' : 'default'}
options={USERS_TABLE_COLUMNS.slice(1)} // Ignore user image column
options={userTableColumns.slice(1)} // Ignore user image column
labelKey="name"
valueKey="id"
labelClass="text-xs"
@@ -401,19 +414,20 @@ export const UsersV2 = () => {
let updatedConfig = (columnConfiguration ?? []).slice()
if (value.length === 0) {
updatedConfig = USERS_TABLE_COLUMNS.map((c) => ({ id: c.id, width: c.width }))
updatedConfig = userTableColumns.map((c) => ({ id: c.id, width: c.width }))
} else {
value.forEach((col) => {
const hasExisting = updatedConfig.find((c) => c.id === col)
if (!hasExisting)
updatedConfig.push({
id: col,
width: USERS_TABLE_COLUMNS.find((c) => c.id === col)?.width,
width: userTableColumns.find((c) => c.id === col)?.width,
})
})
}
const updatedColumns = formatUserColumns({
columns: userTableColumns,
config: updatedConfig,
users: users ?? [],
visibleColumns: value,
@@ -67,7 +67,7 @@ const CurrentPaymentMethod = () => {
<Button type="outline" asChild>
{subscription?.payment_method_type === 'invoice' ? (
<Link
href={`/support/new?slug=${slug}&ref=no-project&message=${encodeURIComponent('I would like to change my payment method')}&category=${SupportCategories.BILLING}`}
href={`/support/new?slug=${slug}&projectRef=no-project&message=${encodeURIComponent('I would like to change my payment method')}&category=${SupportCategories.BILLING}`}
>
Contact support
</Link>
@@ -350,6 +350,7 @@ export const CONNECTION_TYPES = [
{ key: 'frameworks', label: 'App Frameworks', obj: FRAMEWORKS },
{ key: 'mobiles', label: 'Mobile Frameworks', obj: MOBILES },
{ key: 'orms', label: 'ORMs', obj: ORMS },
{ key: 'mcp', label: 'MCP', obj: [] },
]
export const PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT =
@@ -5,6 +5,7 @@ import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
import { useEffect, useMemo, useState } from 'react'
import { DatabaseConnectionString } from 'components/interfaces/Connect/DatabaseConnectionString'
import { McpTabContent } from 'components/interfaces/Connect/McpTabContent'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import Panel from 'components/ui/Panel'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
@@ -357,6 +358,18 @@ export const Connect = () => {
)
}
if (type.key === 'mcp') {
return (
<TabsContent_Shadcn_
key="mcp"
value="mcp"
className={cn(DIALOG_PADDING_X, DIALOG_PADDING_Y, '!mt-0')}
>
<McpTabContent projectKeys={projectKeys} />
</TabsContent_Shadcn_>
)
}
return (
<TabsContent_Shadcn_
key={`content-${type.key}`}
@@ -22,6 +22,8 @@ interface ConnectDropdownProps {
updateState: (state: string) => void
label: string
items: ConnectionType[]
iconFolder?: string
supportsDarkMode?: boolean
}
export const ConnectDropdown = ({
@@ -30,6 +32,8 @@ export const ConnectDropdown = ({
label,
items,
iconFolder,
supportsDarkMode,
}: ConnectDropdownProps) => {
const [open, setOpen] = useState(false)
@@ -54,7 +58,15 @@ export const ConnectDropdown = ({
iconRight={<ChevronDown strokeWidth={1.5} />}
>
<div className="flex items-center gap-2">
{selectedItem?.icon ? <ConnectionIcon icon={selectedItem.icon} /> : <Box size={12} />}
{selectedItem?.icon ? (
<ConnectionIcon
icon={selectedItem.icon}
iconFolder={iconFolder}
supportsDarkMode={supportsDarkMode}
/>
) : (
<Box size={12} />
)}
{selectedItem?.label}
</div>
</Button>
@@ -76,7 +88,15 @@ export const ConnectDropdown = ({
}}
className="flex gap-2 items-center"
>
{item.icon ? <ConnectionIcon icon={item.icon} /> : <Box size={12} />}
{item.icon ? (
<ConnectionIcon
icon={item.icon}
iconFolder={iconFolder}
supportsDarkMode={supportsDarkMode}
/>
) : (
<Box size={12} />
)}
{item.label}
<Check
size={15}
@@ -5,21 +5,26 @@ import { BASE_PATH } from 'lib/constants'
interface ConnectionIconProps {
icon: string
iconFolder?: string
supportsDarkMode?: boolean
}
export const ConnectionIcon = ({ icon }: ConnectionIconProps) => {
export const ConnectionIcon = ({ icon, iconFolder, supportsDarkMode }: ConnectionIconProps) => {
const { resolvedTheme } = useTheme()
const imageFolder = ['ionic-angular'].includes(icon) ? 'icons/frameworks' : 'libraries'
const imageFolder =
iconFolder || (['ionic-angular'].includes(icon) ? 'icons/frameworks' : 'libraries')
const imageExtension = imageFolder === 'icons/frameworks' ? '' : '-icon'
const shouldUseDarkMode =
supportsDarkMode ||
['expo', 'nextjs', 'prisma', 'drizzle', 'astro', 'remix'].includes(icon.toLowerCase())
const iconImgSrc = icon.startsWith('http')
? icon
: `${BASE_PATH}/img/${imageFolder}/${icon.toLowerCase()}${
['expo', 'nextjs', 'prisma', 'drizzle', 'astro', 'remix'].includes(icon.toLowerCase())
? resolvedTheme?.includes('dark')
? '-dark'
: ''
: ''
shouldUseDarkMode && resolvedTheme?.includes('dark') ? '-dark' : ''
}${imageExtension}.svg`
return (
@@ -0,0 +1,50 @@
import { IS_PLATFORM, useParams } from 'common'
import Panel from 'components/ui/Panel'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { BASE_PATH } from 'lib/constants'
import { useTheme } from 'next-themes'
import { McpConfigPanel } from 'ui-patterns/McpUrlBuilder'
import type { projectKeys } from './Connect.types'
export const McpTabContent = ({ projectKeys }: { projectKeys: projectKeys }) => {
const { ref: projectRef } = useParams()
return (
<Panel className="bg-inherit border-none shadow-none">
{projectRef ? (
<McpTabContentInnerLoaded projectRef={projectRef} projectKeys={projectKeys} />
) : (
<McpTabContentInnerLoading />
)}
</Panel>
)
}
const McpTabContentInnerLoading = () => {
return (
<div className="flex flex-col gap-2">
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)
}
const McpTabContentInnerLoaded = ({
projectRef,
projectKeys,
}: {
projectRef: string
projectKeys: projectKeys
}) => {
const { resolvedTheme } = useTheme()
return (
<McpConfigPanel
basePath={BASE_PATH}
projectRef={projectRef}
theme={resolvedTheme as 'light' | 'dark'}
isPlatform={IS_PLATFORM}
apiUrl={projectKeys.apiUrl ?? undefined}
/>
)
}
@@ -1,5 +1,6 @@
import Link from 'next/link'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Badge, NavMenu, NavMenuItem } from 'ui'
@@ -9,6 +10,7 @@ type Props = {
function DatabaseBackupsNav({ active }: Props) {
const { ref, cloud_provider } = useSelectedProjectQuery()?.data || {}
const { databaseRestoreToNewProject } = useIsFeatureEnabled(['database:restore_to_new_project'])
const navMenuItems = [
{
@@ -24,7 +26,7 @@ function DatabaseBackupsNav({ active }: Props) {
href: `/project/${ref}/database/backups/pitr`,
},
{
enabled: cloud_provider !== 'FLY',
enabled: databaseRestoreToNewProject && cloud_provider !== 'FLY',
id: 'rtnp',
label: (
<div className="flex items-center gap-1">
@@ -0,0 +1,50 @@
import { ChevronRightIcon } from 'lucide-react'
import Link from 'next/link'
import { CloneStatus } from 'data/projects/clone-status-query'
import { TimestampInfo } from 'ui-patterns'
import { StatusBadge } from './StatusBadge'
export const PreviousRestoreItem = ({
clone,
}: {
clone: NonNullable<CloneStatus['clones']>[number]
}) => {
if (clone.status === 'REMOVED') {
return (
<div className="grid grid-cols-4 gap-2 text-sm p-4 group">
<div className="min-w-24 truncate">{(clone.target_project as any).name}</div>
<div>
<StatusBadge status={clone.status} />
</div>
<div>
<TimestampInfo
className="font-mono text-xs text-foreground-lighter"
utcTimestamp={clone.inserted_at ?? ''}
/>
</div>
</div>
)
} else {
return (
<Link
href={`/project/${clone.target_project.ref}`}
className="grid grid-cols-4 gap-2 text-sm p-4 group"
>
<div className="min-w-24 truncate">{(clone.target_project as any).name}</div>
<div>
<StatusBadge status={clone.status} />
</div>
<div>
<TimestampInfo
className="font-mono text-xs text-foreground-lighter"
utcTimestamp={clone.inserted_at ?? ''}
/>
</div>
<div className="flex items-center justify-end text-foreground-lighter group-hover:text-foreground">
<ChevronRightIcon className="w-4 h-4" />
</div>
</Link>
)
}
}
@@ -0,0 +1,328 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form'
import { BackupsList } from 'components/interfaces/Database/Backups/RestoreToNewProject/BackupsList'
import { ConfirmRestoreDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog'
import { CreateNewProjectDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog'
import { projectSpecToMonthlyPrice } from 'components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils'
import { DiskType } from 'components/interfaces/DiskManagement/ui/DiskManagement.constants'
import { Markdown } from 'components/interfaces/Markdown'
import AlertError from 'components/ui/AlertError'
import NoPermission from 'components/ui/NoPermission'
import Panel from 'components/ui/Panel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useDiskAttributesQuery } from 'data/config/disk-attributes-query'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useCloneStatusQuery } from 'data/projects/clone-status-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import {
useIsAwsK8sCloudProvider,
useIsOrioleDb,
useSelectedProjectQuery,
} from 'hooks/misc/useSelectedProject'
import { DOCS_URL, PROJECT_STATUS } from 'lib/constants'
import { getDatabaseMajorVersion } from 'lib/helpers'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui'
import { Admonition } from 'ui-patterns'
import { PreviousRestoreItem } from './PreviousRestoreItem'
export const RestoreToNewProject = () => {
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const isFreePlan = organization?.plan?.id === 'free'
const isOrioleDb = useIsOrioleDb()
const isAwsK8s = useIsAwsK8sCloudProvider()
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const [selectedBackupId, setSelectedBackupId] = useState<number | null>(null)
const [showConfirmationDialog, setShowConfirmationDialog] = useState(false)
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false)
const [recoveryTimeTarget, setRecoveryTimeTarget] = useState<number | null>(null)
const {
data: cloneBackups,
error,
isLoading: cloneBackupsLoading,
isError,
} = useCloneBackupsQuery({ projectRef: project?.ref }, { enabled: !isFreePlan })
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.READ,
'physical_backups'
)
const { can: canTriggerPhysicalBackups } = useAsyncCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
)
const PITR_ENABLED = cloneBackups?.pitr_enabled
const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '')
const IS_PG15_OR_ABOVE = dbVersion >= 15
const targetVolumeSizeGb = cloneBackups?.target_volume_size_gb
const targetComputeSize = cloneBackups?.target_compute_size
const planId = organization?.plan?.id ?? 'free'
const { data } = useDiskAttributesQuery({ projectRef: project?.ref })
const storageType = data?.attributes?.type ?? 'gp3'
const {
data: cloneStatus,
refetch: refetchCloneStatus,
isLoading: cloneStatusLoading,
} = useCloneStatusQuery(
{
projectRef: project?.ref,
},
{
refetchInterval,
refetchOnWindowFocus: false,
onSuccess: (data) => {
const hasTransientState = data?.clones.some((c) => c.status === 'IN_PROGRESS')
if (!hasTransientState) setRefetchInterval(false)
},
enabled: PHYSICAL_BACKUPS_ENABLED || PITR_ENABLED,
}
)
const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false
const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading
const previousClones = cloneStatus?.clones
const isRestoring = previousClones?.some((c) => c.status === 'IN_PROGRESS')
const restoringClone = previousClones?.find((c) => c.status === 'IN_PROGRESS')
if (organization?.managed_by === 'vercel-marketplace') {
return (
<Admonition
type="default"
title="Restore to new project is not available for Vercel Marketplace organizations"
description="Restoring project backups to a new project created via Vercel Marketplace is not supported yet."
/>
)
}
if (isFreePlan) {
return (
<UpgradeToPro
buttonText="Upgrade"
source="backupsRestoreToNewProject"
primaryText="Restore to a new project requires a pro plan or above."
secondaryText="To restore to a new project, you need to upgrade to a Pro plan and have physical backups enabled."
/>
)
}
if (isOrioleDb) {
return (
<Admonition
type="default"
title="Restoring to new projects are not available for OrioleDB"
description="OrioleDB is currently in public alpha and projects created are strictly ephemeral with no database backups"
/>
)
}
if (isAwsK8s) {
return (
<Admonition
type="default"
title="Restoring to new projects is temporarily not available for AWS (Revamped) projects"
/>
)
}
if (!canReadPhysicalBackups) {
return <NoPermission resourceText="view backups" />
}
if (!canTriggerPhysicalBackups) {
return <NoPermission resourceText="restore backups" />
}
if (!IS_PG15_OR_ABOVE) {
return (
<Admonition
type="default"
title="Restore to new project is not available for this database version"
>
<Markdown
className="max-w-full"
content={`Restore to new project is only available for Postgres 15 and above.
Go to [infrastructure settings](/project/${project?.ref}/settings/infrastructure)
to upgrade your database version.
`}
/>
</Admonition>
)
}
if (!PHYSICAL_BACKUPS_ENABLED) {
return (
<Admonition
type="default"
title="Restore to new project requires physical backups"
description={
<>
Physical backups must be enabled to restore your database to a new project.
<br /> Find out more about how backups work at supabase{' '}
<Link
target="_blank"
className="underline"
href={`${DOCS_URL}/guides/platform/backups`}
>
in our docs
</Link>
.
</>
}
/>
)
}
if (isLoading) {
return <GenericSkeletonLoader />
}
if (IS_CLONED_PROJECT) {
return (
<Admonition type="default" title="This project cannot be restored to a new project">
<Markdown
className="max-w-full [&>p]:!leading-normal"
content={`This is a temporary limitation whereby projects that were originally restored from another project cannot be restored to yet another project.
If you need to restore from a restored project, please reach out via [support](/support/new?projectRef=${project?.ref}).`}
/>
<Button asChild type="default">
<Link href={`/project/${(cloneStatus?.cloned_from?.source_project as any)?.ref || ''}`}>
Go to original project
</Link>
</Button>
</Admonition>
)
}
if (isError) {
return <AlertError error={error} subject="Failed to retrieve backups" />
}
if (!isActiveHealthy) {
return (
<Admonition
type="default"
title="Restore to new project is not available while project is offline"
description="Your project needs to be online to restore your database to a new project"
/>
)
}
if (
!isLoading &&
PITR_ENABLED &&
!cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix
) {
return (
<Admonition
type="default"
title="No backups found"
description="PITR is enabled, but no backups were found. Check again in a few minutes."
/>
)
}
if (!isLoading && !PITR_ENABLED && cloneBackups?.backups.length === 0) {
return (
<>
<Admonition
type="default"
title="No backups found"
description="Backups are enabled, but no backups were found. Check again tomorrow."
/>
</>
)
}
const additionalMonthlySpend = projectSpecToMonthlyPrice({
targetVolumeSizeGb: targetVolumeSizeGb ?? 0,
targetComputeSize: targetComputeSize ?? 'nano',
planId: planId ?? 'free',
storageType: storageType as DiskType,
})
return (
<div className="flex flex-col gap-4">
<ConfirmRestoreDialog
open={showConfirmationDialog}
onOpenChange={setShowConfirmationDialog}
onSelectContinue={() => {
setShowConfirmationDialog(false)
setShowNewProjectDialog(true)
}}
additionalMonthlySpend={additionalMonthlySpend}
/>
<CreateNewProjectDialog
open={showNewProjectDialog}
selectedBackupId={selectedBackupId}
recoveryTimeTarget={recoveryTimeTarget}
additionalMonthlySpend={additionalMonthlySpend}
onOpenChange={setShowNewProjectDialog}
onCloneSuccess={() => {
refetchCloneStatus()
setRefetchInterval(5000)
setShowNewProjectDialog(false)
}}
/>
{isRestoring ? (
<Alert_Shadcn_ className="[&>svg]:bg-none! [&>svg]:text-foreground-light mb-6">
<Loader2 className="animate-spin" />
<AlertTitle_Shadcn_>Restoration in progress</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>
The new project {(restoringClone?.target_project as any)?.name || ''} is currently
being created. You'll be able to restore again once the project is ready.
</p>
<Button asChild type="default" className="mt-2">
<Link href={`/project/${restoringClone?.target_project.ref}`}>Go to new project</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : null}
{previousClones?.length ? (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Previous restorations</h3>
<Panel className="flex flex-col divide-y divide-border">
{previousClones?.map((c) => <PreviousRestoreItem key={c.inserted_at} clone={c} />)}
</Panel>
</div>
) : null}
{PITR_ENABLED ? (
<>
<PITRForm
disabled={isRestoring}
onSubmit={(v) => {
setShowConfirmationDialog(true)
setRecoveryTimeTarget(v.recoveryTimeTargetUnix)
}}
earliestAvailableBackupUnix={
cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0
}
latestAvailableBackupUnix={
cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0
}
/>
</>
) : (
<BackupsList
disabled={isRestoring}
onSelectRestore={(id) => {
setSelectedBackupId(id)
setShowConfirmationDialog(true)
}}
/>
)}
</div>
)
}
@@ -0,0 +1,25 @@
import { CloneStatus } from 'data/projects/clone-status-query'
import { Badge } from 'ui'
export const StatusBadge = ({
status,
}: {
status: NonNullable<CloneStatus['clones']>[number]['status']
}) => {
const statusTextMap = {
IN_PROGRESS: 'RESTORING',
COMPLETED: 'COMPLETED',
REMOVED: 'REMOVED',
FAILED: 'FAILED',
}
if (status === 'IN_PROGRESS') {
return <Badge variant="warning">{statusTextMap[status]}</Badge>
}
if (status === 'FAILED') {
return <Badge variant="destructive">{statusTextMap[status]}</Badge>
}
return <Badge>{statusTextMap[status]}</Badge>
}
@@ -1,3 +1,4 @@
import { CloudProvider } from 'shared-data'
import { z } from 'zod'
import { ComputeInstanceAddonVariantId } from './DiskManagement.types'
import {
@@ -9,7 +10,6 @@ import {
formatNumber,
} from './DiskManagement.utils'
import { DISK_LIMITS, DiskType } from './ui/DiskManagement.constants'
import { CloudProvider } from 'shared-data'
const baseSchema = z.object({
storageType: z.enum(['io2', 'gp3']).describe('Type of storage: io2 or gp3'),
@@ -41,13 +41,22 @@ const baseSchema = z.object({
.nullable(),
})
export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider: CloudProvider) => {
export const CreateDiskStorageSchema = ({
defaultTotalSize,
cloudProvider,
}: {
defaultTotalSize: number
cloudProvider: CloudProvider
}) => {
const isFlyProject = cloudProvider === 'FLY'
const isAwsNimbusProject = cloudProvider === 'AWS_NIMBUS'
const validateDiskConfiguration = !isFlyProject && !isAwsNimbusProject
const schema = baseSchema.superRefine((data, ctx) => {
const { storageType, totalSize, provisionedIOPS, throughput, maxSizeGb } = data
if (!isFlyProject && totalSize < 8) {
if (validateDiskConfiguration && totalSize < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Allocated disk size must be at least 8 GB.',
@@ -55,7 +64,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider:
})
}
if (!isFlyProject && totalSize < defaultTotalSize) {
if (validateDiskConfiguration && totalSize < defaultTotalSize) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Disk size cannot be reduced in size. Reduce your database size and then head to the Infrastructure settings and go through a Postgres version upgrade to right-size your disk.`,
@@ -64,7 +73,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider:
}
// Validate maxSizeGb cannot be lower than totalSize
if (!isFlyProject && !!maxSizeGb && maxSizeGb < totalSize) {
if (validateDiskConfiguration && !!maxSizeGb && maxSizeGb < totalSize) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Max disk size cannot be lower than the current disk size. Must be at least ${formatNumber(totalSize)} GB.`,
@@ -72,7 +81,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider:
})
}
if (!isFlyProject && storageType === 'io2') {
if (validateDiskConfiguration && storageType === 'io2') {
// Validation rules for io2
if (provisionedIOPS > DISK_LIMITS[DiskType.IO2].maxIops) {
@@ -129,7 +138,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider:
}
}
if (!isFlyProject && storageType === 'gp3') {
if (validateDiskConfiguration && storageType === 'gp3') {
const maxIopsAllowedForDiskSizeWithGp3 = calculateMaxIopsAllowedForDiskSizeWithGp3(totalSize)
if (provisionedIOPS > DISK_LIMITS[DiskType.GP3].maxIops) {
@@ -3,7 +3,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -11,6 +10,7 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { MAX_WIDTH_CLASSES, PADDING_CLASSES, ScaffoldContainer } from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import {
useDiskAttributesQuery,
useRemainingDurationForDiskAttributeUpdate,
@@ -30,6 +30,7 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization
import {
useIsAwsCloudProvider,
useIsAwsK8sCloudProvider,
useIsAwsNimbusCloudProvider,
useSelectedProjectQuery,
} from 'hooks/misc/useSelectedProject'
import { DOCS_URL, GB, PROJECT_STATUS } from 'lib/constants'
@@ -78,6 +79,7 @@ export function DiskManagementForm() {
const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled
const isAws = useIsAwsCloudProvider()
const isAwsK8s = useIsAwsK8sCloudProvider()
const isAwsNimbus = useIsAwsNimbusCloudProvider()
const { can: canUpdateDiskConfiguration, isSuccess: isPermissionsLoaded } =
useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', {
@@ -156,7 +158,10 @@ export function DiskManagementForm() {
const form = useForm<DiskStorageSchemaType>({
resolver: zodResolver(
CreateDiskStorageSchema(defaultValues.totalSize, project?.cloud_provider as CloudProvider)
CreateDiskStorageSchema({
defaultTotalSize: defaultValues.totalSize,
cloudProvider: project?.cloud_provider as CloudProvider,
})
),
defaultValues,
mode: 'onBlur',
@@ -241,12 +246,14 @@ export function DiskManagementForm() {
let willUpdateDiskConfiguration = false
setMessageState(null)
// [Joshen] Skip disk configuration related stuff for AWS Nimbus
try {
if (
payload.storageType !== form.formState.defaultValues?.storageType ||
payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS ||
payload.throughput !== form.formState.defaultValues?.throughput ||
payload.totalSize !== form.formState.defaultValues?.totalSize
!isAwsNimbus &&
(payload.storageType !== form.formState.defaultValues?.storageType ||
payload.provisionedIOPS !== form.formState.defaultValues?.provisionedIOPS ||
payload.throughput !== form.formState.defaultValues?.throughput ||
payload.totalSize !== form.formState.defaultValues?.totalSize)
) {
willUpdateDiskConfiguration = true
@@ -260,9 +267,10 @@ export function DiskManagementForm() {
}
if (
payload.growthPercent !== form.formState.defaultValues?.growthPercent ||
payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb ||
payload.maxSizeGb !== form.formState.defaultValues?.maxSizeGb
!isAwsNimbus &&
(payload.growthPercent !== form.formState.defaultValues?.growthPercent ||
payload.minIncrementGb !== form.formState.defaultValues?.minIncrementGb ||
payload.maxSizeGb !== form.formState.defaultValues?.maxSizeGb)
) {
await updateDiskAutoscaleConfig({
projectRef,
@@ -310,15 +318,7 @@ export function DiskManagementForm() {
type="default"
visible={isPlanUpgradeRequired}
title="Compute and Disk configuration is not available on the Free Plan"
actions={
<Button type="default" asChild>
<Link
href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=diskManagementConfigure`}
>
Upgrade plan
</Link>
</Button>
}
actions={<UpgradePlanButton source="diskManagementConfigure" plan="Pro" />}
description="You will need to upgrade to at least the Pro Plan to configure compute and disk"
/>
@@ -344,12 +344,16 @@ export function DiskManagementForm() {
</div>
) : null}
<Separator />
<ComputeSizeField form={form} disabled={disableComputeInputs} />
<Separator />
{!(isAws || isAwsNimbus) && <Separator />}
<SpendCapDisabledSection />
<NoticeBar
type="default"
visible={!isAws}
visible={!(isAws || isAwsNimbus)}
title="Disk configuration is only available for projects in the AWS cloud provider"
description={
isAwsK8s
@@ -7,7 +7,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsAwsNimbusCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { formatCurrency } from 'lib/helpers'
import {
Alert_Shadcn_,
@@ -150,6 +150,7 @@ export const DiskManagementReviewAndSubmitDialog = ({
}: DiskSizeMeterProps) => {
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const isAwsNimbus = useIsAwsNimbusCloudProvider()
const { formState, getValues } = form
@@ -213,19 +214,22 @@ export const DiskManagementReviewAndSubmitDialog = ({
const hasComputeChanges =
form.formState.defaultValues?.computeSize !== form.getValues('computeSize')
const hasTotalSizeChanges =
form.formState.defaultValues?.totalSize !== form.getValues('totalSize')
!isAwsNimbus && form.formState.defaultValues?.totalSize !== form.getValues('totalSize')
const hasStorageTypeChanges =
form.formState.defaultValues?.storageType !== form.getValues('storageType')
!isAwsNimbus && form.formState.defaultValues?.storageType !== form.getValues('storageType')
const hasThroughputChanges =
form.formState.defaultValues?.throughput !== form.getValues('throughput')
!isAwsNimbus && form.formState.defaultValues?.throughput !== form.getValues('throughput')
const hasIOPSChanges =
!isAwsNimbus &&
form.formState.defaultValues?.provisionedIOPS !== form.getValues('provisionedIOPS')
const hasGrowthPercentChanges =
form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent')
!isAwsNimbus && form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent')
const hasMinIncrementChanges =
!isAwsNimbus &&
form.formState.defaultValues?.minIncrementGb !== form.getValues('minIncrementGb')
const hasMaxSizeChanges = form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb')
const hasMaxSizeChanges =
!isAwsNimbus && form.formState.defaultValues?.maxSizeGb !== form.getValues('maxSizeGb')
const hasDiskConfigChanges =
hasIOPSChanges ||
@@ -191,6 +191,10 @@ const AddNewSecretForm = () => {
<Input
{...field}
type={showSecretValue ? 'text' : 'password'}
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
actions={
<div className="mr-1">
<Button
@@ -18,9 +18,7 @@ const CATEGORIES = [
] as const
export const AvailableIntegrations = () => {
const { integrationsShowStripeWrapper } = useIsFeatureEnabled([
'integrations:show_stripe_wrapper',
])
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const [selectedCategory, setSelectedCategory] = useQueryState(
'category',
@@ -43,9 +41,9 @@ export const AvailableIntegrations = () => {
const installedIds = installedIntegrations.map((i) => i.id)
// available integrations for install
const availableIntegrations = integrationsShowStripeWrapper
const availableIntegrations = integrationsWrappers
? allIntegrations
: allIntegrations.filter((x) => x.id !== 'stripe_wrapper')
: allIntegrations.filter((x) => !x.id.endsWith('_wrapper'))
const integrationsByCategory =
selectedCategory === 'all'
? availableIntegrations
@@ -3,6 +3,7 @@ import { useMemo } from 'react'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { EMPTY_ARR } from 'lib/void'
import { wrapperMetaComparator } from '../Wrappers/Wrappers.utils'
@@ -10,6 +11,15 @@ import { INTEGRATIONS } from './Integrations.constants'
export const useInstalledIntegrations = () => {
const { data: project } = useSelectedProjectQuery()
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const allIntegrations = useMemo(() => {
if (integrationsWrappers) {
return INTEGRATIONS
} else {
return INTEGRATIONS.filter((integration) => !integration.id.endsWith('_wrapper'))
}
}, [integrationsWrappers])
const {
data,
@@ -47,28 +57,30 @@ export const useInstalledIntegrations = () => {
const wrappers = useMemo(() => data ?? EMPTY_ARR, [data])
const installedIntegrations = useMemo(() => {
return INTEGRATIONS.filter((i) => {
// special handling for supabase webhooks
if (i.id === 'webhooks') {
return isHooksEnabled
}
if (i.type === 'wrapper') {
return wrappers.find((w) => wrapperMetaComparator(i.meta, w))
}
if (i.type === 'postgres_extension') {
return i.requiredExtensions.every((extName) => {
const foundExtension = (extensions ?? []).find((ext) => ext.name === extName)
return !!foundExtension?.installed_version
})
}
return false
}).sort((a, b) => a.name.localeCompare(b.name))
return allIntegrations
.filter((i) => {
// special handling for supabase webhooks
if (i.id === 'webhooks') {
return isHooksEnabled
}
if (i.type === 'wrapper') {
return wrappers.find((w) => wrapperMetaComparator(i.meta, w))
}
if (i.type === 'postgres_extension') {
return i.requiredExtensions.every((extName) => {
const foundExtension = (extensions ?? []).find((ext) => ext.name === extName)
return !!foundExtension?.installed_version
})
}
return false
})
.sort((a, b) => a.name.localeCompare(b.name))
}, [wrappers, extensions, isHooksEnabled])
// available integrations are all integrations that can be installed. If an integration can't be installed (needed
// extensions are not available on this DB image), the UI will provide a tooltip explaining why.
const availableIntegrations = useMemo(
() => INTEGRATIONS.sort((a, b) => a.name.localeCompare(b.name)),
() => allIntegrations.sort((a, b) => a.name.localeCompare(b.name)),
[]
)
@@ -3,7 +3,6 @@ import { useParams } from 'common'
import dayjs from 'dayjs'
import { ArrowDown, ArrowUp, RefreshCw, User } from 'lucide-react'
import Image from 'next/legacy/image'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { LogDetailsPanel } from 'components/interfaces/AuditLogs'
@@ -15,6 +14,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FilterPopover } from 'components/ui/FilterPopover'
import NoPermission from 'components/ui/NoPermission'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import {
AuditLog,
@@ -231,11 +231,9 @@ export const AuditLogs = () => {
</div>
<div className="flex items-center">
<Button type="primary" asChild>
<Link href={`/org/${slug}/billing?panel=subscriptionPlan&source=auditLogs`}>
Upgrade subscription
</Link>
</Button>
<UpgradePlanButton source="auditLogs" plan="Team" type="primary">
Upgrade subscription
</UpgradePlanButton>
</div>
</div>
</Alert_Shadcn_>
@@ -1,12 +1,13 @@
import Link from 'next/link'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { PricingMetric } from 'data/analytics/org-daily-stats-query'
import type { OrgSubscription } from 'data/subscriptions/types'
import type { OrgUsageResponse } from 'data/usage/org-usage-query'
import { formatCurrency } from 'lib/helpers'
import { ChevronRight } from 'lucide-react'
import { useMemo } from 'react'
import { Button, cn, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui'
import { cn, HoverCard, HoverCardContent, HoverCardTrigger } from 'ui'
import { billingMetricUnit, formatUsage } from '../helpers'
import { Metric, USAGE_APPROACHING_THRESHOLD } from './BillingBreakdown.constants'
@@ -156,13 +157,9 @@ export const BillingMetric = ({
</div>
) : (
<div>
<Button type="default" asChild>
<Link
href={`/org/${slug}/billing?panel=subscriptionPlan&source=billingBreakdownUsage${metric.anchor}`}
>
Upgrade
</Link>
</Button>
<UpgradePlanButton source={`billingBreakdownUsage${metric.anchor}`}>
Upgrade
</UpgradePlanButton>
</div>
)}
</div>
@@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { useEffect } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import z from 'zod'
@@ -8,6 +7,7 @@ import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { InlineLink } from 'components/ui/InlineLink'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useSSOConfigCreateMutation } from 'data/sso/sso-config-create-mutation'
import { useOrgSSOConfigQuery } from 'data/sso/sso-config-query'
import { useSSOConfigUpdateMutation } from 'data/sso/sso-config-update-mutation'
@@ -67,7 +67,7 @@ export type SSOConfigFormSchema = z.infer<typeof FormSchema>
export const SSOConfig = () => {
const FORM_ID = 'sso-config-form'
const { data: organization, isLoading: isLoadingOrganization } = useSelectedOrganizationQuery()
const { data: organization } = useSelectedOrganizationQuery()
const plan = organization?.plan.id
const canSetupSSOConfig = ['team', 'enterprise'].includes(plan ?? '')
@@ -179,13 +179,7 @@ export const SSOConfig = () => {
</div>
<div className="flex items-center">
<Button type="primary" asChild>
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=sso`}
>
Upgrade to Team
</Link>
</Button>
<UpgradePlanButton type="primary" source="sso" plan="Team" />
</div>
</div>
</Alert_Shadcn_>
@@ -1,6 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -12,6 +11,7 @@ import AlertError from 'components/ui/AlertError'
import { InlineLink } from 'components/ui/InlineLink'
import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
import { useOrganizationMfaToggleMutation } from 'data/organizations/organization-mfa-mutation'
import { useOrganizationMfaQuery } from 'data/organizations/organization-mfa-query'
@@ -128,11 +128,9 @@ export const SecuritySettings = () => {
</div>
<div className="flex items-center">
<Button type="primary" asChild>
<Link href={`/org/${slug}/billing?panel=subscriptionPlan&source=mfa`}>
Upgrade subscription
</Link>
</Button>
<UpgradePlanButton type="primary" source="mfa" plan="Pro">
Upgrade subscription
</UpgradePlanButton>
</div>
</div>
</Alert_Shadcn_>
@@ -10,6 +10,7 @@ import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import InformationBox from 'components/ui/InformationBox'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useOrganizationCreateInvitationMutation } from 'data/organization-members/organization-invitation-create-mutation'
import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query'
import { useOrganizationMembersQuery } from 'data/organizations/organization-members-query'
@@ -309,13 +310,7 @@ export const InviteMemberButton = () => {
</Link>
</Button>
{(currentPlan?.id === 'free' || currentPlan?.id === 'pro') && (
<Button asChild type="default">
<Link
href={`/org/${slug}/billing?panel=subscriptionPlan&source=inviteMemberSSO`}
>
Upgrade to Team
</Link>
</Button>
<UpgradePlanButton source="inviteMemberSSO" plan="Team" />
)}
</div>
</div>
@@ -9,7 +9,8 @@ import { useParams } from 'common'
import { ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { InlineLink } from 'components/ui/InlineLink'
import { ToggleSpendCapButton } from 'components/ui/ToggleSpendCapButton'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useMaxConnectionsQuery } from 'data/database/max-connections-query'
import { useRealtimeConfigurationUpdateMutation } from 'data/realtime/realtime-config-mutation'
@@ -61,6 +62,7 @@ export const RealtimeSettings = () => {
schema: 'realtime',
})
const isFreePlan = organization?.plan.id === 'free'
const isUsageBillingEnabled = organization?.usage_billing_enabled
// Check if RLS policies exist for realtime.messages table
@@ -255,21 +257,27 @@ export const RealtimeSettings = () => {
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
{isSuccessOrganization && !isUsageBillingEnabled && (
<Admonition
showIcon={false}
type="default"
title="Spend cap needs to be disabled to configure this value"
description={
<>
You may adjust this setting in the{' '}
<InlineLink
href={`/org/${organization?.slug}/billing?panel=costControl`}
>
organization billing settings
</InlineLink>
</>
}
/>
<Admonition showIcon={false} type="default">
<div className="flex items-center gap-x-2">
<div>
<h5 className="text-foreground mb-1">
Spend cap needs to be disabled to configure this value
</h5>
<p className="text-foreground-light">
{isFreePlan
? 'Upgrade to the Pro plan first to disable spend cap'
: 'You may adjust this setting in the organization billing settings'}
</p>
</div>
<div className="flex-grow flex items-center justify-end">
{false ? (
<UpgradePlanButton source="realtimeSettings" plan="Pro" />
) : (
<ToggleSpendCapButton />
)}
</div>
</div>
</Admonition>
)}
</FormSectionContent>
</FormSection>
@@ -18,6 +18,10 @@ export const DeprecatedChartBlock = ({ label, attribute, actions }: DeprecatedCh
const getLogsUrl = (logsName?: string) => {
switch (logsName) {
case 'Database API':
return '/logs/postgrest-logs'
case 'All API usage':
return '/logs/explorer'
case 'Realtime':
return '/logs/realtime-logs'
case 'Storage':
@@ -38,14 +42,16 @@ export const DeprecatedChartBlock = ({ label, attribute, actions }: DeprecatedCh
label={label}
actions={actions}
>
<p className="text-xs text-foreground-light">
This chart is not longer available, and can be removed from your report
</p>
<p className="text-xs text-foreground-lighter">
You may view the equivalent of this data from the{' '}
<InlineLink href={`/project/${ref}/${getLogsUrl(logsName)}`}>{logsName} Logs</InlineLink>{' '}
instead
</p>
<div className="flex flex-col justify-center flex-1">
<p className="text-xs text-foreground-lightr">
This chart is not longer available, and can be removed from your report
</p>
<p className="text-xs text-foreground-lighter">
You may view the equivalent of this data from the{' '}
<InlineLink href={`/project/${ref}/${getLogsUrl(logsName)}`}>{logsName} Logs</InlineLink>{' '}
instead
</p>
</div>
</ReportBlockContainer>
)
}
@@ -638,6 +638,20 @@ export const DEPRECATED_REPORTS = [
'total_storage_options_requests',
'total_storage_patch_requests',
'total_options_requests',
'total_rest_ingress',
'total_rest_get_requests',
'total_rest_post_requests',
'total_rest_patch_requests',
'total_rest_delete_requests',
'total_storage_get_requests',
'total_storage_post_requests',
'total_storage_delete_requests',
'total_auth_delete_requests',
'total_get_requests',
'total_patch_requests',
'total_post_requests',
'total_ingress',
'total_delete_requests',
]
export const EDGE_FUNCTION_REGIONS = [
@@ -7,6 +7,7 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import { UpgradePlanButton } from 'components/ui/UpgradePlanButton'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation'
@@ -271,15 +272,7 @@ const PITRSidePanel = () => {
variant="info"
className="mb-4"
title="Changing your Point-In-Time-Recovery is only available on the Pro Plan"
actions={
<Button asChild type="default">
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=pitrSidePanel`}
>
View available plans
</Link>
</Button>
}
actions={<UpgradePlanButton source="pitrSidePanel" plan="Pro" />}
>
Upgrade your plan to change PITR for your project
</Alert>
@@ -13,8 +13,9 @@ import Panel from 'components/ui/Panel'
import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation'
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsAwsNimbusCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useUrlState } from 'hooks/ui/useUrlState'
import { DOCS_URL } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
@@ -29,6 +30,9 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const isAwsNimbus = useIsAwsNimbusCloudProvider()
const { reportsAll } = useIsFeatureEnabled(['reports:all'])
const [{ show_increase_disk_size_modal }, setUrlParams] = useUrlState()
const showIncreaseDiskSizeModal = show_increase_disk_size_modal === 'true'
const setShowIncreaseDiskSizeModal = (value: SetStateAction<boolean>) => {
@@ -80,9 +84,10 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
Supabase employs auto-scaling storage and allows for manual disk size
adjustments when necessary
</p>
<div className="flex items-end justify-end">
{!isAwsNimbus && (
<ButtonTooltip
type="default"
className="w-min ml-auto"
disabled={!canUpdateDiskSizeConfig || disabled}
onClick={() => setShowIncreaseDiskSizeModal(true)}
tooltip={{
@@ -96,7 +101,7 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
>
Increase disk size
</ButtonTooltip>
</div>
)}
</div>
<div className="grid grid-cols-12 gap-2 mt-12 items-start">
@@ -112,15 +117,17 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
<span className="text-lg">{currentDiskSize} GB</span>
</div>
<div className="col-span-2 mt-4">
<Button asChild type="default" iconRight={<ExternalLink size={14} />}>
<Link
href={`/project/${projectRef}/reports/database#database-size-report`}
>
View detailed summary
</Link>
</Button>
</div>
{reportsAll && (
<div className="col-span-2 mt-4">
<Button asChild type="default" iconRight={<ExternalLink size={14} />}>
<Link
href={`/project/${projectRef}/reports/database#database-size-report`}
>
View detailed summary
</Link>
</Button>
</div>
)}
</div>
<div className="col-span-8">
@@ -67,7 +67,7 @@ const DisallowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => (
</ButtonTooltip>
)
const NetworkRestrictions = () => {
export const NetworkRestrictions = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isAddingAddress, setIsAddingAddress] = useState<undefined | 'IPv4' | 'IPv6'>()
@@ -300,5 +300,3 @@ const NetworkRestrictions = () => {
</>
)
}
export default NetworkRestrictions
@@ -1,2 +1 @@
export { ConnectionPooling } from './ConnectionPooling/ConnectionPooling'
export { default as NetworkRestrictions } from './NetworkRestrictions/NetworkRestrictions'
@@ -10,6 +10,7 @@ import Panel from 'components/ui/Panel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useProjectUpdateMutation } from 'data/projects/project-update-mutation'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useProjectByRefQuery, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
@@ -31,6 +32,10 @@ const General = () => {
const { data: parentProject } = useProjectByRefQuery(project?.parent_project_ref)
const isBranch = parentProject !== undefined
const { projectSettingsRestartProject } = useIsFeatureEnabled([
'project_settings:restart_project',
])
const formId = 'project-general-settings'
const initialValues = { name: project?.name ?? '', ref: project?.ref ?? '' }
const { can: canUpdateProject } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', {
@@ -122,7 +127,9 @@ const General = () => {
<div className="flex flex-col px-8 py-4">
<div className="flex justify-between items-center">
<div>
<p className="text-sm">Restart project</p>
<p className="text-sm">
{projectSettingsRestartProject ? 'Restart project' : 'Restart database'}
</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
Your project will not be available for a few minutes.
@@ -12,6 +12,7 @@ import { useProjectRestartMutation } from 'data/projects/project-restart-mutatio
import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation'
import { setProjectStatus } from 'data/projects/projects-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useIsAwsK8sCloudProvider, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import {
Button,
@@ -31,6 +32,10 @@ const RestartServerButton = () => {
const isAwsK8s = useIsAwsK8sCloudProvider()
const [serviceToRestart, setServiceToRestart] = useState<'project' | 'database'>()
const { projectSettingsRestartProject } = useIsFeatureEnabled([
'project_settings:restart_project',
])
const projectRef = project?.ref ?? ''
const projectRegion = project?.region ?? ''
@@ -88,68 +93,80 @@ const RestartServerButton = () => {
return (
<>
<div className="flex">
<ButtonTooltip
type="default"
className={cn(
'px-3 hover:z-10',
canRestartProject && isProjectActive ? 'rounded-r-none' : ''
{projectSettingsRestartProject ? (
<div className="flex">
<ButtonTooltip
type="default"
className={cn(
'px-3 hover:z-10',
canRestartProject && isProjectActive ? 'rounded-r-none' : ''
)}
disabled={
project === undefined ||
!canRestartProject ||
!isProjectActive ||
projectRestartDisabled ||
isAwsK8s
}
onClick={() => setServiceToRestart('project')}
tooltip={{
content: {
side: 'bottom',
text: projectRestartDisabled
? 'Project restart is currently disabled'
: !canRestartProject
? 'You need additional permissions to restart this project'
: !isProjectActive
? 'Unable to restart project as project is not active'
: isAwsK8s
? 'Project restart is not supported for AWS (Revamped) projects'
: undefined,
},
}}
>
Restart project
</ButtonTooltip>
{canRestartProject && isProjectActive && !projectRestartDisabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
className="rounded-l-none px-[4px] py-[5px] -ml-[1px]"
icon={<ChevronDown />}
disabled={!canRestartProject}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="database"
disabled={isLoading}
onClick={() => {
setServiceToRestart('database')
}}
>
<div className="space-y-1">
<p className="block text-foreground">Fast database reboot</p>
<p className="block text-foreground-light">
Restarts only the database - faster but may not be able to recover from all
failure modes
</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
disabled={
project === undefined ||
!canRestartProject ||
!isProjectActive ||
projectRestartDisabled ||
isAwsK8s
}
onClick={() => setServiceToRestart('project')}
tooltip={{
content: {
side: 'bottom',
text: projectRestartDisabled
? 'Project restart is currently disabled'
: !canRestartProject
? 'You need additional permissions to restart this project'
: !isProjectActive
? 'Unable to restart project as project is not active'
: isAwsK8s
? 'Project restart is not supported for AWS (Revamped) projects'
: undefined,
},
</div>
) : (
<Button
type="default"
disabled={isLoading}
onClick={() => {
setServiceToRestart('database')
}}
>
Restart project
</ButtonTooltip>
{canRestartProject && isProjectActive && !projectRestartDisabled && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="default"
className="rounded-l-none px-[4px] py-[5px] -ml-[1px]"
icon={<ChevronDown />}
disabled={!canRestartProject}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom">
<DropdownMenuItem
key="database"
disabled={isLoading}
onClick={() => {
setServiceToRestart('database')
}}
>
<div className="space-y-1">
<p className="block text-foreground">Fast database reboot</p>
<p className="block text-foreground-light">
Restarts only the database - faster but may not be able to recover from all
failure modes
</p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
Restart database
</Button>
)}
<ConfirmModal
danger
@@ -17,7 +17,12 @@ import {
useReadReplicasStatusesQuery,
} from 'data/read-replicas/replicas-status-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import {
useIsAwsCloudProvider,
useIsOrioleDb,
useSelectedProjectQuery,
} from 'hooks/misc/useSelectedProject'
import { timeout } from 'lib/helpers'
import { type AWS_REGIONS_KEYS } from 'shared-data'
import {
@@ -52,6 +57,9 @@ const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationU
const numTransition = useRef<number>()
const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery()
const isAws = useIsAwsCloudProvider()
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
const [view, setView] = useState<'flow' | 'map'>('flow')
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false)
const { showNewReplicaPanel, setShowNewReplicaPanel } = useShowNewReplicaPanel()
@@ -225,7 +233,7 @@ const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationU
{isError && <AlertError error={error} subject="Failed to retrieve replicas" />}
{isSuccessReplicas && !isLoadingProject && (
<>
{!diagramOnly && (
{!diagramOnly && infrastructureReadReplicas && (
<div className="z-10 absolute top-4 right-4 flex items-center justify-center gap-x-2">
<div className="flex items-center justify-center">
<ButtonTooltip
@@ -269,7 +277,7 @@ const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationU
</DropdownMenu>
)}
</div>
{project?.cloud_provider === 'AWS' && (
{isAws && (
<div className="flex items-center justify-center">
<Button
type="default"
@@ -36,7 +36,8 @@ const InfrastructureInfo = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const authEnabled = useIsFeatureEnabled('project_auth:all')
const { projectAuthAll: authEnabled, projectSettingsDatabaseUpgrades: showDatabaseUpgrades } =
useIsFeatureEnabled(['project_auth:all', 'project_settings:database_upgrades'])
const {
data,
@@ -109,6 +110,7 @@ const InfrastructureInfo = () => {
</Alert_Shadcn_>
) : (
<>
{/* [Joshen] Double check why we need this waterfall loading behaviour here */}
{isLoadingUpgradeEligibility && <GenericSkeletonLoader />}
{isErrorUpgradeEligibility && (
<AlertError error={error} subject="Failed to retrieve Postgres version" />
@@ -187,7 +189,7 @@ const InfrastructureInfo = () => {
</>
)}
{data.eligible ? (
{showDatabaseUpgrades && data.eligible ? (
hasReadReplicas ? (
<ReadReplicasWarning latestPgVersion={latestPgVersion} />
) : (
@@ -195,7 +197,7 @@ const InfrastructureInfo = () => {
)
) : null}
{!data.eligible ? (
{showDatabaseUpgrades && !data.eligible ? (
hasObjectsToBeDropped ? (
<ObjectsToBeDroppedWarning
objectsToBeDropped={data.objects_to_be_dropped}
@@ -15,7 +15,7 @@ import {
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import DefaultPreviewSelectionRenderer from './LogSelectionRenderers/DefaultPreviewSelectionRenderer'
import type { LogData, QueryType } from './Logs.types'
import { jwtAPIKey, apiKey, role as extractRole } from './Logs.utils'
import { apiKey, role as extractRole, jwtAPIKey } from './Logs.utils'
export interface LogSelectionProps {
log?: LogData
@@ -97,6 +97,7 @@ const LogSelection = ({ log, onClose, queryType, isLoading, error }: LogSelectio
<TabsTrigger_Shadcn_ disabled={!log} className="px-3" value="raw">
Raw
</TabsTrigger_Shadcn_>
<div className="*:px-1.5 *:text-foreground-lighter ml-auto flex gap-1 absolute right-2 top-2">
<ButtonTooltip
disabled={!log || isLoading}
@@ -444,6 +444,7 @@ const LogTable = ({
document.body
)}
</ResizablePanel>
<ResizableHandle withHandle />
{selectionOpen && (
@@ -267,7 +267,15 @@ export const CreateBucketModal = () => {
labelOptional="Buckets cannot be renamed once created."
>
<FormControl_Shadcn_>
<Input_Shadcn_ id="name" {...field} placeholder="Enter bucket name" />
<Input_Shadcn_
id="name"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore
{...field}
placeholder="Enter bucket name"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
@@ -1,6 +1,11 @@
import { SupportCategories } from '@supabase/shared-types/out/constants'
export const CATEGORY_OPTIONS = [
export const CATEGORY_OPTIONS: {
value: SupportCategories | 'Plan_upgrade'
label: string
description: string
query?: string
}[] = [
{
value: SupportCategories.PROBLEM,
label: 'APIs and client libraries',
@@ -55,6 +60,13 @@ export const CATEGORY_OPTIONS = [
description: 'Issues with logging in and MFA',
query: undefined,
},
// [Joshen] Ideally shift this to shared-types, although not critical as API isn't validating the category
{
value: 'Plan_upgrade',
label: 'Plan upgrade',
description: 'Enquire a plan upgrade for your organization',
query: undefined,
},
]
export const SEVERITY_OPTIONS = [
@@ -26,6 +26,7 @@ import { useDocsSearch, useParams, type DocsSearchResult as Page } from 'common'
import { CLIENT_LIBRARIES } from 'common/constants'
import CopyButton from 'components/ui/CopyButton'
import { OrganizationProjectSelector } from 'components/ui/OrganizationProjectSelector'
import { PLAN_REQUEST_EMPTY_PLACEHOLDER } from 'components/ui/UpgradePlanButton'
import { getProjectAuthConfig } from 'data/auth/auth-config-query'
import { useSendSupportTicketMutation } from 'data/feedback/support-ticket-send'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
@@ -92,21 +93,41 @@ const createFormSchema = (showClientLibraries: boolean) => {
})
if (showClientLibraries) {
return baseSchema.refine(
(data) => {
return !(data.category === 'Problem' && data.library === '')
},
{
message: "Please select the library that you're facing issues with",
path: ['library'],
}
)
return baseSchema
.refine(
(data) => {
return !(data.category === 'Problem' && data.library === '')
},
{
message: "Please select the library that you're facing issues with",
path: ['library'],
}
)
.refine(
(data) => {
return !data.message.includes(PLAN_REQUEST_EMPTY_PLACEHOLDER)
},
{
message: `Please let us know which plan you'd like to upgrade to for your organization`,
path: ['message'],
}
)
}
// When showClientLibraries is false, make library optional and remove the refine validation
return baseSchema.extend({
library: z.string().optional(),
})
return baseSchema
.extend({
library: z.string().optional(),
})
.refine(
(data) => {
return !data.message.includes(PLAN_REQUEST_EMPTY_PLACEHOLDER)
},
{
message: `Please let us know which plan you'd like to upgrade to for your organization`,
path: ['message'],
}
)
}
const defaultValues = {
@@ -128,8 +149,6 @@ interface SupportFormV2Props {
setSentCategory: (value: string) => void
}
// [Joshen] Just naming it as V2 for now for PR review purposes so its easier to view
// This is a rewrite of the old SupportForm to use the new form components
export const SupportFormV2 = ({
onProjectSelected: setSelectedProject,
onOrganizationSelected: setSelectedOrganization,
@@ -161,7 +180,10 @@ export const SupportFormV2 = ({
option.value === SupportCategories.SALES_ENQUIRY
) {
return isBillingEnabled
} else if (option.value === 'Plan_upgrade') {
return !isBillingEnabled
}
return true
})
}, [isBillingEnabled])
@@ -323,7 +345,7 @@ export const SupportFormV2 = ({
if (isSuccessOrganizations) {
if (organizations.length === 0) {
form.setValue('organizationSlug', 'no-org')
} else if (urlRef) {
} else if (urlRef && urlRef !== 'no-project') {
// Check validity of project via project details
const selectedProject = await getProjectDetail({ ref: urlRef })
if (!!selectedProject) {
@@ -750,7 +772,7 @@ export const SupportFormV2 = ({
{library && library.length > 0 && <LibrarySuggestions library={library} />}
{category !== 'Login_issues' && (
{category !== 'Login_issues' && category !== 'Plan_upgrade' && (
<FormField_Shadcn_
name="affectedServices"
control={form.control}
@@ -197,6 +197,19 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
schema: table.schema,
payload: payload,
})
sendEvent({
action: 'table_rls_enabled',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
},
groups: {
project: projectRef,
...(org?.slug && { organization: org.slug }),
},
})
}
return (
@@ -21,6 +21,7 @@ import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-muta
import { tableKeys } from 'data/tables/keys'
import { RetrieveTableResult } from 'data/tables/table-retrieve-query'
import { getTables } from 'data/tables/tables-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useUrlState } from 'hooks/ui/useUrlState'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
@@ -74,6 +75,7 @@ const SidePanelEditor = ({
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const [isEdited, setIsEdited] = useState<boolean>(false)
const [isClosingPanel, setIsClosingPanel] = useState<boolean>(false)
@@ -454,6 +456,7 @@ const SidePanelEditor = ({
foreignKeyRelations,
isRLSEnabled,
importContent,
organizationSlug: org?.slug,
})
if (isRealtimeEnabled) await updateTableRealtime(table, true)
@@ -477,6 +480,7 @@ const SidePanelEditor = ({
foreignKeyRelations,
existingForeignKeyRelations,
primaryKey,
organizationSlug: org?.slug,
})
if (table === undefined) {
@@ -32,6 +32,7 @@ import {
updateTable as updateTableMutation,
} from 'data/tables/table-update-mutation'
import { getTables } from 'data/tables/tables-query'
import { sendEvent } from 'data/telemetry/send-event-mutation'
import { timeout, tryParseJson } from 'lib/helpers'
import {
generateCreateColumnPayload,
@@ -461,6 +462,7 @@ export const createTable = async ({
foreignKeyRelations,
isRLSEnabled,
importContent,
organizationSlug,
}: {
projectRef: string
connectionString?: string | null
@@ -474,6 +476,7 @@ export const createTable = async ({
foreignKeyRelations: ForeignKey[]
isRLSEnabled: boolean
importContent?: ImportContent
organizationSlug?: string
}) => {
const queryClient = getQueryClient()
@@ -484,6 +487,26 @@ export const createTable = async ({
payload: payload,
})
// Track table creation event
try {
await sendEvent({
event: {
action: 'table_created',
properties: {
method: 'table_editor',
schema_name: payload.schema,
table_name: payload.name,
},
groups: {
project: projectRef,
...(organizationSlug && { organization: organizationSlug }),
},
},
})
} catch (error) {
console.error('Failed to track table creation event:', error)
}
const table = await queryClient.fetchQuery({
queryKey: tableKeys.retrieve(projectRef, payload.name, payload.schema),
queryFn: ({ signal }) =>
@@ -508,6 +531,26 @@ export const createTable = async ({
schema: table.schema,
payload: { rls_enabled: isRLSEnabled },
})
// Track RLS enablement event
try {
await sendEvent({
event: {
action: 'table_rls_enabled',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
},
groups: {
project: projectRef,
...(organizationSlug && { organization: organizationSlug }),
},
},
})
} catch (error) {
console.error('Failed to track RLS enablement event:', error)
}
}
// Then insert the columns - we don't do Promise.all as we want to keep the integrity
@@ -662,6 +705,7 @@ export const updateTable = async ({
foreignKeyRelations,
existingForeignKeyRelations,
primaryKey,
organizationSlug,
}: {
projectRef: string
connectionString?: string | null
@@ -672,6 +716,7 @@ export const updateTable = async ({
foreignKeyRelations: ForeignKey[]
existingForeignKeyRelations: ForeignKeyConstraint[]
primaryKey?: Constraint
organizationSlug?: string
}) => {
const queryClient = getQueryClient()
@@ -703,6 +748,28 @@ export const updateTable = async ({
payload,
})
// Track RLS enablement if it's being turned on
if (payload.rls_enabled === true) {
try {
await sendEvent({
event: {
action: 'table_rls_enabled',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: payload.name ?? table.name,
},
groups: {
project: projectRef,
...(organizationSlug && { organization: organizationSlug }),
},
},
})
} catch (error) {
console.error('Failed to track RLS enablement event:', error)
}
}
const updatedTable = await queryClient.fetchQuery({
queryKey: tableKeys.retrieve(
projectRef,
@@ -3,6 +3,7 @@ import { useState } from 'react'
import { useParams } from 'common'
import { useDataTable } from 'components/ui/DataTable/providers/DataTableProvider'
import { ServiceFlowType, useUnifiedLogInspectionQuery } from 'data/logs'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import {
CodeBlock,
ResizableHandle,
@@ -43,6 +44,8 @@ export function ServiceFlowPanel({
const { ref: projectRef } = useParams()
const [activeTab, setActiveTab] = useState('service-flow')
const { logsMetadata } = useIsFeatureEnabled(['logs:metadata'])
const logType = selectedRow?.log_type
const serviceFlowType: ServiceFlowType | undefined =
logType === 'edge function' ? 'edge-function' : (logType as ServiceFlowType)
@@ -69,6 +72,14 @@ export function ServiceFlowPanel({
const jsonData =
shouldShowServiceFlow && serviceFlowData?.result?.[0] ? serviceFlowData.result[0] : selectedRow
const formattedJsonData =
!logsMetadata && 'raw_log_data' in jsonData && 'metadata' in jsonData.raw_log_data
? {
...jsonData,
raw_log_data: { ...jsonData.raw_log_data, metadata: undefined },
}
: jsonData
if (selectedRowKey) {
return (
<>
@@ -218,7 +229,7 @@ export function ServiceFlowPanel({
language="json"
className="max-h-[800px] overflow-auto border-none rounded-none [&_pre]:!leading-tight [&_code]:!leading-tight"
>
{JSON.stringify(jsonData, null, 2)}
{JSON.stringify(formattedJsonData, null, 2)}
</CodeBlock>
</TabsContent>
</Tabs>
@@ -1,3 +1,4 @@
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { BASE_PATH } from 'lib/constants'
import { useTheme } from 'next-themes'
import Head from 'next/head'
@@ -9,10 +10,12 @@ export interface APIAuthorizationLayoutProps {}
const APIAuthorizationLayout = ({ children }: PropsWithChildren<APIAuthorizationLayoutProps>) => {
const { resolvedTheme } = useTheme()
const { appTitle } = useCustomContent(['app:title'])
return (
<>
<Head>
<title>Authorize API access | Supabase</title>
<title>Authorize API access | {appTitle || 'Supabase'}</title>
</Head>
<main className="h-screen flex flex-col w-full h-full overflow-y-auto">
<div>
@@ -3,6 +3,7 @@ import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect } from 'react'
import { LOCAL_STORAGE_KEYS } from 'common'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { withAuth } from 'hooks/misc/withAuth'
import { IS_PLATFORM } from 'lib/constants'
@@ -18,6 +19,9 @@ const AccountLayout = ({ children, title }: PropsWithChildren<AccountLayoutProps
const router = useRouter()
const appSnap = useAppStateSnapshot()
const { appTitle } = useCustomContent(['app:title'])
const titleSuffix = appTitle || 'Supabase'
const [lastVisitedOrganization] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_ORGANIZATION,
''
@@ -41,7 +45,7 @@ const AccountLayout = ({ children, title }: PropsWithChildren<AccountLayoutProps
return (
<>
<Head>
<title>{title ? `${title} | Supabase` : 'Supabase'}</title>
<title>{title ? `${title} | ${titleSuffix}` : titleSuffix}</title>
<meta name="description" content="Supabase Studio" />
</Head>
<div className={cn('flex flex-col w-screen h-[calc(100vh-48px)]')}>
@@ -12,9 +12,10 @@ export function useDatabaseGotoCommands(options?: CommandOptions) {
let { ref } = useParams()
ref ||= '_'
const { databaseReplication, databaseRoles } = useIsFeatureEnabled([
const { databaseReplication, databaseRoles, integrationsWrappers } = useIsFeatureEnabled([
'database:replication',
'database:roles',
'integrations:wrappers',
])
useRegisterCommands(
@@ -102,13 +103,17 @@ export function useDatabaseGotoCommands(options?: CommandOptions) {
route: `/project/${ref}/database/backups/scheduled`,
defaultHidden: true,
},
{
id: 'nav-database-wrappers',
name: 'Wrappers',
value: 'Database: Wrappers',
route: `/project/${ref}/integrations/wrappers`,
defaultHidden: true,
},
...(integrationsWrappers
? [
{
id: 'nav-database-wrappers',
name: 'Wrappers',
value: 'Database: Wrappers',
route: `/project/${ref}/integrations?category=wrappers`,
defaultHidden: true,
} as IRouteCommand,
]
: []),
{
id: 'nav-database-migrations',
name: 'Migrations',
@@ -33,10 +33,11 @@ const DatabaseProductMenu = () => {
const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled()
const enablePgReplicate = useFlag('enablePgReplicate')
const { databaseReplication: showPgReplicate, databaseRoles: showRoles } = useIsFeatureEnabled([
'database:replication',
'database:roles',
])
const {
databaseReplication: showPgReplicate,
databaseRoles: showRoles,
integrationsWrappers: showWrappers,
} = useIsFeatureEnabled(['database:replication', 'database:roles', 'integrations:wrappers'])
return (
<>
@@ -49,6 +50,7 @@ const DatabaseProductMenu = () => {
enablePgReplicate,
showPgReplicate,
showRoles,
showWrappers,
})}
/>
</>
@@ -13,6 +13,7 @@ export const generateDatabaseMenu = (
enablePgReplicate: boolean
showPgReplicate: boolean
showRoles: boolean
showWrappers: boolean
}
): ProductMenuGroup[] => {
const ref = project?.ref ?? 'default'
@@ -23,6 +24,7 @@ export const generateDatabaseMenu = (
enablePgReplicate,
showPgReplicate,
showRoles,
showWrappers,
} = flags || {}
return [
@@ -134,13 +136,17 @@ export const generateDatabaseMenu = (
url: `/project/${ref}/database/migrations`,
items: [],
},
{
name: 'Wrappers',
key: 'wrappers',
url: `/project/${ref}/integrations?category=wrapper`,
rightIcon: <ArrowUpRight strokeWidth={1} className="h-4 w-4" />,
items: [],
},
...(showWrappers
? [
{
name: 'Wrappers',
key: 'wrappers',
url: `/project/${ref}/integrations?category=wrapper`,
rightIcon: <ArrowUpRight strokeWidth={1} className="h-4 w-4" />,
items: [],
},
]
: []),
...(!!pgNetExtensionExists
? [
{
@@ -1,13 +1,13 @@
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { useFlag } from 'common'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout'
import AlertError from 'components/ui/AlertError'
import { ProductMenu } from 'components/ui/ProductMenu'
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import { Menu, Separator } from 'ui'
@@ -20,6 +20,7 @@ import { GenericSkeletonLoader } from 'ui-patterns'
const IntegrationsLayout = ({ children }: PropsWithChildren) => {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const { integrationsWrappers: showWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const segments = router.asPath.split('/')
// construct the page url to be used to determine the active state for the sidebar
@@ -65,7 +66,7 @@ const IntegrationsLayout = ({ children }: PropsWithChildren) => {
: 'integrations'
: page
}
menu={generateIntegrationsMenu({ projectRef: project?.ref })}
menu={generateIntegrationsMenu({ projectRef: project?.ref, flags: { showWrappers } })}
/>
<Separator />
<div className="px-4 py-6 md:px-6">
@@ -110,7 +111,15 @@ const IntegrationsLayout = ({ children }: PropsWithChildren) => {
// Wrap component with authentication HOC before exporting
export default withAuth(IntegrationsLayout)
const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): ProductMenuGroup[] => {
const generateIntegrationsMenu = ({
projectRef,
flags,
}: {
projectRef?: string
flags?: { showWrappers: boolean }
}): ProductMenuGroup[] => {
const { showWrappers } = flags ?? {}
return [
{
title: 'Explore',
@@ -122,13 +131,17 @@ const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): Prod
pages: ['integrations'],
items: [],
},
{
name: 'Wrappers',
key: 'integrations-wrapper',
url: `/project/${projectRef}/integrations?category=wrapper`,
pages: ['integrations?category=wrapper'],
items: [],
},
...(showWrappers
? [
{
name: 'Wrappers',
key: 'integrations-wrapper',
url: `/project/${projectRef}/integrations?category=wrapper`,
pages: ['integrations?category=wrapper'],
items: [],
},
]
: []),
{
name: 'Postgres Modules',
key: 'integrations-postgres_extension',
@@ -13,13 +13,11 @@ export function useIntegrationsGotoCommands(options?: CommandOptions) {
let { ref } = useParams()
ref ||= '_'
const { integrationsShowStripeWrapper } = useIsFeatureEnabled([
'integrations:show_stripe_wrapper',
])
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const allIntegrations = integrationsShowStripeWrapper
const allIntegrations = integrationsWrappers
? INTEGRATIONS
: INTEGRATIONS.filter((x) => x.id !== 'stripe_wrapper')
: INTEGRATIONS.filter((x) => !x.id.endsWith('_wrapper'))
const getName = (integration: IntegrationDefinition) => {
switch (integration.id) {
@@ -1,3 +1,4 @@
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { BASE_PATH } from 'lib/constants'
import { useTheme } from 'next-themes'
import Head from 'next/head'
@@ -12,10 +13,12 @@ const LinkAwsMarketplaceLayout = ({
children,
}: PropsWithChildren<LinkAwsMarketplaceLayoutProps>) => {
const { resolvedTheme } = useTheme()
const { appTitle } = useCustomContent(['app:title'])
return (
<>
<Head>
<title>AWS Marketplace Setup | Supabase</title>
<title>AWS Marketplace Setup | {appTitle || 'Supabase'}</title>
</Head>
<main className="flex flex-col flex-grow w-full h-full overflow-y-auto">
<div>
@@ -9,6 +9,7 @@ import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant'
import { Loading } from 'components/ui/Loading'
import { ResourceExhaustionWarningBanner } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
@@ -94,6 +95,9 @@ const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayout
const { mobileMenuOpen, showSidebar, setMobileMenuOpen } = useAppStateSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
const { appTitle } = useCustomContent(['app:title'])
const titleSuffix = appTitle || 'Supabase'
useHotKey(() => aiSnap.toggleAssistant(), 'i', [aiSnap])
const editor = useEditorType()
@@ -124,14 +128,14 @@ const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayout
<Head>
<title>
{title
? `${title} | Supabase`
? `${title} | ${titleSuffix}`
: selectedTable
? `${selectedTable} | ${projectName} | ${organizationName} | Supabase`
? `${selectedTable} | ${projectName} | ${organizationName} | ${titleSuffix}`
: projectName
? `${projectName} | ${organizationName} | Supabase`
? `${projectName} | ${organizationName} | ${titleSuffix}`
: organizationName
? `${organizationName} | Supabase`
: 'Supabase'}
? `${organizationName} | ${titleSuffix}`
: titleSuffix}
</title>
<meta name="description" content="Supabase Studio" />
</Head>
@@ -10,6 +10,7 @@ import { Markdown } from 'components/interfaces/Markdown'
import { REPLICA_STATUS } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { formatDatabaseID, formatDatabaseRegion } from 'data/read-replicas/replicas.utils'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { IS_PLATFORM } from 'lib/constants'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import {
@@ -53,6 +54,8 @@ const DatabaseSelector = ({
const [open, setOpen] = useState(false)
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
const state = useDatabaseSelectorStateSnapshot()
const selectedDatabaseId = _selectedDatabaseId ?? state.selectedDatabaseId
@@ -203,7 +206,7 @@ const DatabaseSelector = ({
})}
</ScrollArea>
</CommandGroup_Shadcn_>
{IS_PLATFORM && (
{IS_PLATFORM && infrastructureReadReplicas && (
<CommandGroup_Shadcn_ className="border-t">
<CommandItem_Shadcn_
className="cursor-pointer w-full"
@@ -2,7 +2,7 @@ import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events'
import dayjs from 'dayjs'
import { X } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useParams } from 'common'
import { useProjectUpgradingStatusQuery } from 'data/config/project-upgrade-status-query'
@@ -18,9 +18,11 @@ export const ProjectUpgradeFailedBanner = () => {
const { status, initiated_at, latest_status_at, error } = data?.databaseUpgradeStatus ?? {}
const key = `supabase-upgrade-${ref}-${initiated_at}`
const isAcknowledged =
typeof window !== 'undefined' ? localStorage?.getItem(key) === 'true' : false
const [showMessage, setShowMessage] = useState(!isAcknowledged)
const [hasDismissed, setHasDismissed] = useState(false)
useEffect(() => {
setHasDismissed(localStorage?.getItem(key) === 'true')
}, [key])
const isFailed = status === DatabaseUpgradeStatus.Failed
const initiatedAt = dayjs
@@ -43,11 +45,11 @@ export const ProjectUpgradeFailedBanner = () => {
const timestampFilter = `its=${initiatedAtEncoded}&ite=${latestStatusAtEncoded}`
const acknowledgeMessage = () => {
setShowMessage(false)
setHasDismissed(true)
localStorage.setItem(key, 'true')
}
if (!isFailed || !showMessage) return null
if (!isFailed || hasDismissed) return null
return (
<div className="max-w-7xl">
@@ -0,0 +1,37 @@
import Link from 'next/link'
import { PropsWithChildren } from 'react'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Button } from 'ui'
interface ToggleSpendCapButtonProps {
action?: 'disable' | 'enable'
type?: 'default' | 'primary'
}
export const ToggleSpendCapButton = ({
action = 'disable',
type = 'default',
children,
}: PropsWithChildren<ToggleSpendCapButtonProps>) => {
const { data: organization } = useSelectedOrganizationQuery()
const slug = organization?.slug ?? '_'
const { billingAll } = useIsFeatureEnabled(['billing:all'])
const subject = `Enquiry to ${action} spend cap for organization`
const message = `Name: ${organization?.name}\nSlug: ${organization?.slug}`
const href = billingAll
? `/org/${slug}/billing?panel=costControl`
: `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}`
return (
<Button type={type} asChild>
<Link href={href} className="capitalize">
{children || `${action} spend cap`}
</Link>
</Button>
)
}
@@ -0,0 +1,45 @@
import Link from 'next/link'
import { PropsWithChildren } from 'react'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { Button } from 'ui'
export const PLAN_REQUEST_EMPTY_PLACEHOLDER =
'<Specify which plan to upgrade to: Pro | Team | Enterprise>'
interface UpgradePlanButtonProps {
source?: string
type?: 'default' | 'primary'
plan?: 'Pro' | 'Team' | 'Enterprise'
href?: string // [Joshen] As an override if needed (Used in UpgradeToPro)
disabled?: boolean
}
export const UpgradePlanButton = ({
source,
type = 'default',
plan,
href: propsHref,
disabled,
children,
}: PropsWithChildren<UpgradePlanButtonProps>) => {
const { data: organization } = useSelectedOrganizationQuery()
const slug = organization?.slug ?? '_'
const { billingAll } = useIsFeatureEnabled(['billing:all'])
const subject = `Enquiry to upgrade ${!!plan ? `to ${plan} ` : ''}plan for organization`
const message = `Name: ${organization?.name}\nSlug: ${organization?.slug}\nRequested plan: ${plan ?? PLAN_REQUEST_EMPTY_PLACEHOLDER}`
const href = billingAll
? propsHref ??
`/org/${slug}/billing?panel=subscriptionPlan${!!source ? `&source=${source}` : ''}`
: `/support/new?slug=${slug}&projectRef=no-project&category=Plan_upgrade&subject=${subject}&message=${encodeURIComponent(message)}`
return (
<Button asChild type={type} disabled={disabled}>
<Link href={href}>{children || `Upgrade to ${plan}`}</Link>
</Button>
)
}
+15 -17
View File
@@ -1,13 +1,13 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { ReactNode } from 'react'
import { useFlag } from 'common'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Button, cn } from 'ui'
import { cn } from 'ui'
import { ButtonTooltip } from './ButtonTooltip'
import { UpgradePlanButton } from './UpgradePlanButton'
interface UpgradeToProProps {
icon?: ReactNode
@@ -74,23 +74,21 @@ const UpgradeToPro = ({
{buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')}
</ButtonTooltip>
) : (
<Button
asChild
<UpgradePlanButton
type="primary"
disabled={!canUpdateSubscription || projectUpdateDisabled || disabled}
plan="Pro"
source={source}
disabled={disabled}
href={
plan === 'free'
? `/org/${organization?.slug ?? '_'}/billing?panel=subscriptionPlan&source=${source}`
: addon == null
? `/org/${organization?.slug ?? '_'}/billing?panel=costControl&source=${source}`
: `/project/${project?.ref ?? '_'}/settings/addons?panel=${addon}&source=${source}`
}
>
<Link
href={
plan === 'free'
? `/org/${organization?.slug ?? '_'}/billing?panel=subscriptionPlan&source=${source}`
: addon == null
? `/org/${organization?.slug ?? '_'}/billing?panel=costControl&source=${source}`
: `/project/${project?.ref ?? '_'}/settings/addons?panel=${addon}&source=${source}`
}
>
{buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')}
</Link>
</Button>
{buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')}
</UpgradePlanButton>
)}
</div>
</div>
@@ -1,7 +1,10 @@
import { get, handleError } from 'data/fetchers'
import { projectKeys } from './keys'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { components } from 'api-types'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { projectKeys } from './keys'
export type CloneStatus = components['schemas']['ProjectClonedStatusResponse']
export async function getCloneStatus(projectRef?: string) {
if (!projectRef) throw new Error('Project ref is required')
@@ -1,6 +1,9 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { sqlEventParser } from 'lib/sql-event-parser'
import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
// [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query
@@ -31,12 +34,38 @@ export const useExecuteSqlMutation = ({
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
const { mutate: sendEvent } = useSendEventMutation()
const { data: org } = useSelectedOrganizationQuery()
return useMutation<ExecuteSqlData, QueryResponseError, ExecuteSqlVariables>(
(args) => executeSql(args),
{
async onSuccess(data, variables, context) {
const { contextualInvalidation, sql, projectRef } = variables
// Track all table-related events from SQL execution
try {
const tableEvents = sqlEventParser.getTableEvents(sql)
tableEvents.forEach((event) => {
if (projectRef) {
sendEvent({
action: event.type,
properties: {
method: 'sql_editor',
schema_name: event.schema,
table_name: event.tableName,
},
groups: {
project: projectRef,
...(org?.slug && { organization: org.slug }),
},
})
}
})
} catch (error) {
console.error('Failed to parse SQL for telemetry:', error)
}
// [Joshen] Default to false for now, only used for SQL editor to dynamically invalidate
const sqlLower = sql.toLowerCase()
const isMutationSQL =
@@ -3,6 +3,8 @@ import { toast } from 'sonner'
import { Query } from '@supabase/pg-meta/src/query'
import { executeSql } from 'data/sql/execute-sql-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import type { ResponseError } from 'types'
@@ -65,12 +67,33 @@ export const useTableRowCreateMutation = ({
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
const { mutate: sendEvent } = useSendEventMutation()
const { data: org } = useSelectedOrganizationQuery()
return useMutation<TableRowCreateData, ResponseError, TableRowCreateVariables>(
(vars) => createTableRow(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, table } = variables
// Track data insertion event
try {
sendEvent({
action: 'table_data_added',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
},
groups: {
project: projectRef,
...(org?.slug && { organization: org.slug }),
},
})
} catch (error) {
console.error('Failed to track table data insertion event:', error)
}
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
await onSuccess?.(data, variables, context)
},
+13 -1
View File
@@ -16,6 +16,7 @@ import {
checkForWithClause,
} from 'components/interfaces/Settings/Logs/Logs.utils'
import { get } from 'data/fetchers'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { DOCS_URL } from 'lib/constants'
export interface LogsQueryHook {
@@ -46,6 +47,8 @@ const useLogsQuery = (
: defaultHelper.calcTo(),
})
const { logsMetadata } = useIsFeatureEnabled(['logs:metadata'])
useEffect(() => {
setParams((prev) => ({
...prev,
@@ -113,10 +116,19 @@ const useLogsQuery = (
setParams((prev) => ({ ...prev, sql: newQuery }))
}
const logData = (data?.result ?? []).map((x) => {
if (logsMetadata) {
return x
} else {
const { metadata, ...log } = x
return log
}
})
return {
params,
isLoading: (_enabled && isLoading) || isRefetching,
logData: data?.result ?? [],
logData: logData,
error,
changeQuery,
runQuery: () => refetch(),
+7 -1
View File
@@ -8,6 +8,7 @@ import type {
} from 'components/interfaces/Settings/Logs/Logs.types'
import { genSingleLogQuery } from 'components/interfaces/Settings/Logs/Logs.utils'
import { get } from 'data/fetchers'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
interface SingleLogHook {
data: LogData | undefined
@@ -35,6 +36,8 @@ function useSingleLog({
const enabled = Boolean(id && table)
const { logsMetadata } = useIsFeatureEnabled(['logs:metadata'])
const {
data,
error: rcError,
@@ -67,8 +70,11 @@ function useSingleLog({
let error: null | string | object = rcError ? (rcError as any).message : null
const result = data?.result ? data.result[0] : undefined
return {
data: result,
data: !!result
? { ...result, metadata: logsMetadata ? result?.metadata : undefined }
: undefined,
isLoading: (enabled && isLoading) || isRefetching,
error,
refresh: () => refetch(),
@@ -1,6 +1,8 @@
import type { CloudProvider } from 'shared-data'
export type CustomContentTypes = {
appTitle: string
dashboardAuthCustomProvider: string
organizationLegalDocuments: {
@@ -1,6 +1,8 @@
{
"$schema": "./custom-content.schema.json",
"app:title": null,
"dashboard_auth:custom_provider": null,
"organization:legal_documents": null,
@@ -1,6 +1,8 @@
{
"$schema": "./custom-content.schema.json",
"app:title": "Test Title",
"dashboard_auth:custom_provider": "Nimbus",
"organization:legal_documents": [
@@ -6,6 +6,11 @@
"type": "string"
},
"app:title": {
"type": ["string", "null"],
"description": "Render a custom HTML title for the site"
},
"dashboard_auth:custom_provider": {
"type": ["string", "null"],
"description": "Show a custom provider on the sign in page (Continue with X)"
@@ -85,6 +90,7 @@
}
},
"required": [
"app:title",
"organization:legal_documents",
"project_homepage:client_libraries",
"project_homepage:example_projects",
@@ -53,6 +53,13 @@ export const useIsAwsK8sCloudProvider = () => {
return isAwsK8s
}
export const useIsAwsNimbusCloudProvider = () => {
const { data: project } = useSelectedProjectQuery()
const isAwsNimbus = project?.cloud_provider === PROVIDERS.AWS_NIMBUS.id
return isAwsNimbus
}
export const useIsOrioleDb = () => {
const { data: project } = useSelectedProjectQuery()
const isOrioleDb = project?.dbVersion?.endsWith('orioledb')
+1
View File
@@ -327,6 +327,7 @@ export const CHAT_PROMPT = `
## SQL Execution and Display
- To execute SQL, call the \`execute_sql\` tool with the relevant \`sql\` string; the client manages confirmation and display of results.
- Do not show the SQL query before execution; the client will display it to the user.
- Set chartConfig \`view\` to \`chart\` and xAxis/yAxis if the results would be best displayed as a chart e.g. count of items by date
- On execution error, explain succinctly and attempt to correct if possible, validating each outcome briefly (1–2 lines) after execution.
- If a user skips execution, acknowledge and suggest alternatives.
- Use markdown code blocks (\`\`\`sql\`\`\`) for illustrative SQL only if requested by the user or when providing non-executable examples.
@@ -7,6 +7,13 @@ export const getRenderingTools = () => ({
inputSchema: z.object({
sql: z.string().describe('The SQL statement to execute.'),
label: z.string().describe('A short 2-4 word label for the SQL statement.'),
chartConfig: z
.object({
view: z.enum(['table', 'chart']).describe('How to render the results after execution'),
xAxis: z.string().optional().describe('The column to use for the x-axis of the chart.'),
yAxis: z.string().optional().describe('The column to use for the y-axis of the chart.'),
})
.describe('Chart configuration for rendering the results'),
isWriteQuery: z
.boolean()
.describe(
+462
View File
@@ -0,0 +1,462 @@
import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants'
import { describe, expect, it } from 'vitest'
import { sqlEventParser } from './sql-event-parser'
describe('SQL Event Parser', () => {
describe('CREATE TABLE detection', () => {
it('detects basic CREATE TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE users (id INT PRIMARY KEY)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
it('detects CREATE TABLE with schema', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE public.users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'users',
})
})
it('detects CREATE TABLE IF NOT EXISTS', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE IF NOT EXISTS users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE "public"."user_table" (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'user_table',
})
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
it('detects CREATE TEMPORARY TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TEMPORARY TABLE temp_users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
it('detects CREATE TEMP TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TEMP TABLE temp_users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
it('detects CREATE UNLOGGED TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE UNLOGGED TABLE fast_table (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'fast_table',
})
})
it('detects CREATE TEMP TABLE IF NOT EXISTS', () => {
const results = sqlEventParser.getTableEvents(
'CREATE TEMP TABLE IF NOT EXISTS temp_users (id INT)'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
})
describe('INSERT detection', () => {
it('detects basic INSERT INTO', () => {
const results = sqlEventParser.getTableEvents("INSERT INTO users (name) VALUES ('John')")
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: undefined,
tableName: 'users',
})
})
it('detects INSERT with schema', () => {
const results = sqlEventParser.getTableEvents(
"INSERT INTO public.users (name) VALUES ('John')"
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'public',
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('INSERT INTO "auth"."users" (id) VALUES (1)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'auth',
tableName: 'users',
})
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('UPDATE users SET name = "John"')
expect(results).toHaveLength(0)
})
})
describe('COPY detection', () => {
it('detects basic COPY FROM', () => {
const results = sqlEventParser.getTableEvents("COPY users FROM '/tmp/users.csv'")
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: undefined,
tableName: 'users',
})
})
it('detects COPY with schema', () => {
const results = sqlEventParser.getTableEvents(
"COPY public.users FROM '/tmp/users.csv' WITH CSV HEADER"
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'public',
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('COPY "auth"."users" FROM STDIN')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'auth',
tableName: 'users',
})
})
it('returns empty array for COPY TO', () => {
const results = sqlEventParser.getTableEvents("COPY users TO '/tmp/users.csv'")
expect(results).toHaveLength(0)
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
})
describe('SELECT INTO detection', () => {
it('detects SELECT INTO', () => {
const results = sqlEventParser.getTableEvents('SELECT * INTO new_users FROM users')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('detects SELECT INTO with schema', () => {
const results = sqlEventParser.getTableEvents(
'SELECT id, name INTO public.new_users FROM users'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'new_users',
})
})
it('detects CREATE TABLE AS SELECT', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE new_users AS SELECT * FROM users')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('detects CREATE TABLE IF NOT EXISTS AS SELECT', () => {
const results = sqlEventParser.getTableEvents(
'CREATE TABLE IF NOT EXISTS new_users AS SELECT * FROM users WHERE active = true'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents(
'SELECT * INTO "backup"."users_2024" FROM users'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'backup',
tableName: 'users_2024',
})
})
it('returns empty array for regular SELECT', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
})
describe('RLS detection', () => {
it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE ROW LEVEL SECURITY')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('detects short form ENABLE RLS', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE RLS')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('detects with schema', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE public.users ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: 'public',
tableName: 'users',
})
})
it('handles other ALTER TABLE statements in between', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE users ADD COLUMN test INT, ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('returns empty array for disabling RLS', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users DISABLE ROW LEVEL SECURITY')
expect(results).toHaveLength(0)
})
})
describe('ReDoS protection', () => {
it('handles extremely long identifier names efficiently', () => {
const longIdentifier = 'a'.repeat(10000)
const sql = `CREATE TABLE ${longIdentifier} (id INT)`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: longIdentifier,
})
})
it('handles nested dots in schema names without catastrophic backtracking', () => {
const maliciousInput = 'a.'.repeat(1000) + 'table'
const sql = `CREATE TABLE ${maliciousInput} (id INT)`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results.length).toBeGreaterThan(0)
})
it('handles pathological SELECT INTO patterns', () => {
const maliciousSQL = 'SELECT ' + 'a '.repeat(1000) + 'INTO table FROM users'
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(maliciousSQL)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'table',
})
})
it('handles ALTER TABLE with many operations between', () => {
const manyOperations = 'ADD COLUMN test INT, '.repeat(100)
const sql = `ALTER TABLE users ${manyOperations} ENABLE ROW LEVEL SECURITY`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('handles mixed quotes and backticks efficiently', () => {
const mixedQuotes = '`"`.'.repeat(100) + 'tablename'
const sql = `CREATE TABLE ${mixedQuotes} (id INT)`
const startTime = Date.now()
sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
})
})
describe('Edge cases and special characters', () => {
it('handles Unicode identifiers', () => {
const sql = 'CREATE TABLE 用户表 (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(0)
})
it('handles identifiers with numbers', () => {
const sql = 'CREATE TABLE table123 (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'table123',
})
})
it('handles identifiers with underscores', () => {
const sql = 'CREATE TABLE user_accounts (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'user_accounts',
})
})
it('handles escaped quotes in identifiers', () => {
const sql = 'CREATE TABLE "user""table" (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'usertable',
})
})
it('handles dollar-quoted strings in SQL', () => {
const sql = `
CREATE TABLE users (id INT);
INSERT INTO logs VALUES ($$CREATE TABLE fake$$);
INSERT INTO users VALUES (1);
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(3)
expect(results[0].type).toBe(TABLE_EVENT_ACTIONS.TableCreated)
expect(results[0]).toMatchObject({ tableName: 'users' })
expect(results[1].type).toBe(TABLE_EVENT_ACTIONS.TableCreated)
expect(results[1]).toMatchObject({ tableName: 'fake' })
expect(results[2].type).toBe(TABLE_EVENT_ACTIONS.TableDataAdded)
})
it('handles SQL injection attempts safely', () => {
const sql = "CREATE TABLE users'; DROP TABLE users; -- (id INT)"
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
})
describe('getTableEvents', () => {
it('filters only table-related events', () => {
const sql = `
CREATE TABLE users (id INT);
CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
INSERT INTO users (id) VALUES (1);
ALTER TABLE users ENABLE RLS;
CREATE VIEW user_view AS SELECT * FROM users;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(3)
expect(results.map((r) => r.type)).toEqual([
TABLE_EVENT_ACTIONS.TableCreated,
TABLE_EVENT_ACTIONS.TableDataAdded,
TABLE_EVENT_ACTIONS.TableRLSEnabled,
])
})
it('returns empty array for non-table SQL', () => {
const sql = `
CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
CREATE VIEW user_view AS SELECT * FROM users;
SELECT * FROM users;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(0)
})
})
})
+128
View File
@@ -0,0 +1,128 @@
/**
* Lightweight SQL parser for telemetry event detection.
*
* [Sean] Replace this with a proper SQL parser like `@supabase/pg-parser` once a
* browser-compatible version is available.
*/
import { TABLE_EVENT_ACTIONS, TableEventAction } from 'common/telemetry-constants'
export interface TableEventDetails {
type: TableEventAction
schema?: string
tableName?: string
}
type Detector = {
type: TableEventAction
patterns: RegExp[]
}
export class SQLEventParser {
private static DETECTORS: Detector[] = [
{
type: TABLE_EVENT_ACTIONS.TableCreated,
patterns: [
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
/CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
/CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
/SELECT\s+.*?\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/is,
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)\s+AS\s+SELECT/i,
],
},
{
type: TABLE_EVENT_ACTIONS.TableDataAdded,
patterns: [
/INSERT\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
/COPY\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)\s+FROM/i,
],
},
{
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
patterns: [
/ALTER\s+TABLE\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i,
/ALTER\s+TABLE\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+).*?ENABLE\s+RLS/i,
],
},
]
private cleanIdentifier(identifier?: string) {
return identifier?.replace(/["`']/g, '').replace(/\.$/, '')
}
private match(sql: string): TableEventDetails | null {
for (const { type, patterns } of SQLEventParser.DETECTORS) {
for (const pattern of patterns) {
const match = sql.match(pattern)
if (match?.groups) {
return {
type,
schema: this.cleanIdentifier(match.groups.schema),
tableName: this.cleanIdentifier(match.groups.table ?? match.groups.object),
}
}
}
}
return null
}
private splitStatements(sql: string): string[] {
// Regex matches:
// - single quotes ('...') with escapes
// - double quotes ("...")
// - dollar-quoted blocks ($$...$$ or $tag$...$tag$)
// - semicolons
// - everything else
const tokens =
sql.match(
/'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|;|[^'"$;]+/g
) || []
const statements: string[] = []
let current = ''
for (const token of tokens) {
if (token === ';') {
if (current.trim()) statements.push(current.trim())
current = ''
} else {
current += token
}
}
if (current.trim()) {
statements.push(current.trim())
}
return statements
}
private deduplicate(events: TableEventDetails[]): TableEventDetails[] {
const seen = new Set<string>()
return events.filter((e) => {
const key = `${e.type}:${e.schema || ''}:${e.tableName || ''}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
private removeComments(sql: string): string {
return sql
.replace(/--.*?$/gm, '') // line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
}
getTableEvents(sql: string): TableEventDetails[] {
const statements = this.splitStatements(this.removeComments(sql))
const results: TableEventDetails[] = []
for (const stmt of statements) {
const event = this.match(stmt)
if (event) results.push(event)
}
return this.deduplicate(results)
}
}
export const sqlEventParser = new SQLEventParser()
+3 -1
View File
@@ -50,6 +50,7 @@ import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvid
import { GlobalErrorBoundaryState } from 'components/ui/GlobalErrorBoundaryState'
import { useRootQueryClient } from 'data/query-client'
import { customFont, sourceCodePro } from 'fonts'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { AuthProvider } from 'lib/auth'
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from 'lib/constants'
import { ProfileProvider } from 'lib/profile'
@@ -84,6 +85,7 @@ loader.config({
function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
const queryClient = useRootQueryClient()
const { appTitle } = useCustomContent(['app:title'])
const getLayout = Component.getLayout ?? ((page) => page)
@@ -127,7 +129,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
>
<ProfileProvider>
<Head>
<title>Supabase</title>
<title>{appTitle ?? 'Supabase'}</title>
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<meta property="og:image" content={`${BASE_PATH}/img/supabase-logo.png`} />
{/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}
+17 -6
View File
@@ -1,5 +1,5 @@
import Head from 'next/head'
import { useMemo, useState } from 'react'
import { PropsWithChildren, useMemo, useState } from 'react'
import { useParams } from 'common'
import { ProjectClaimBenefits } from 'components/interfaces/Organization/ProjectClaim/benefits'
@@ -10,10 +10,24 @@ import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorization-query'
import { useOrganizationProjectClaimQuery } from 'data/organizations/organization-project-claim-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { withAuth } from 'hooks/misc/withAuth'
import type { NextPageWithLayout } from 'types'
import { Admonition } from 'ui-patterns'
const ClaimProjectPageLayout = ({ children }: PropsWithChildren) => {
const { appTitle } = useCustomContent(['app:title'])
return (
<>
<Head>
<title>Claim project | {appTitle || 'Supabase'}</title>
</Head>
{children}
</>
)
}
const ClaimProjectPage: NextPageWithLayout = () => {
const { auth_id, token: claimToken } = useParams()
const [selectedOrgSlug, setSelectedOrgSlug] = useState<string>()
@@ -111,12 +125,9 @@ const ClaimProjectPage: NextPageWithLayout = () => {
}
ClaimProjectPage.getLayout = (page) => (
<>
<Head>
<title>Claim project | Supabase</title>
</Head>
<ClaimProjectPageLayout>
<main className="flex flex-col w-full min-h-screen overflow-y-auto">{page}</main>
</>
</ClaimProjectPageLayout>
)
export default withAuth(ClaimProjectPage)
@@ -1,42 +1,22 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChevronRightIcon, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useParams } from 'common'
import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav'
import { PITRForm } from 'components/interfaces/Database/Backups/PITR/pitr-form'
import { BackupsList } from 'components/interfaces/Database/Backups/RestoreToNewProject/BackupsList'
import { ConfirmRestoreDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/ConfirmRestoreDialog'
import { CreateNewProjectDialog } from 'components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog'
import { projectSpecToMonthlyPrice } from 'components/interfaces/Database/Backups/RestoreToNewProject/RestoreToNewProject.utils'
import { DiskType } from 'components/interfaces/DiskManagement/ui/DiskManagement.constants'
import { Markdown } from 'components/interfaces/Markdown'
import { RestoreToNewProject } from 'components/interfaces/Database/RestoreToNewProject/RestoreToNewProject'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import NoPermission from 'components/ui/NoPermission'
import Panel from 'components/ui/Panel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useDiskAttributesQuery } from 'data/config/disk-attributes-query'
import { useCloneBackupsQuery } from 'data/projects/clone-query'
import { useCloneStatusQuery } from 'data/projects/clone-status-query'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import {
useIsAwsK8sCloudProvider,
useIsOrioleDb,
useSelectedProjectQuery,
} from 'hooks/misc/useSelectedProject'
import { DOCS_URL, PROJECT_STATUS } from 'lib/constants'
import { getDatabaseMajorVersion } from 'lib/helpers'
import { UnknownInterface } from 'components/ui/UnknownInterface'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import type { NextPageWithLayout } from 'types'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Badge, Button } from 'ui'
import { Admonition, TimestampInfo } from 'ui-patterns'
const RestoreToNewProjectPage: NextPageWithLayout = () => {
const { ref } = useParams()
const { databaseRestoreToNewProject } = useIsFeatureEnabled(['database:restore_to_new_project'])
if (!databaseRestoreToNewProject) {
return <UnknownInterface urlBack={`/project/${ref}/database/backups/scheduled`} />
}
return (
<ScaffoldContainer>
<ScaffoldSection>
@@ -60,367 +40,4 @@ RestoreToNewProjectPage.getLayout = (page) => (
</DefaultLayout>
)
const RestoreToNewProject = () => {
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const isFreePlan = organization?.plan?.id === 'free'
const isOrioleDb = useIsOrioleDb()
const isAwsK8s = useIsAwsK8sCloudProvider()
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const [selectedBackupId, setSelectedBackupId] = useState<number | null>(null)
const [showConfirmationDialog, setShowConfirmationDialog] = useState(false)
const [showNewProjectDialog, setShowNewProjectDialog] = useState(false)
const [recoveryTimeTarget, setRecoveryTimeTarget] = useState<number | null>(null)
const {
data: cloneBackups,
error,
isLoading: cloneBackupsLoading,
isError,
} = useCloneBackupsQuery({ projectRef: project?.ref }, { enabled: !isFreePlan })
const isActiveHealthy = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
const { can: canReadPhysicalBackups, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.READ,
'physical_backups'
)
const { can: canTriggerPhysicalBackups } = useAsyncCheckPermissions(
PermissionAction.INFRA_EXECUTE,
'queue_job.restore.prepare'
)
const PITR_ENABLED = cloneBackups?.pitr_enabled
const PHYSICAL_BACKUPS_ENABLED = project?.is_physical_backups_enabled
const dbVersion = getDatabaseMajorVersion(project?.dbVersion ?? '')
const IS_PG15_OR_ABOVE = dbVersion >= 15
const targetVolumeSizeGb = cloneBackups?.target_volume_size_gb
const targetComputeSize = cloneBackups?.target_compute_size
const planId = organization?.plan?.id ?? 'free'
const { data } = useDiskAttributesQuery({ projectRef: project?.ref })
const storageType = data?.attributes?.type ?? 'gp3'
const {
data: cloneStatus,
refetch: refetchCloneStatus,
isLoading: cloneStatusLoading,
} = useCloneStatusQuery(
{
projectRef: project?.ref,
},
{
refetchInterval,
refetchOnWindowFocus: false,
onSuccess: (data) => {
const hasTransientState = data?.clones.some((c) => c.status === 'IN_PROGRESS')
if (!hasTransientState) setRefetchInterval(false)
},
enabled: PHYSICAL_BACKUPS_ENABLED || PITR_ENABLED,
}
)
const IS_CLONED_PROJECT = (cloneStatus?.cloned_from?.source_project as any)?.ref ? true : false
const isLoading = !isPermissionsLoaded || cloneBackupsLoading || cloneStatusLoading
const previousClones = cloneStatus?.clones
const isRestoring = previousClones?.some((c) => c.status === 'IN_PROGRESS')
const restoringClone = previousClones?.find((c) => c.status === 'IN_PROGRESS')
const StatusBadge = ({
status,
}: {
status: NonNullable<typeof previousClones>[number]['status']
}) => {
const statusTextMap = {
IN_PROGRESS: 'RESTORING',
COMPLETED: 'COMPLETED',
REMOVED: 'REMOVED',
FAILED: 'FAILED',
}
if (status === 'IN_PROGRESS') {
return <Badge variant="warning">{statusTextMap[status]}</Badge>
}
if (status === 'FAILED') {
return <Badge variant="destructive">{statusTextMap[status]}</Badge>
}
return <Badge>{statusTextMap[status]}</Badge>
}
const PreviousRestoreItem = ({
clone,
}: {
clone: NonNullable<typeof previousClones>[number]
}) => {
if (clone.status === 'REMOVED') {
return (
<div className="grid grid-cols-4 gap-2 text-sm p-4 group">
<div className="min-w-24 truncate">{(clone.target_project as any).name}</div>
<div>
<StatusBadge status={clone.status} />
</div>
<div>
<TimestampInfo
className="font-mono text-xs text-foreground-lighter"
utcTimestamp={clone.inserted_at ?? ''}
/>
</div>
</div>
)
} else {
return (
<Link
href={`/project/${clone.target_project.ref}`}
className="grid grid-cols-4 gap-2 text-sm p-4 group"
>
<div className="min-w-24 truncate">{(clone.target_project as any).name}</div>
<div>
<StatusBadge status={clone.status} />
</div>
<div>
<TimestampInfo
className="font-mono text-xs text-foreground-lighter"
utcTimestamp={clone.inserted_at ?? ''}
/>
</div>
<div className="flex items-center justify-end text-foreground-lighter group-hover:text-foreground">
<ChevronRightIcon className="w-4 h-4" />
</div>
</Link>
)
}
}
if (organization?.managed_by === 'vercel-marketplace') {
return (
<Admonition
type="default"
title="Restore to new project is not available for Vercel Marketplace organizations"
description="Restoring project backups to a new project created via Vercel Marketplace is not supported yet."
/>
)
}
if (isFreePlan) {
return (
<UpgradeToPro
buttonText="Upgrade"
source="backupsRestoreToNewProject"
primaryText="Restore to a new project requires a pro plan or above."
secondaryText="To restore to a new project, you need to upgrade to a Pro plan and have physical backups enabled."
/>
)
}
if (isOrioleDb) {
return (
<Admonition
type="default"
title="Restoring to new projects are not available for OrioleDB"
description="OrioleDB is currently in public alpha and projects created are strictly ephemeral with no database backups"
/>
)
}
if (isAwsK8s) {
return (
<Admonition
type="default"
title="Restoring to new projects is temporarily not available for AWS (Revamped) projects"
/>
)
}
if (!canReadPhysicalBackups) {
return <NoPermission resourceText="view backups" />
}
if (!canTriggerPhysicalBackups) {
return <NoPermission resourceText="restore backups" />
}
if (!IS_PG15_OR_ABOVE) {
return (
<Admonition
type="default"
title="Restore to new project is not available for this database version"
>
<Markdown
className="max-w-full"
content={`Restore to new project is only available for Postgres 15 and above.
Go to [infrastructure settings](/project/${project?.ref}/settings/infrastructure)
to upgrade your database version.
`}
/>
</Admonition>
)
}
if (!PHYSICAL_BACKUPS_ENABLED) {
return (
<Admonition
type="default"
title="Restore to new project requires physical backups"
description={
<>
Physical backups must be enabled to restore your database to a new project.
<br /> Find out more about how backups work at supabase{' '}
<Link
target="_blank"
className="underline"
href={`${DOCS_URL}/guides/platform/backups`}
>
in our docs
</Link>
.
</>
}
/>
)
}
if (isLoading) {
return <GenericSkeletonLoader />
}
if (IS_CLONED_PROJECT) {
return (
<Admonition type="default" title="This project cannot be restored to a new project">
<Markdown
className="max-w-full [&>p]:!leading-normal"
content={`This is a temporary limitation whereby projects that were originally restored from another project cannot be restored to yet another project.
If you need to restore from a restored project, please reach out via [support](/support/new?projectRef=${project?.ref}).`}
/>
<Button asChild type="default">
<Link href={`/project/${(cloneStatus?.cloned_from?.source_project as any)?.ref || ''}`}>
Go to original project
</Link>
</Button>
</Admonition>
)
}
if (isError) {
return <AlertError error={error} subject="Failed to retrieve backups" />
}
if (!isActiveHealthy) {
return (
<Admonition
type="default"
title="Restore to new project is not available while project is offline"
description="Your project needs to be online to restore your database to a new project"
/>
)
}
if (
!isLoading &&
PITR_ENABLED &&
!cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix
) {
return (
<Admonition
type="default"
title="No backups found"
description="PITR is enabled, but no backups were found. Check again in a few minutes."
/>
)
}
if (!isLoading && !PITR_ENABLED && cloneBackups?.backups.length === 0) {
return (
<>
<Admonition
type="default"
title="No backups found"
description="Backups are enabled, but no backups were found. Check again tomorrow."
/>
</>
)
}
const additionalMonthlySpend = projectSpecToMonthlyPrice({
targetVolumeSizeGb: targetVolumeSizeGb ?? 0,
targetComputeSize: targetComputeSize ?? 'nano',
planId: planId ?? 'free',
storageType: storageType as DiskType,
})
return (
<div className="flex flex-col gap-4">
<ConfirmRestoreDialog
open={showConfirmationDialog}
onOpenChange={setShowConfirmationDialog}
onSelectContinue={() => {
setShowConfirmationDialog(false)
setShowNewProjectDialog(true)
}}
additionalMonthlySpend={additionalMonthlySpend}
/>
<CreateNewProjectDialog
open={showNewProjectDialog}
selectedBackupId={selectedBackupId}
recoveryTimeTarget={recoveryTimeTarget}
additionalMonthlySpend={additionalMonthlySpend}
onOpenChange={setShowNewProjectDialog}
onCloneSuccess={() => {
refetchCloneStatus()
setRefetchInterval(5000)
setShowNewProjectDialog(false)
}}
/>
{isRestoring ? (
<Alert_Shadcn_ className="[&>svg]:bg-none! [&>svg]:text-foreground-light mb-6">
<Loader2 className="animate-spin" />
<AlertTitle_Shadcn_>Restoration in progress</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>
The new project {(restoringClone?.target_project as any)?.name || ''} is currently
being created. You'll be able to restore again once the project is ready.
</p>
<Button asChild type="default" className="mt-2">
<Link href={`/project/${restoringClone?.target_project.ref}`}>Go to new project</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : null}
{previousClones?.length ? (
<div className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Previous restorations</h3>
<Panel className="flex flex-col divide-y divide-border">
{previousClones?.map((c) => <PreviousRestoreItem key={c.inserted_at} clone={c} />)}
</Panel>
</div>
) : null}
{PITR_ENABLED ? (
<>
<PITRForm
disabled={isRestoring}
onSubmit={(v) => {
setShowConfirmationDialog(true)
setRecoveryTimeTarget(v.recoveryTimeTargetUnix)
}}
earliestAvailableBackupUnix={
cloneBackups?.physicalBackupData.earliestPhysicalBackupDateUnix || 0
}
latestAvailableBackupUnix={
cloneBackups?.physicalBackupData.latestPhysicalBackupDateUnix || 0
}
/>
</>
) : (
<BackupsList
disabled={isRestoring}
onSelectRestore={(id) => {
setSelectedBackupId(id)
setShowConfirmationDialog(true)
}}
/>
)}
</div>
)
}
export default RestoreToNewProjectPage
@@ -1,14 +1,16 @@
import { DiskManagementPanelForm } from 'components/interfaces/DiskManagement/DiskManagementPanelForm'
import { ConnectionPooling, NetworkRestrictions } from 'components/interfaces/Settings/Database'
import { ConnectionPooling } from 'components/interfaces/Settings/Database'
import BannedIPs from 'components/interfaces/Settings/Database/BannedIPs'
import { DatabaseReadOnlyAlert } from 'components/interfaces/Settings/Database/DatabaseReadOnlyAlert'
import ResetDbPassword from 'components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword'
import DiskSizeConfiguration from 'components/interfaces/Settings/Database/DiskSizeConfiguration'
import { NetworkRestrictions } from 'components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions'
import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal'
import SSLConfiguration from 'components/interfaces/Settings/Database/SSLConfiguration'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useIsAwsCloudProvider, useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject'
import type { NextPageWithLayout } from 'types'
@@ -18,6 +20,8 @@ const ProjectSettings: NextPageWithLayout = () => {
const showNewDiskManagementUI = isAws || isAwsK8s
const { databaseNetworkRestrictions } = useIsFeatureEnabled(['database:network_restrictions'])
return (
<>
<ScaffoldContainer>
@@ -40,7 +44,7 @@ const ProjectSettings: NextPageWithLayout = () => {
) : (
<DiskSizeConfiguration />
)}
<NetworkRestrictions />
{databaseNetworkRestrictions && <NetworkRestrictions />}
<BannedIPs />
</div>
</ScaffoldContainer>
@@ -3,9 +3,11 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import DefaultLayout from 'components/layouts/DefaultLayout'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
import { PageLayout, NavigationItem } from 'components/layouts/PageLayout/PageLayout'
import { NavigationItem, PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { UnknownInterface } from 'components/ui/UnknownInterface'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useRouter } from 'next/compat/router'
import { useEffect, useMemo } from 'react'
import { NextPageWithLayout } from 'types'
@@ -14,6 +16,7 @@ import { Admonition } from 'ui-patterns'
const IntegrationPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref, id, pageId, childId } = useParams()
const { integrationsWrappers } = useIsFeatureEnabled(['integrations:wrappers'])
const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } =
useInstalledIntegrations()
@@ -117,6 +120,10 @@ const IntegrationPage: NextPageWithLayout = () => {
return null
}
if (!integrationsWrappers && id?.endsWith('_wrapper')) {
return <UnknownInterface urlBack={`/project/${ref}/integrations`} />
}
return (
<PageLayout
title={pageTitle}
@@ -3,7 +3,7 @@ import { useLocalStorage } from '@uidotdev/usehooks'
import dayjs from 'dayjs'
import { editor } from 'monaco-editor'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
@@ -37,6 +37,7 @@ import {
import useLogsQuery from 'hooks/analytics/useLogsQuery'
import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState'
import { useCustomContent } from 'hooks/custom-content/useCustomContent'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useUpgradePrompt } from 'hooks/misc/useUpgradePrompt'
import { uuidv4 } from 'lib/helpers'
@@ -66,6 +67,12 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
const { ref, q, queryId } = useParams()
const projectRef = ref as string
const { data: organization } = useSelectedOrganizationQuery()
const { logsShowMetadataIpTemplate } = useIsFeatureEnabled(['logs:show_metadata_ip_template'])
const allTemplates = useMemo(() => {
if (logsShowMetadataIpTemplate) return TEMPLATES
else return TEMPLATES.filter((x) => x.label !== 'Metadata IP')
}, [logsShowMetadataIpTemplate])
const editorRef = useRef<editor.IStandaloneCodeEditor>()
const [editorId] = useState<string>(uuidv4())
@@ -301,7 +308,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
defaultTo={timestampEnd || ''}
onDateChange={handleDateChange}
onSelectSource={handleInsertSource}
templates={TEMPLATES.filter((template) => template.mode === 'custom')}
templates={allTemplates.filter((template) => template.mode === 'custom')}
onSelectTemplate={onSelectTemplate}
warnings={warnings}
/>
@@ -15,17 +15,23 @@ import { Button, Popover, cn } from 'ui'
export const LogsTemplatesPage: NextPageWithLayout = () => {
const { ref: projectRef } = useParams()
const isTemplatesEnabled = useIsFeatureEnabled('logs:templates')
const { logsTemplates: isTemplatesEnabled, logsShowMetadataIpTemplate: showMetadataIpTemplate } =
useIsFeatureEnabled(['logs:templates', 'logs:show_metadata_ip_template'])
if (!isTemplatesEnabled) {
return <UnknownInterface urlBack={`/project/${projectRef}/logs/explorer`} />
}
const allTemplates = showMetadataIpTemplate
? TEMPLATES
: TEMPLATES.filter((template) => template.label !== 'Metadata IP')
return (
<div className="mx-auto h-full w-full px-5 py-6">
<LogsExplorerHeader subtitle="Templates" />
<div className="grid lg:grid-cols-3 gap-6 mt-4 pb-24">
{TEMPLATES.sort((a, b) => a.label!.localeCompare(b.label!))
{allTemplates
.sort((a, b) => a.label!.localeCompare(b.label!))
.filter((template) => template.mode === 'custom')
.map((template, i) => {
return <Template key={i} projectRef={projectRef} template={template} />
@@ -0,0 +1,16 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
<style type="text/css">
.st0{fill:#faf9f5;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
</sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 715 B

@@ -0,0 +1,16 @@
<svg version="1.1" id="Layer_1" xmlns:x="ns_extend;" xmlns:i="ns_ai;" xmlns:graph="ns_graphs;" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.2 65" style="enable-background:new 0 0 92.2 65;" xml:space="preserve">
<style type="text/css">
.st0{fill:#181818;}
</style>
<metadata>
<sfw xmlns="ns_sfw;">
<slices>
</slices>
<sliceSourceBounds bottomLeftOrigin="true" height="65" width="92.2" x="-43.7" y="-98">
</sliceSourceBounds>
</sfw>
</metadata>
<path class="st0" d="M66.5,0H52.4l25.7,65h14.1L66.5,0z M25.7,0L0,65h14.4l5.3-13.6h26.9L51.8,65h14.4L40.5,0C40.5,0,25.7,0,25.7,0z
M24.3,39.3l8.8-22.8l8.8,22.8H24.3z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 714 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="36" fill="none"><path fill="url(#a)" d="M0 0h36v36H0z"/><defs><pattern id="a" width="1" height="1" patternContentUnits="objectBoundingBox"><use xlink:href="#b" transform="scale(.0005)"/></pattern><image xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB9AAAAfQCAYAAACaOMR5AAAACXBIWXMAAAsTAAALEwEAmpwYABAAAElEQVR4nOz9edhl55mXhz5rD+VBLckqzYNlyZYnyZbVsixLtsqaSqoqqaHTEAgk0GmGQOgDDTTdTdNtS5ZMNz0EGprQDAnDARIydQYOkEOSQ0aSQBJCEgJR1TfUXJqH0lBV3x7W+aP2s+u3f9+zdsnG2pru+7rq2nuv4Z3W2uur77vf53mbtm0DAAAAAAAAAAAAAAAAAADg/U7v7W4AAAAAAAAAAAAAAAAAAADAOwEEOgAAAAAAAAAAAAAAAAAAQCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIgKBDgAAAAAAAAAAAAAAAAAAEBEIdAAAAAAAAAAAAAAAAAAAgIhAoAMAAAAAAAAAAAAAAAAAAEQEAh0AAAAAAAAAAAAAAAAAACAiEOgAAAAAAAAAAAAAAAAAAAARgUAHAAAAAAAAAAAAAAAAAACICAQ6AAAAAAAAAAAAAAAAAABARCDQAQAAAAAAAAAAAAAAAAAAIiJi8HY3AOD9RtM0b3cTAAAAAADgnccFl1566fUvvPDCP3u7GwIAAAAAAO882rZ9u5sA8L6BCHQAAAAAAACAt5Fbb71170//9E//N1/96lf/0VVXXfXLH/7wh695u9sEAAAAAAAA8H6lYcYKwGohAh0AAAAAACIirr/++pt37979+N133/0bR6NR/Oqv/mo888wzcckllxw/dOjQzx8+fPjPR8SZt7udAAAAAADw9oPPA1gdpHAHAAAAAAAAWCEXXXTRznvvvfcPPfTQQz/yoQ996HvOnDkTL774YnvmzJk4ceJEXHTRRdd8+tOf/lPXXHPND66vrz/23HPP/Z23u80AAAAAAAAA7xcQ6AAAAAAAAACrofnKV77yQ4888sjXr7766o+Px+M4depUu7W11bz22mvNaDSKM2fOxMmTJ9ter9dcdNFFX/ziF7/4t0+cOPGr6+vr33zttdf+ydvdAQAAAAAAAID3Ogh0AAAAAAAAgLeYz3zmM/c++uijT95yyy1fm0wmcebMmXY6ncbW1lbzxhtvxGuvvRZbW1vRtm288sorzY4dO6Lf77fD4bC59tprf/1VV121d2Nj488cOHDgFyPi+be7PwAAAAAAAADvVRDoAAAAAAAAAG8RH/3oRz9x7733fuOee+75rcPhsLe1tRXT6bQdjUbNZDKJ8Xgcp0+fjjNnzkTbtjGdTmM0GsWpU6diOBw2ERFt27b9fv+CT33qUz9xzTXX/Mb19fWfPXr06F+KiMnb3D0AAAAAAACA9xwIdAAAAAAAAIDvMpdddtmFX/rSl37kwQcf/NGdO3funKVnbyOiGY/HzWQyiclkEqdOnYpTp07FaDSKtm2j1+vNpfqOHTsiIuKDH/xg07ZtO51O48Mf/vANt95661+49tprf3Btbe3xF1544e+9vT0FAAAAAAAAeG+BQAcAAAAAAAD4LvLlL3/5N+3bt++xj370o5+NiNja2mqn02kTEc10Oo3pdBqzNO6R6563bRtN08RkMom2bePMmTNx+vTp6Pf70e/3IyKaXq8Xo9Go7fV6zaWXXnrPzp07/6tjx4799YMHD37r5MmTB97WTgMAAAAAAAC8R0CgAwAAAAAAAHwX+OQnP/nlvXv3PnHbbbftmU6nMR6PYxY53rRtO5fj4/E4ptNp5LYU6m3bRkTMP29tbcUHPvCBGI1G0TRNNE0TvV6vmaV6b4fDYXPdddf91iuuuOLXbGxs/MmNjY0/GRGvvK2DAAAAAAAAAPAuB4EOAAAAAAAA8M/Btddee92uXbt+6r777vudw+FwqOI8hXeub55yPAX5aDSK0WgU0+l0Xl4eu7W1FWfOnNEo9BgMBtHr9aLf7zej0SgGg0G7Y8eOj3z2s5/95rXXXvsvr62tPXnixIl/5+0aCwAAAAAAAIB3Owh0AAAAAAAAgO+MDz744IM/vHfv3h/fuXPnVdPpNEajURsRzWQyaSIitra25uuaTyaTGI/HERExHo9jNBrF1tbWQkS6yvbJZDIX7CnOJ5PJPNV7v9+P8Xjc9Hq9ttfrxYUXXvip22677a9fd911v219ff2xF1988X98OwcHAAAAAAAA4N0IAh0AAAAAAADg2+R7v/d7f+0jjzzyxMc//vHbmqaJra2ttmmamEWdLxybkedN08w/pzSfTCbz43K/4gI9IuaSXT43M7HeRkRz1VVXPXjZZZfde+jQob+0sbHxs6dPnz70Fg0DAAAAAAAAwHsOBDoAAAAAAADAm+TGG2/8wu7du5+46667vj/XM2/PLl7epNROKZ7rmue/lOanT5+OWbT6fFv+UzQSPaPRk+FwGJPJJHq9Xkyn06y36ff7MRqN2l6vN7jxxht/11VXXfUDm5ubv7i5uflnIuKNFQ4VAAAAAAAAwLsSBDoAAAAAAADAebjiiiuuvOeee378wQcf/OEPfehDH5pMJjGdTtu2Line truncated

After

Width:  |  Height:  |  Size: 1.5 MiB

Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more