mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
Merge branch 'master' into dnywh/prototype/warehouse
This commit is contained in:
commit
cbe143fb00
156 files changed
+4671
-2580
No files matched your search
+1
-1
@@ -19,4 +19,4 @@
|
||||
/apps/studio/components/interfaces/Organization/Documents/ @supabase/security
|
||||
/apps/studio/pages/new/index.tsx @supabase/security
|
||||
|
||||
/packages/shared-data/compute-disk-limits.ts @supabase/infra
|
||||
/packages/shared-data/compute-disk-limits.ts @supabase/infra @supabase/platform
|
||||
@@ -1 +0,0 @@
|
||||
apps/docs/public/.well-known/security.txt
|
||||
@@ -12,6 +12,8 @@ import { TelemetryTagManager } from 'common'
|
||||
import { genFaviconData } from 'common/MetaFavicons/app-router'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
|
||||
import { inter, manrope } from '@/fonts'
|
||||
|
||||
const { metadataApplicationName, metadataTitle } = getCustomContent([
|
||||
'metadata:application_name',
|
||||
'metadata:title',
|
||||
@@ -50,7 +52,7 @@ const viewport: Viewport = {
|
||||
|
||||
const RootLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="en" className={`${manrope.variable} ${inter.variable}`} suppressHydrationWarning>
|
||||
<body>
|
||||
<TelemetryTagManager />
|
||||
<GlobalProviders>
|
||||
|
||||
@@ -2096,6 +2096,7 @@ export const storage: NavMenuConstant = {
|
||||
items: [
|
||||
{ name: 'Fundamentals', url: '/guides/storage/cdn/fundamentals' },
|
||||
{ name: 'Smart CDN', url: '/guides/storage/cdn/smart-cdn' },
|
||||
{ name: 'Purging Cache', url: '/guides/storage/cdn/purge-cdn-cache' },
|
||||
{ name: 'Metrics', url: '/guides/storage/cdn/metrics' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@ const TopNavBar: FC = () => {
|
||||
<div className="flex gap-2 items-center">
|
||||
<DevToolbarTrigger />
|
||||
<CommandMenuTriggerInput
|
||||
className="[&>div>p]:text-foreground-lighter"
|
||||
placeholder={
|
||||
<>
|
||||
Search
|
||||
|
||||
@@ -80,7 +80,7 @@ const Step: FC<PropsWithChildren<IStep>> = ({ children, title, step }) => {
|
||||
const Details: FC<PropsWithChildren<IDetails>> = ({ children, title, fullWidth = false }) => {
|
||||
return (
|
||||
<div className={cn(fullWidth ? 'col-span-12' : 'col-span-5', 'ml-12', 'lg:ml-0')}>
|
||||
<h3 className="mt-0 text-foreground text-base">{title}</h3>
|
||||
<h3 className="not-prose mb-4 text-foreground text-base">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -119,7 +119,7 @@ Parameters marked with **Restart: Yes** cause the CLI to automatically restart y
|
||||
|
||||
</Admonition>
|
||||
|
||||
Use the examples below with `supabase --experimental --project-ref <project-ref> postgres-config update`:
|
||||
Use the examples below with `supabase postgres-config update --project-ref <project-ref> --experimental`:
|
||||
|
||||
| Parameter | Type | Restart | Example |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ------- | --------------------------------------------- |
|
||||
@@ -165,26 +165,26 @@ To start:
|
||||
To update Postgres configurations, use the [`postgres config`](/docs/reference/cli/supabase-postgres-config) command:
|
||||
|
||||
```bash
|
||||
supabase --experimental \
|
||||
postgres-config update --config shared_buffers=250MB \
|
||||
--project-ref <project-ref>
|
||||
supabase postgres-config update --config shared_buffers=250MB \
|
||||
--project-ref <project-ref> \
|
||||
--experimental
|
||||
```
|
||||
|
||||
By default, the CLI will merge any provided config overrides with any existing ones. The `--replace-existing-overrides` flag can be used to instead force all existing overrides to be replaced with the ones being provided:
|
||||
|
||||
```bash
|
||||
supabase --experimental \
|
||||
postgres-config update --config max_parallel_workers=3 \
|
||||
supabase postgres-config update --config max_parallel_workers=3 \
|
||||
--replace-existing-overrides \
|
||||
--project-ref <project-ref>
|
||||
--project-ref <project-ref> \
|
||||
--experimental
|
||||
```
|
||||
|
||||
To delete specific configuration overrides, use the `postgres-config delete` command:
|
||||
|
||||
```bash
|
||||
supabase --experimental \
|
||||
postgres-config delete --config shared_buffers,work_mem \
|
||||
--project-ref <project-ref>
|
||||
supabase postgres-config delete --config shared_buffers,work_mem \
|
||||
--project-ref <project-ref> \
|
||||
--experimental
|
||||
```
|
||||
|
||||
By default, CLI v2 (≥ 2.0.0) checks the parameter’s context and requests the correct action (reload or restart):
|
||||
@@ -223,9 +223,9 @@ By default, Supabase ensures that this propagation is executed correctly. Howeve
|
||||
</Admonition>
|
||||
|
||||
```bash
|
||||
supabase --experimental \
|
||||
supabase postgres-config delete --config shared_buffers --no-restart \
|
||||
--project-ref <project-ref> \
|
||||
postgres-config delete --config shared_buffers --no-restart
|
||||
--experimental
|
||||
```
|
||||
|
||||
### Resetting to default config
|
||||
|
||||
@@ -47,7 +47,7 @@ Most inspection commands are Postgres agnostic. You can run inspection routines
|
||||
For example you can connect to your local Postgres instance:
|
||||
|
||||
```
|
||||
supabase --db-url postgresql://postgres:postgres@localhost:5432/postgres inspect db bloat
|
||||
supabase inspect db bloat --db-url postgresql://postgres:postgres@localhost:5432/postgres
|
||||
```
|
||||
|
||||
### Connect to a Supabase instance
|
||||
|
||||
@@ -20,8 +20,9 @@ For your bot to seamlessly interact with Slack, you'll need to configure Slack A
|
||||
Deploy the following code as an Edge function using the CLI:
|
||||
|
||||
```bash
|
||||
supabase --project-ref nacho_slacker secrets \
|
||||
set SLACK_TOKEN=<xoxb-0000000000-0000000000-01010101010nacho101010>
|
||||
supabase secrets set \
|
||||
SLACK_TOKEN=<xoxb-0000000000-0000000000-01010101010nacho101010> \
|
||||
--project-ref nacho_slacker
|
||||
```
|
||||
|
||||
Here's the code of the Edge Function, you can change the response to handle the text received:
|
||||
|
||||
@@ -168,7 +168,7 @@ Assume your Supabase project's domain is `abcdefghijklmnopqrst.supabase.co` and
|
||||
Use the [`vanity-subdomains check-availability`](/docs/reference/cli/supabase-vanity-subdomains-check-availability) command of the CLI to check if your desired subdomain is available for use:
|
||||
|
||||
```bash
|
||||
supabase vanity-subdomains --project-ref abcdefghijklmnopqrst check-availability --desired-subdomain my-example-brand --experimental
|
||||
supabase vanity-subdomains check-availability --project-ref abcdefghijklmnopqrst --desired-subdomain my-example-brand --experimental
|
||||
```
|
||||
|
||||
### Prepare to activate the subdomain
|
||||
@@ -200,7 +200,7 @@ Once you've chosen an available subdomain and have done all the necessary prepar
|
||||
Use the [`vanity-subdomains activate`](/docs/reference/cli/supabase-vanity-subdomains-activate) command to activate and claim your subdomain:
|
||||
|
||||
```bash
|
||||
supabase vanity-subdomains --project-ref abcdefghijklmnopqrst activate --desired-subdomain my-example-brand --experimental
|
||||
supabase vanity-subdomains activate --project-ref abcdefghijklmnopqrst --desired-subdomain my-example-brand --experimental
|
||||
```
|
||||
|
||||
If you wish to use the new domain in client code, you can set it up like so:
|
||||
|
||||
@@ -70,7 +70,7 @@ To get started:
|
||||
You can use the `get` subcommand of the CLI to check whether SSL is currently being enforced:
|
||||
|
||||
```bash
|
||||
supabase ssl-enforcement --project-ref {ref} get --experimental
|
||||
supabase ssl-enforcement get --project-ref {ref} --experimental
|
||||
```
|
||||
|
||||
Response if SSL is being enforced:
|
||||
@@ -90,13 +90,13 @@ SSL is *NOT* being enforced.
|
||||
The `update` subcommand is used to change the SSL enforcement status for your project:
|
||||
|
||||
```bash
|
||||
supabase ssl-enforcement --project-ref {ref} update --enable-db-ssl-enforcement --experimental
|
||||
supabase ssl-enforcement update --project-ref {ref} --enable-db-ssl-enforcement --experimental
|
||||
```
|
||||
|
||||
Similarly, to disable SSL enforcement:
|
||||
|
||||
```bash
|
||||
supabase ssl-enforcement --project-ref {ref} update --disable-db-ssl-enforcement --experimental
|
||||
supabase ssl-enforcement update --project-ref {ref} --disable-db-ssl-enforcement --experimental
|
||||
```
|
||||
|
||||
### A note about Postgres SSL modes
|
||||
|
||||
@@ -514,9 +514,10 @@ sh run.sh recreate
|
||||
The `generate-keys.sh` script sets the following secrets automatically. You can also configure them manually in the `.env` file if needed:
|
||||
|
||||
- `SECRET_KEY_BASE`: encryption key for securing Realtime and Supavisor communications. (Must be at least 64 characters; generate with `openssl rand -base64 48`)
|
||||
- `REALTIME_DB_ENC_KEY`: encryption key used by Realtime for sensitive fields in the `_realtime` schema. (Must be exactly 16 characters; generate with `openssl rand -hex 8`)
|
||||
- `VAULT_ENC_KEY`: encryption key used by Supavisor for storing encrypted configuration. (Must be exactly 32 characters; generate with `openssl rand -hex 16`)
|
||||
- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings used by Studio against postgres-meta. (Must be at least 32 characters; generate with `openssl rand -base64 24`)
|
||||
- `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion and querying. Used by Vector and Studio to send and query logs. (Must be at least 32 characters; generate with `openssl rand -base64 24`)
|
||||
- `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion used by Logflare and Vector. (Must be at least 32 characters; generate with `openssl rand -base64 24`)
|
||||
- `LOGFLARE_PRIVATE_ACCESS_TOKEN`: API token for Logflare management operations. Used by Studio for administrative tasks. Never expose client-side. (Must be at least 32 characters; generate with `openssl rand -base64 24`)
|
||||
- `S3_PROTOCOL_ACCESS_KEY_ID`: Access key ID (username-like) for [accessing](/docs/guides/self-hosting/self-hosted-s3) the S3 protocol endpoint in Storage. (Generate with `openssl rand -hex 16`)
|
||||
- `S3_PROTOCOL_ACCESS_KEY_SECRET`: Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID. (Generate with `openssl rand -hex 32`)
|
||||
|
||||
@@ -61,7 +61,7 @@ storage:
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Podman does not support nested variable interpolation (`${A:-${B}}`). If you are using Podman, replace each nested expression with the required variable directly - see the inline comments in `docker-compose.yml` for the exact substitutions.
|
||||
Nested variable interpolation (`${A:-${B}}`) requires `podman-compose >= 1.6.0`. Earlier versions (still shipped by some Linux distributions) do not support it - if you are on an older `podman-compose`, either upgrade or replace each nested expression with the required variable directly, see the inline comments in `docker-compose.yml` for the exact substitutions.
|
||||
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
id: 'storage-cdn-purge-cache'
|
||||
title: 'Purge CDN Cache'
|
||||
description: 'Learn how to manually purge Supabase Storage CDN cache.'
|
||||
sidebar_label: 'CDN'
|
||||
---
|
||||
|
||||
With Smart CDN enabled, Supabase Storage automatically invalidates the cache when files are updated or deleted. However, there are scenarios where you may need to manually purge the CDN cache for specific objects or entire buckets. The cache purge API allows you to immediately queue cache content invalidation across all CDN edge nodes.
|
||||
|
||||
Manual cache purging is useful when you need to ensure that updates are propagated as soon as possible, or when you want to clear the cache for debugging purposes. Once purged, the next request for that object will be served from the origin server, and the CDN cache will be repopulated.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
Cache purging requires the **secret key**. The server rejects calls made with the legacy anon key or a user JWT. Never expose your secret key in client-side code.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
CDN cache purge is available for [Pro Plan and above](/pricing).
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Purge a single object
|
||||
|
||||
You can purge the CDN cache for a specific file by providing the exact path to the object. This operation does not support wildcards or recursion. You must specify the complete path of the file you want to invalidate.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="js"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```javascript
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Create Supabase client with secret key
|
||||
const supabase = createClient('your_project_url', 'your_secret_key')
|
||||
|
||||
// Purge cache for a single object
|
||||
async function purgeCachedObject() {
|
||||
const { data, error } = await supabase.storage
|
||||
.from('bucket_name')
|
||||
.purgeCache('folder_name/file_name.png')
|
||||
|
||||
if (error) {
|
||||
// Handle error
|
||||
} else {
|
||||
// Handle success
|
||||
console.log(data.message) // 'success'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="curl" label="cURL">
|
||||
|
||||
```bash
|
||||
curl -X DELETE "https://{your_project_ref}.supabase.co/storage/v1/cdn/bucket_name/folder_name/file_name.png" \
|
||||
-H "apikey: {your_secret_key}"
|
||||
|
||||
# If using legacy jwt keys use this header: Authorization: Bearer {your_service_role_jwt}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Purge an entire bucket
|
||||
|
||||
For scenarios where you need to invalidate all cached objects in a bucket, you can purge the entire bucket's cache. This is useful when performing bulk updates or major changes to your storage bucket.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="js"
|
||||
queryGroup="language"
|
||||
>
|
||||
<TabPanel id="js" label="JavaScript">
|
||||
|
||||
```javascript
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Create Supabase client with secret key
|
||||
const supabase = createClient('your_project_url', 'your_secret_key')
|
||||
|
||||
// Purge cache for an entire bucket
|
||||
async function purgeBucketCache() {
|
||||
const { data, error } = await supabase.storage.purgeBucketCache('bucket_name')
|
||||
|
||||
if (error) {
|
||||
// Handle error
|
||||
} else {
|
||||
// Handle success
|
||||
console.log(data.message) // 'success'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
<TabPanel id="curl" label="cURL">
|
||||
|
||||
```bash
|
||||
curl -X DELETE "https://{your_project_ref}.supabase.co/storage/v1/cdn/bucket_name" \
|
||||
-H "apikey: {your_secret_key}"
|
||||
|
||||
# If using legacy jwt keys use this header: Authorization: Bearer {your_service_role_jwt}
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
## Cache propagation
|
||||
|
||||
After purging the cache, it can take **up to 60 seconds** for the invalidation to propagate across all CDN edge nodes worldwide. During this time, some users may still receive cached content depending on which edge node they are routed to.
|
||||
|
||||
Keep in mind that purging the CDN cache does not affect browser caches. If users have the asset cached locally in their browser, they will continue to see the cached version until the browser cache expires based on the `cacheControl` value set during upload.
|
||||
@@ -101,9 +101,9 @@ const GuideTemplate = ({
|
||||
<ReactMarkdown>{meta?.title || 'Supabase Docs'}</ReactMarkdown>
|
||||
</h1>
|
||||
{meta?.subtitle && (
|
||||
<h2 className="mt-3 text-xl text-foreground-light">
|
||||
<div className="mt-6 not-prose [&_p]:text-xl [&_p]:leading-7 text-foreground-light [&>p]:m-0">
|
||||
<ReactMarkdown>{meta.subtitle}</ReactMarkdown>
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
<hr className="not-prose border-t-0 border-b my-8" />
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
import { useGuide } from './Guide'
|
||||
|
||||
interface GuideHeaderProps {
|
||||
@@ -16,9 +17,9 @@ export function GuideHeader({ className }: GuideHeaderProps) {
|
||||
<ReactMarkdown>{meta?.title || 'Supabase Docs'}</ReactMarkdown>
|
||||
</h1>
|
||||
{meta?.subtitle && (
|
||||
<h2 className="mt-3 text-xl text-foreground-light">
|
||||
<div className="mt-6 not-prose [&_p]:text-xl [&_p]:leading-7 text-foreground-light [&>p]:m-0">
|
||||
<ReactMarkdown>{meta.subtitle}</ReactMarkdown>
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
<hr className="not-prose border-t-0 border-b my-8" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Inter, Manrope } from 'next/font/google'
|
||||
|
||||
export const manrope = Manrope({
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
export const inter = Inter({
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
subsets: ['latin'],
|
||||
})
|
||||
@@ -516,6 +516,7 @@ export const MARKDOWN_SLUGS: readonly string[] = [
|
||||
"storage/buckets/fundamentals",
|
||||
"storage/cdn/fundamentals",
|
||||
"storage/cdn/metrics",
|
||||
"storage/cdn/purge-cdn-cache",
|
||||
"storage/cdn/smart-cdn",
|
||||
"storage/debugging/error-codes",
|
||||
"storage/debugging/logs",
|
||||
|
||||
@@ -11,6 +11,29 @@
|
||||
@source './../../../packages/ui/src/**/*.{tsx,ts,js}';
|
||||
@source './../../../packages/ui-patterns/src/**/*.{tsx,ts,js}';
|
||||
|
||||
@theme inline {
|
||||
--font-sans:
|
||||
var(--font-inter), Inter, Helvetica Neue, Helvetica, ui-sans-serif, system-ui, sans-serif;
|
||||
--font-heading: var(--font-manrope, var(--font-sans));
|
||||
--font-mono: 'Source Code Pro', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.9375rem;
|
||||
--text-lg: 1rem;
|
||||
--text-xl: 1.125rem;
|
||||
--text-2xl: 1.375rem;
|
||||
--text-3xl: 1.75rem;
|
||||
--text-4xl: 2.125rem;
|
||||
--text-5xl: 2.875rem;
|
||||
--text-6xl: 3.625rem;
|
||||
--text-7xl: 4.375rem;
|
||||
--text-8xl: 5.875rem;
|
||||
--text-9xl: 7.875rem;
|
||||
--font-weight-normal: 450;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.prose--remove-p-margin p {
|
||||
margin: 0;
|
||||
@@ -25,26 +48,28 @@
|
||||
::file-selector-button {
|
||||
border-color: hsl(var(--border-default, currentColor));
|
||||
}
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'custom-font';
|
||||
src:
|
||||
url(../../../packages/common/assets/fonts/CustomFont-Book.woff2) format('woff2'),
|
||||
url(../../../packages/common/assets/fonts/CustomFont-Book.woff) format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'custom-font';
|
||||
src:
|
||||
url(../../../packages/common/assets/fonts/CustomFont-Medium.woff2) format('woff2'),
|
||||
url(../../../packages/common/assets/fonts/CustomFont-Medium.woff) format('woff');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
code,
|
||||
.code-content,
|
||||
pre,
|
||||
kbd,
|
||||
samp,
|
||||
.font-mono {
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-7xl: 4.5rem;
|
||||
--text-8xl: 6rem;
|
||||
--text-9xl: 8rem;
|
||||
--font-weight-normal: 400;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -52,9 +77,17 @@
|
||||
--header-height: 50px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
html[data-theme*='dark'] {
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -64,14 +97,19 @@ body {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* a { */
|
||||
/* text-decoration: none !important; */
|
||||
/* } */
|
||||
|
||||
article h1 {
|
||||
/* margin-bottom: 2rem !important; */
|
||||
/* font-size: 3rem !important; */
|
||||
/* font-weight: 400 !important; */
|
||||
h1:not(.font-mono),
|
||||
h2:not(.font-mono),
|
||||
h3:not(.font-mono),
|
||||
h4:not(.font-mono),
|
||||
h5:not(.font-mono),
|
||||
h6:not(.font-mono),
|
||||
.h1:not(.font-mono),
|
||||
.h2:not(.font-mono),
|
||||
.h3:not(.font-mono),
|
||||
.h4:not(.font-mono),
|
||||
.h5:not(.font-mono),
|
||||
.h6:not(.font-mono) {
|
||||
@apply font-heading font-semibold;
|
||||
}
|
||||
|
||||
.thin-scrollbar {
|
||||
@@ -103,10 +141,36 @@ article h1 {
|
||||
@apply text-foreground-muted text-xs;
|
||||
}
|
||||
|
||||
.prose :where(p):not(:where([class~='not-prose'] *)) {
|
||||
.prose :where(p):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.prose :where(p, a, code, pre):not(:where([class~='not-prose'], [class~='not-prose'] *)),
|
||||
.prose :where(ol > li):not(:where([class~='not-prose'], [class~='not-prose'] *))::marker {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.prose :where(h1, h2, h3, h4, h5, h6):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
@apply font-heading font-semibold;
|
||||
}
|
||||
|
||||
.prose
|
||||
:where(p, li, a, figcaption):not(:where([class~='not-prose'], [class~='not-prose'] *)):not(
|
||||
:where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6) *
|
||||
) {
|
||||
@apply text-base leading-7;
|
||||
}
|
||||
|
||||
.prose :where(h1, .h1):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
@apply text-4xl;
|
||||
}
|
||||
.prose :where(h2, .h2):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
@apply text-2xl;
|
||||
}
|
||||
.prose :where(h3, .h3):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
@apply text-xl leading-7;
|
||||
}
|
||||
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
text-shadow: none !important;
|
||||
@@ -136,7 +200,7 @@ pre[class*='language-'] {
|
||||
}
|
||||
|
||||
.method-list-title-isChild {
|
||||
font-family: var(--custom-font-size-xs);
|
||||
font-family: var(--font-mono);
|
||||
& h5 {
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from 'ui-patterns/PageSection'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { parseRedirectMessage } from './AccountIdentities.utils'
|
||||
import {
|
||||
ChangeEmailAddressForm,
|
||||
GitHubChangeEmailAddress,
|
||||
@@ -68,7 +69,7 @@ export const AccountIdentities = () => {
|
||||
const [selectedProviderUpdateEmail, setSelectedProviderUpdateEmail] = useState<string>()
|
||||
const [linkingProviderId, setLinkingProviderId] = useState<string>()
|
||||
|
||||
const [, message] = router.asPath.split('#message=')
|
||||
const message = parseRedirectMessage(router.asPath)
|
||||
const unlinkedExternalProviders = connectableExternalProviders.filter((provider) => {
|
||||
return !identities.some(
|
||||
(identity) => identity.provider === provider.authProvider || identity.provider === provider.id
|
||||
@@ -119,7 +120,7 @@ export const AccountIdentities = () => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (message) toast.success(message.replaceAll('+', ' '))
|
||||
if (message) toast.success(message)
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseRedirectMessage } from './AccountIdentities.utils'
|
||||
|
||||
describe('parseRedirectMessage', () => {
|
||||
it('drops the trailing sb marker and decodes + as spaces', () => {
|
||||
expect(
|
||||
parseRedirectMessage(
|
||||
'/account/me#message=Confirmation+link+accepted.+Please+proceed+to+confirm+link+sent+to+the+other+email&sb='
|
||||
)
|
||||
).toBe('Confirmation link accepted. Please proceed to confirm link sent to the other email')
|
||||
})
|
||||
|
||||
it('returns undefined when there is no hash', () => {
|
||||
expect(parseRedirectMessage('/account/me')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when the fragment has no message key', () => {
|
||||
expect(parseRedirectMessage('/account/me#sb=')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('finds message even when it is not the first fragment param', () => {
|
||||
expect(parseRedirectMessage('/account/me#sb=&message=Hi+there')).toBe('Hi there')
|
||||
})
|
||||
|
||||
it('preserves a literal + via percent-encoding', () => {
|
||||
expect(parseRedirectMessage('/account/me#message=a%2Bb&sb=')).toBe('a+b')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export const parseRedirectMessage = (asPath: string) =>
|
||||
new URLSearchParams(asPath.split('#')[1] ?? '').get('message') ?? undefined
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'react'
|
||||
|
||||
import { useFeaturePreviews } from './useFeaturePreviews'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { EMPTY_OBJ } from '@/lib/void'
|
||||
|
||||
type FeaturePreviewContextType = {
|
||||
@@ -43,6 +44,11 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) =
|
||||
const initializeFlags = useEffectEvent(() => {
|
||||
setFlags(
|
||||
featurePreviews.reduce((a, b) => {
|
||||
// Platform-only previews can never be enabled outside the hosted platform
|
||||
if (!IS_PLATFORM && b.isPlatformOnly) {
|
||||
return { ...a, [b.key]: false }
|
||||
}
|
||||
|
||||
const defaultOptIn = b.isDefaultOptIn
|
||||
const localStorageValue = safeLocalStorage.getItem(b.key)
|
||||
return {
|
||||
@@ -101,8 +107,7 @@ export const useUnifiedLogsPreview = () => {
|
||||
|
||||
export const useIsPgDeltaDiffEnabled = () => {
|
||||
const { flags } = useFeaturePreviewContext()
|
||||
const pgDeltaDiffEnabled = useFlag('pgdeltaDiff')
|
||||
return pgDeltaDiffEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF]
|
||||
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF]
|
||||
}
|
||||
|
||||
export const useIsAdvisorRulesEnabled = () => {
|
||||
|
||||
@@ -20,7 +20,6 @@ export type FeaturePreview = {
|
||||
}
|
||||
|
||||
export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
const pgDeltaDiffEnabled = useFlag('pgdeltaDiff')
|
||||
const platformWebhooksEnabled = useFlag('platformWebhooks')
|
||||
const jitDbAccessEnabled = useFlag('jitDbAccess')
|
||||
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
|
||||
@@ -68,7 +67,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
isNew: false,
|
||||
isPlatformOnly: true,
|
||||
isDefaultOptIn: true,
|
||||
enabled: pgDeltaDiffEnabled,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS,
|
||||
@@ -111,12 +110,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
|
||||
getRoute: (ref?: string) => `/project/${ref}/integrations`,
|
||||
},
|
||||
].sort((a, b) => Number(b.isNew) - Number(a.isNew)),
|
||||
[
|
||||
unifiedLogsDefaultOptIn,
|
||||
pgDeltaDiffEnabled,
|
||||
platformWebhooksEnabled,
|
||||
jitDbAccessEnabled,
|
||||
isMarketplaceEnabled,
|
||||
]
|
||||
[unifiedLogsDefaultOptIn, platformWebhooksEnabled, jitDbAccessEnabled, isMarketplaceEnabled]
|
||||
)
|
||||
}
|
||||
@@ -42,5 +42,22 @@ export function urlRegex(
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeRedirectUrl(url: string): string {
|
||||
return url.trim().replace(/\s*,\s*$/, '')
|
||||
}
|
||||
|
||||
export function parseRedirectUrls(allowList?: string | null): string[] {
|
||||
if (!allowList) return []
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
allowList
|
||||
.split(',')
|
||||
.map(normalizeRedirectUrl)
|
||||
.filter((url) => url.length > 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Use a const string to represent no chars option. Represented as empty string on the backend side.
|
||||
export const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS'
|
||||
@@ -80,7 +80,7 @@ describe('AddNewURLModal', () => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Successfully added 1 URL')
|
||||
})
|
||||
|
||||
it('dedupes URLs after normalising a trailing comma before submitting', async () => {
|
||||
it('normalizes a trailing-comma URL before submitting', async () => {
|
||||
const user = userEvent.setup()
|
||||
mutateMock.mockImplementation((_vars, callbacks) => callbacks?.onSuccess?.())
|
||||
|
||||
@@ -88,11 +88,7 @@ describe('AddNewURLModal', () => {
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://mydomain.com'), 'https://app.example.com')
|
||||
await user.click(screen.getByRole('button', { name: 'Add URL' }))
|
||||
|
||||
const urlInputs = screen.getAllByPlaceholderText('https://mydomain.com')
|
||||
await user.type(urlInputs[1], 'https://app.example.com,')
|
||||
await user.type(screen.getByPlaceholderText('https://mydomain.com'), 'https://app.example.com,')
|
||||
|
||||
fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement)
|
||||
|
||||
@@ -130,4 +126,24 @@ describe('AddNewURLModal', () => {
|
||||
expect(await screen.findByText('URL already exists in the allow list')).toBeInTheDocument()
|
||||
expect(mutateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a whitespace-padded URL when it already exists in the allow list', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<AddNewURLModal visible allowList={['https://existing.example.com']} onClose={vi.fn()} />
|
||||
)
|
||||
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('https://mydomain.com'),
|
||||
' https://existing.example.com '
|
||||
)
|
||||
|
||||
fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement)
|
||||
|
||||
expect(await screen.findByText('URL already exists in the allow list')).toBeInTheDocument()
|
||||
expect(mutateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { urlRegex } from '../Auth.constants'
|
||||
import { normalizeRedirectUrl, parseRedirectUrls, urlRegex } from '../Auth.constants'
|
||||
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
|
||||
|
||||
const MAX_URLS_LENGTH = 2 * 1024
|
||||
@@ -33,61 +33,88 @@ interface AddNewURLModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const normaliseUrl = (value: string) => value.replace(/,\s*$/, '')
|
||||
const createRedirectUrlsSchema = (normalizedAllowList: string[]) => {
|
||||
const redirectUrlRegex = urlRegex()
|
||||
|
||||
return z
|
||||
.object({
|
||||
urls: z
|
||||
.object({
|
||||
value: z.string().trim().min(1, 'Please provide a value').transform(normalizeRedirectUrl),
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const seenUrls = new Set<string>()
|
||||
|
||||
data.urls.forEach((url, index) => {
|
||||
if (!redirectUrlRegex.test(url.value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['urls', index, 'value'],
|
||||
message: 'Please provide a valid URL',
|
||||
})
|
||||
}
|
||||
|
||||
if (normalizedAllowList.includes(url.value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['urls', index, 'value'],
|
||||
message: 'URL already exists in the allow list',
|
||||
})
|
||||
}
|
||||
|
||||
if (seenUrls.has(url.value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['urls', index, 'value'],
|
||||
message: 'URL already exists in this list',
|
||||
})
|
||||
}
|
||||
|
||||
seenUrls.add(url.value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const AddNewURLModal = ({ visible, allowList, onClose }: AddNewURLModalProps) => {
|
||||
const { ref } = useParams()
|
||||
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation()
|
||||
const redirectUrlRegex = urlRegex()
|
||||
|
||||
const FormSchema = z.object({
|
||||
urls: z
|
||||
.object({
|
||||
value: z
|
||||
.string()
|
||||
.min(1, 'Please provide a value')
|
||||
.refine(
|
||||
(value) => redirectUrlRegex.test(normaliseUrl(value)),
|
||||
'Please provide a valid URL'
|
||||
)
|
||||
.refine((value) => !allowList.includes(normaliseUrl(value)), {
|
||||
message: 'URL already exists in the allow list',
|
||||
}),
|
||||
})
|
||||
.array()
|
||||
.default([]),
|
||||
})
|
||||
const normalizedAllowList = parseRedirectUrls(allowList.join(','))
|
||||
const formSchema = createRedirectUrlsSchema(normalizedAllowList)
|
||||
|
||||
const initialValues = { urls: [{ value: '' }] }
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: initialValues,
|
||||
})
|
||||
const urls = form.watch('urls')
|
||||
|
||||
const onSubmit = (data: z.infer<typeof FormSchema>) => {
|
||||
const dedupedUrls = [...new Set(data.urls.map((url) => normaliseUrl(url.value)))]
|
||||
const payloadUrls = allowList.concat(dedupedUrls)
|
||||
const addedCount = dedupedUrls.length
|
||||
const payload = payloadUrls.toString()
|
||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||
const payload = parseRedirectUrls(
|
||||
normalizedAllowList.concat(data.urls.map((url) => url.value)).join(',')
|
||||
)
|
||||
const payloadString = payload.join(',')
|
||||
const addedCount = payload.length - normalizedAllowList.length
|
||||
|
||||
if (payload.length > MAX_URLS_LENGTH) {
|
||||
if (payloadString.length > MAX_URLS_LENGTH) {
|
||||
return toast.error('Too many redirect URLs, please remove some or try to use wildcards')
|
||||
} else {
|
||||
updateAuthConfig(
|
||||
{ projectRef: ref!, config: { URI_ALLOW_LIST: payload } },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to add URL(s): ${error?.message}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully added ${addedCount} URL${addedCount > 1 ? 's' : ''}`)
|
||||
form.reset(initialValues)
|
||||
onClose()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
updateAuthConfig(
|
||||
{ projectRef: ref!, config: { URI_ALLOW_LIST: payloadString } },
|
||||
{
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to add URL(s): ${error?.message}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(`Successfully added ${addedCount} URL${addedCount > 1 ? 's' : ''}`)
|
||||
form.reset(initialValues)
|
||||
onClose()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
|
||||
import { parseRedirectUrls } from '../Auth.constants'
|
||||
import { AddNewURLModal } from './AddNewURLModal'
|
||||
import { RedirectUrlList } from './RedirectUrlList'
|
||||
import { ValueContainer } from './ValueContainer'
|
||||
@@ -46,9 +47,7 @@ export const RedirectUrls = () => {
|
||||
useAuthConfigUpdateMutation()
|
||||
|
||||
const URI_ALLOW_LIST_ARRAY = useMemo(() => {
|
||||
return authConfig?.URI_ALLOW_LIST
|
||||
? authConfig.URI_ALLOW_LIST.split(/\s*[,]+\s*/).filter((url: string) => url)
|
||||
: []
|
||||
return parseRedirectUrls(authConfig?.URI_ALLOW_LIST)
|
||||
}, [authConfig?.URI_ALLOW_LIST])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -58,8 +57,7 @@ export const RedirectUrls = () => {
|
||||
const onConfirmDeleteUrl = async (urls?: string[]) => {
|
||||
if (!urls || urls.length === 0) return
|
||||
|
||||
// Remove selectedUrl from array and update
|
||||
const payload = URI_ALLOW_LIST_ARRAY.filter((url: string) => !selectedUrls.includes(url))
|
||||
const payload = URI_ALLOW_LIST_ARRAY.filter((url: string) => !urls.includes(url))
|
||||
const payloadString = payload.join(',')
|
||||
await updateAuthConfig(
|
||||
{ projectRef: projectRef!, config: { URI_ALLOW_LIST: payloadString } },
|
||||
|
||||
@@ -1,6 +1,341 @@
|
||||
export const POLICY_MODAL_VIEWS = {
|
||||
SELECTION: 'SELECTION',
|
||||
TEMPLATES: 'TEMPLATES',
|
||||
EDITOR: 'EDITOR',
|
||||
REVIEW: 'REVIEW',
|
||||
import { safeSql } from '@supabase/pg-meta/src/pg-format'
|
||||
import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
|
||||
|
||||
export interface PolicyTemplate {
|
||||
id: string
|
||||
preview: boolean
|
||||
templateName: string
|
||||
description: string
|
||||
name: string
|
||||
statement: string
|
||||
definition: SafeSqlFragment
|
||||
check: SafeSqlFragment
|
||||
command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL'
|
||||
roles: Array<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* ----------------------------------------------------------------
|
||||
* PostgreSQL policy templates for the auth policies page
|
||||
* ----------------------------------------------------------------
|
||||
* id: Unique identifier for the monaco editor to dynamically refresh
|
||||
* templateName: As a display for a more descriptive title for the policy
|
||||
* description: Additional details about the template and how to make it yours
|
||||
* statement: SQL statement template for the policy
|
||||
*
|
||||
* name: Actual policy name that will be used in the editor
|
||||
* definition: Actual policy using expression that will be used in the editor
|
||||
* check: Actual policy with check expression that will be used in the editor
|
||||
* command: Operation to create policy for
|
||||
*/
|
||||
|
||||
export const getGeneralPolicyTemplates = (schema: string, table: string): PolicyTemplate[] => [
|
||||
{
|
||||
id: 'policy-1',
|
||||
preview: false,
|
||||
templateName: 'Enable read access to everyone',
|
||||
description:
|
||||
'This policy gives read access to your table for all users via the SELECT operation.',
|
||||
statement: `
|
||||
create policy "Enable read access for all users"
|
||||
on "${schema}"."${table}"
|
||||
for select using (true);`.trim(),
|
||||
name: 'Enable read access for all users',
|
||||
definition: safeSql`true`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-2',
|
||||
preview: false,
|
||||
templateName: 'Enable insert access for authenticated users only',
|
||||
description: 'This policy gives insert access to your table for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "${schema}"."${table}"
|
||||
for insert to authenticated
|
||||
with check (true);`.trim(),
|
||||
name: 'Enable insert for authenticated users only',
|
||||
definition: safeSql``,
|
||||
check: safeSql`true`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-3',
|
||||
preview: false,
|
||||
templateName: 'Enable delete access for users based on their user ID *',
|
||||
description:
|
||||
'This policy assumes that your table has a column "user_id", and allows users to delete rows which the "user_id" column matches their ID',
|
||||
statement: `
|
||||
create policy "Enable delete for users based on user_id"
|
||||
on "${schema}"."${table}"
|
||||
for delete using (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable delete for users based on user_id',
|
||||
definition: safeSql`(select auth.uid()) = user_id`,
|
||||
check: safeSql``,
|
||||
command: 'DELETE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-4',
|
||||
preview: false,
|
||||
templateName: 'Enable insert access for users based on their user ID *',
|
||||
description:
|
||||
'This policy assumes that your table has a column "user_id", and allows users to insert rows which the "user_id" column matches their ID',
|
||||
statement: `
|
||||
create policy "Enable insert for users based on user_id"
|
||||
on "${schema}"."${table}"
|
||||
for insert with check (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable insert for users based on user_id',
|
||||
definition: safeSql``,
|
||||
check: safeSql`(select auth.uid()) = user_id`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-5',
|
||||
preview: true,
|
||||
name: 'Policy with table joins',
|
||||
templateName: 'Policy with table joins',
|
||||
description: `
|
||||
Query across tables to build more advanced RLS rules
|
||||
|
||||
Assuming 2 tables called \`teams\` and \`members\`, you can query both tables in the policy to control access to the members table.`,
|
||||
statement: `
|
||||
create policy "Members can update team details if they belong to the team"
|
||||
on teams for update using (
|
||||
(select auth.uid()) in (
|
||||
select user_id from members where team_id = id
|
||||
)
|
||||
);
|
||||
`.trim(),
|
||||
definition: safeSql`(select auth.uid()) in (select user_id from members where team_id = id)`,
|
||||
check: safeSql``,
|
||||
command: 'UPDATE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-6',
|
||||
preview: true,
|
||||
templateName: 'Policy with security definer functions',
|
||||
description: `
|
||||
Useful in a many-to-many relationship where you want to restrict access to the linking table.
|
||||
|
||||
Assuming 2 tables called \`teams\` and \`members\`, you can use a security definer function in combination with a policy to control access to the members table.`.trim(),
|
||||
statement: `
|
||||
create or replace function get_teams_for_user(user_id uuid)
|
||||
returns setof bigint as $$
|
||||
select team_id from members where user_id = $1
|
||||
$$ stable language sql security definer;
|
||||
|
||||
create policy "Team members can update team members if they belong to the team"
|
||||
on members
|
||||
for all using (
|
||||
team_id in (select get_teams_for_user(auth.uid()))
|
||||
);
|
||||
`.trim(),
|
||||
name: 'Policy with security definer functions',
|
||||
definition: safeSql`team_id in (select get_teams_for_user(auth.uid()))`,
|
||||
check: safeSql``,
|
||||
command: 'ALL',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-7',
|
||||
preview: true,
|
||||
name: 'Policy to implement Time To Live (TTL)',
|
||||
templateName: 'Policy to implement Time To Live (TTL)',
|
||||
description: `
|
||||
Implement a TTL-like feature that you see in Instagram stories or Snapchat where messages expire after a day.
|
||||
|
||||
Rows under the table are available only if they have been created within the last 24 hours.`,
|
||||
statement: `
|
||||
create policy "Stories are live for a day"
|
||||
on "${schema}"."${table}"
|
||||
for select using (
|
||||
created_at > (current_timestamp - interval '1 day')
|
||||
);
|
||||
`.trim(),
|
||||
definition: safeSql`created_at > (current_timestamp - interval '1 day')`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-8',
|
||||
preview: false,
|
||||
templateName: 'Allow users to only view their own data',
|
||||
description: 'Restrict users to reading only their own data.',
|
||||
statement: `
|
||||
create policy "Enable users to view their own data only"
|
||||
on "${schema}"."${table}"
|
||||
for select
|
||||
to authenticated
|
||||
using (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable users to view their own data only',
|
||||
definition: safeSql`(select auth.uid()) = user_id`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
]
|
||||
|
||||
export const getRealtimePolicyTemplates = (): PolicyTemplate[] => {
|
||||
const results = [
|
||||
{
|
||||
id: 'policy-broadcast-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts for authenticated users only',
|
||||
description: 'This policy allows listening for broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow listening for broadcasts for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-2',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts for authenticated users only',
|
||||
description: 'This policy allows pushing broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts for authenticated users only"
|
||||
ON realtime.messages for insert
|
||||
TO authenticated
|
||||
with check ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow pushing broadcasts for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
check: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts from a specific channel',
|
||||
description: 'This policy allows listening for broadcasts from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for broadcasts from a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-4',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts to specific channel',
|
||||
description: 'This policy allow pushing broadcasts to specific channel.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts to specific channel"
|
||||
ON realtime.messages for insert
|
||||
with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow pushing broadcasts to specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables listening for presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences on all channels for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'presence' );`.trim(),
|
||||
name: 'Allow listening for presences on all channels for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'presence'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-2',
|
||||
preview: false,
|
||||
templateName: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables broadcasting presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow broadcasting presences on all channels for authenticated users only"
|
||||
ON realtime.messages for insert
|
||||
TO authenticated
|
||||
with check ( realtime.messages.extension = 'presence' );
|
||||
;`.trim(),
|
||||
name: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'presence'`,
|
||||
check: safeSql`realtime.messages.extension = 'presence'`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences from a specific channel',
|
||||
description: 'This policy enables listening for presences from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for presences from a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-4',
|
||||
preview: false,
|
||||
templateName: 'Publish presence to a specific channel',
|
||||
description: 'This policy allows publishing presence to a specific channel.',
|
||||
statement: `
|
||||
create policy "Publish presence to a specific channel"
|
||||
ON realtime.messages for insert
|
||||
with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );
|
||||
;`.trim(),
|
||||
name: 'Publish presence to a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
] as PolicyTemplate[]
|
||||
return results
|
||||
}
|
||||
|
||||
export const getQueuePolicyTemplates = (): PolicyTemplate[] => {
|
||||
return [
|
||||
{
|
||||
id: 'policy-queues-1',
|
||||
preview: false,
|
||||
templateName: 'Allow access to queue',
|
||||
statement: ``.trim(),
|
||||
name: 'Allow anon and authenticated to access messages from queue',
|
||||
description:
|
||||
'Base policy to ensure that anon and authenticated can only access appropriate rows. USING and CHECK statements will need to be adjusted accordingly',
|
||||
definition: safeSql`true`,
|
||||
check: safeSql`true`,
|
||||
command: 'ALL',
|
||||
roles: ['anon', 'authenticated'],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
import { safeSql } from '@supabase/pg-meta'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
generateAiPoliciesForTable,
|
||||
generateProgrammaticPoliciesForTable,
|
||||
generateStartingPoliciesForTable,
|
||||
type GeneratedPolicy,
|
||||
} from './Policies.utils'
|
||||
import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
|
||||
|
||||
// Mock generateSqlPolicy for AI tests
|
||||
const mockGenerateSqlPolicy = vi.fn()
|
||||
vi.mock('@/data/ai/sql-policy-mutation', () => ({
|
||||
generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args),
|
||||
}))
|
||||
|
||||
// Helper to create a foreign key constraint
|
||||
const createForeignKey = (overrides: Partial<ForeignKeyConstraint> = {}): ForeignKeyConstraint => ({
|
||||
id: 1,
|
||||
constraint_name: 'fk_constraint',
|
||||
source_id: 100,
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_id: 200,
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
deletion_action: 'NO ACTION',
|
||||
update_action: 'NO ACTION',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Policies.utils - Policy Generation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('generateProgrammaticPoliciesForTable', () => {
|
||||
it('should generate 4 CRUD policies for direct FK to auth.users', () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(4)
|
||||
|
||||
const commands = policies.map((p) => p.command)
|
||||
expect(commands).toContain('SELECT')
|
||||
expect(commands).toContain('INSERT')
|
||||
expect(commands).toContain('UPDATE')
|
||||
expect(commands).toContain('DELETE')
|
||||
})
|
||||
|
||||
it('should return empty array when no FK path to auth.users exists', () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['category_id'],
|
||||
target_schema: 'public',
|
||||
target_table: 'categories',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should return empty array when foreignKeyConstraints is empty', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints: [],
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => {
|
||||
// posts -> profiles -> auth.users
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
id: 1,
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['profile_id'],
|
||||
target_schema: 'public',
|
||||
target_table: 'profiles',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
createForeignKey({
|
||||
id: 2,
|
||||
source_schema: 'public',
|
||||
source_table: 'profiles',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(4)
|
||||
|
||||
// Check that the expression contains EXISTS for indirect path
|
||||
const selectPolicy = policies.find((p) => p.command === 'SELECT')
|
||||
expect(selectPolicy?.definition).toContain('exists')
|
||||
expect(selectPolicy?.sql).toContain('exists')
|
||||
})
|
||||
|
||||
describe('policy structure validation', () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
it('should include all required fields in generated policies', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
for (const policy of policies) {
|
||||
expect(policy).toHaveProperty('name')
|
||||
expect(policy).toHaveProperty('sql')
|
||||
expect(policy).toHaveProperty('command')
|
||||
expect(policy).toHaveProperty('table', 'posts')
|
||||
expect(policy).toHaveProperty('schema', 'public')
|
||||
expect(policy).toHaveProperty('action', 'PERMISSIVE')
|
||||
expect(policy).toHaveProperty('roles')
|
||||
expect(policy.roles).toContain('authenticated')
|
||||
}
|
||||
})
|
||||
|
||||
it('SELECT policy should have definition but no check', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const selectPolicy = policies.find((p) => p.command === 'SELECT')
|
||||
expect(selectPolicy?.definition).toBeDefined()
|
||||
expect(selectPolicy?.check).toBeUndefined()
|
||||
})
|
||||
|
||||
it('DELETE policy should have definition but no check', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const deletePolicy = policies.find((p) => p.command === 'DELETE')
|
||||
expect(deletePolicy?.definition).toBeDefined()
|
||||
expect(deletePolicy?.check).toBeUndefined()
|
||||
})
|
||||
|
||||
it('INSERT policy should have check but no definition', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const insertPolicy = policies.find((p) => p.command === 'INSERT')
|
||||
expect(insertPolicy?.definition).toBeUndefined()
|
||||
expect(insertPolicy?.check).toBeDefined()
|
||||
})
|
||||
|
||||
it('UPDATE policy should have both definition and check', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const updatePolicy = policies.find((p) => p.command === 'UPDATE')
|
||||
expect(updatePolicy?.definition).toBeDefined()
|
||||
expect(updatePolicy?.check).toBeDefined()
|
||||
})
|
||||
|
||||
it('should generate correct SQL syntax for direct FK', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const selectPolicy = policies.find((p) => p.command === 'SELECT')
|
||||
expect(selectPolicy?.sql).toContain('CREATE POLICY')
|
||||
expect(selectPolicy?.sql).toContain('public.posts')
|
||||
expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT')
|
||||
expect(selectPolicy?.sql).toContain('TO authenticated')
|
||||
expect(selectPolicy?.sql).toContain('USING')
|
||||
expect(selectPolicy?.sql).toContain('auth.uid()')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle non-public schema', () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'private',
|
||||
source_table: 'documents',
|
||||
source_columns: ['owner_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'documents', schema: 'private' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(4)
|
||||
expect(policies[0].schema).toBe('private')
|
||||
expect(policies[0].sql).toContain('private.documents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateAiPoliciesForTable', () => {
|
||||
const mockAiPolicies: GeneratedPolicy[] = [
|
||||
{
|
||||
name: 'ai_select_policy',
|
||||
sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);',
|
||||
command: 'SELECT',
|
||||
table: 'posts',
|
||||
schema: 'public',
|
||||
definition: safeSql`true`,
|
||||
action: 'PERMISSIVE',
|
||||
roles: ['public'],
|
||||
},
|
||||
]
|
||||
|
||||
it('should return policies from AI when called with valid inputs', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
|
||||
|
||||
const policies = await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: 'id' }, { name: 'title' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({
|
||||
tableName: 'posts',
|
||||
schema: 'public',
|
||||
columns: ['id', 'title'],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
})
|
||||
expect(policies).toEqual(mockAiPolicies)
|
||||
})
|
||||
|
||||
it('should return empty array when connectionString is null', async () => {
|
||||
const policies = await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: null,
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
expect(policies).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when connectionString is undefined', async () => {
|
||||
const policies = await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: undefined,
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
expect(policies).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully and return empty array', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
mockGenerateSqlPolicy.mockRejectedValue(new Error('API error'))
|
||||
|
||||
const policies = await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
})
|
||||
|
||||
expect(policies).toEqual([])
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error))
|
||||
|
||||
consoleLogSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should trim column names before sending to API', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue([])
|
||||
|
||||
await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: ' id ' }, { name: ' title ' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
columns: ['id', 'title'],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateStartingPoliciesForTable', () => {
|
||||
const mockAiPolicies: GeneratedPolicy[] = [
|
||||
{
|
||||
name: 'ai_policy',
|
||||
sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);',
|
||||
command: 'SELECT',
|
||||
table: 'posts',
|
||||
schema: 'public',
|
||||
definition: safeSql`true`,
|
||||
action: 'PERMISSIVE',
|
||||
roles: ['public'],
|
||||
},
|
||||
]
|
||||
|
||||
it('should use programmatic policies when FK path exists (does not call AI)', async () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: true,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(4)
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to AI when no FK path exists and enableAi is true', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
|
||||
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints: [],
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: true,
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).toHaveBeenCalled()
|
||||
expect(policies).toEqual(mockAiPolicies)
|
||||
})
|
||||
|
||||
it('should return empty array when no FK path exists and enableAi is false', async () => {
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints: [],
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: false,
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
expect(policies).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when no FK path and AI returns empty', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue([])
|
||||
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints: [],
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: true,
|
||||
})
|
||||
|
||||
expect(policies).toEqual([])
|
||||
})
|
||||
|
||||
it('should prioritize programmatic over AI even when both could generate policies', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
|
||||
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: true,
|
||||
})
|
||||
|
||||
// Should return 4 programmatic policies, not 1 AI policy
|
||||
expect(policies).toHaveLength(4)
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,477 +0,0 @@
|
||||
import {
|
||||
acceptUntrustedSql,
|
||||
ident,
|
||||
safeSql,
|
||||
untrustedSql,
|
||||
type DisplayableSqlFragment,
|
||||
type SafeSqlFragment,
|
||||
} from '@supabase/pg-meta'
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { has, isEmpty, isEqual } from 'lodash'
|
||||
|
||||
import {
|
||||
DraftPostgresPolicyCreatePayload,
|
||||
DraftPostgresPolicyUpdatePayload,
|
||||
PolicyFormField,
|
||||
PolicyForReview,
|
||||
} from './Policies.types'
|
||||
import { generateSqlPolicy } from '@/data/ai/sql-policy-mutation'
|
||||
import type { CreatePolicyBody } from '@/data/database-policies/database-policy-create-mutation'
|
||||
import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
|
||||
|
||||
/**
|
||||
* Returns an array of SQL statements that will preview in the review step of the policy editor
|
||||
* @param {*} policyFormFields { name, using, check, command }
|
||||
*/
|
||||
|
||||
export const createSQLPolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
originalPolicyFormFields?: PGPolicy
|
||||
) => {
|
||||
const { definition, check } = policyFormFields
|
||||
const formattedPolicyFormFields = {
|
||||
...policyFormFields,
|
||||
definition: definition
|
||||
? definition.replace(/\s+/g, ' ').trim()
|
||||
: definition === undefined
|
||||
? null
|
||||
: definition,
|
||||
check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check,
|
||||
}
|
||||
|
||||
if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) {
|
||||
return createSQLStatementForCreatePolicy(formattedPolicyFormFields)
|
||||
}
|
||||
|
||||
// If there are no changes, return an empty object
|
||||
if (isEqual(policyFormFields, originalPolicyFormFields)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// Extract out all the fields that updated
|
||||
const fieldsToUpdate: any = {}
|
||||
if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) {
|
||||
fieldsToUpdate.name = formattedPolicyFormFields.name
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) {
|
||||
fieldsToUpdate.definition = formattedPolicyFormFields.definition
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) {
|
||||
fieldsToUpdate.check = formattedPolicyFormFields.check
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) {
|
||||
fieldsToUpdate.roles = formattedPolicyFormFields.roles
|
||||
}
|
||||
|
||||
if (!isEmpty(fieldsToUpdate)) {
|
||||
return createSQLStatementForUpdatePolicy(formattedPolicyFormFields, fieldsToUpdate)
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): PolicyForReview => {
|
||||
const { name, definition, check, command, schema, table } = policyFormFields
|
||||
const roles = policyFormFields.roles.length === 0 ? ['public'] : policyFormFields.roles
|
||||
const description = `Add policy for the ${command} operation under the policy "${name}"`
|
||||
const statement = [
|
||||
`CREATE POLICY "${name}" ON "${schema}"."${table}"`,
|
||||
`AS PERMISSIVE FOR ${command}`,
|
||||
`TO ${roles.join(', ')}`,
|
||||
`${definition ? `USING (${definition})` : ''}`,
|
||||
`${check ? `WITH CHECK (${check})` : ''}`,
|
||||
].join('\n')
|
||||
|
||||
return { description, statement }
|
||||
}
|
||||
|
||||
const createSQLStatementForUpdatePolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
fieldsToUpdate: Partial<PolicyFormField>
|
||||
): PolicyForReview => {
|
||||
const { name, schema, table } = policyFormFields
|
||||
|
||||
const definitionChanged = has(fieldsToUpdate, ['definition'])
|
||||
const checkChanged = has(fieldsToUpdate, ['check'])
|
||||
const nameChanged = has(fieldsToUpdate, ['name'])
|
||||
const rolesChanged = has(fieldsToUpdate, ['roles'])
|
||||
|
||||
const parameters = Object.keys(fieldsToUpdate)
|
||||
const description = `Update policy's ${
|
||||
parameters.length === 1
|
||||
? parameters[0]
|
||||
: `${parameters.slice(0, parameters.length - 1).join(', ')} and ${
|
||||
parameters[parameters.length - 1]
|
||||
}`
|
||||
} `
|
||||
const roles =
|
||||
(fieldsToUpdate?.roles ?? []).length === 0 ? ['public'] : (fieldsToUpdate.roles as string[])
|
||||
|
||||
const alterStatement = `ALTER POLICY "${name}" ON "${schema}"."${table}"`
|
||||
const statement = [
|
||||
'BEGIN;',
|
||||
...(definitionChanged ? [` ${alterStatement} USING (${fieldsToUpdate.definition});`] : []),
|
||||
...(checkChanged ? [` ${alterStatement} WITH CHECK (${fieldsToUpdate.check});`] : []),
|
||||
...(rolesChanged ? [` ${alterStatement} TO ${roles.join(', ')};`] : []),
|
||||
...(nameChanged ? [` ${alterStatement} RENAME TO "${fieldsToUpdate.name}";`] : []),
|
||||
'COMMIT;',
|
||||
].join('\n')
|
||||
|
||||
return { description, statement }
|
||||
}
|
||||
|
||||
// These constructors return DRAFT payloads — `definition`/`check` are still
|
||||
// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture
|
||||
// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that
|
||||
// it was reached via a deliberate user action.
|
||||
export const createPayloadForCreatePolicy = (
|
||||
policyFormFields: PolicyFormField
|
||||
): DraftPostgresPolicyCreatePayload => {
|
||||
const { name, schema, table, command, definition, check, roles } = policyFormFields
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
table,
|
||||
action: 'PERMISSIVE',
|
||||
command: command || undefined,
|
||||
definition: !definition ? undefined : untrustedSql(definition),
|
||||
check: !check ? undefined : untrustedSql(check),
|
||||
roles: roles.length > 0 ? roles : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const createPayloadForUpdatePolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
originalPolicyFormFields: PGPolicy
|
||||
): DraftPostgresPolicyUpdatePayload => {
|
||||
const { definition, check } = policyFormFields
|
||||
const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition
|
||||
const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check
|
||||
|
||||
const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
|
||||
|
||||
if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) {
|
||||
payload.name = policyFormFields.name
|
||||
}
|
||||
if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) {
|
||||
payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition)
|
||||
}
|
||||
if (!isEqual(formattedCheck, originalPolicyFormFields.check)) {
|
||||
payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck)
|
||||
}
|
||||
if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) {
|
||||
if (policyFormFields.roles.length === 0) payload.roles = ['public']
|
||||
else payload.roles = policyFormFields.roles || undefined
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- Policy Generation ---
|
||||
|
||||
/**
|
||||
* A policy generated for display/staging in the table editor.
|
||||
* `definition`/`check` are `DisplayableSqlFragment` because generators have different provenance:
|
||||
* programmatic generation produces `SafeSqlFragment` (composed via `safeSql`), AI generation
|
||||
* produces `UntrustedSqlFragment` (third-party output). Consumers must promote via
|
||||
* `acceptUntrustedSql` at a user gesture before executing.
|
||||
*/
|
||||
export type GeneratedPolicy = Required<
|
||||
Pick<CreatePolicyBody, 'name' | 'table' | 'schema' | 'action' | 'roles'>
|
||||
> &
|
||||
Pick<CreatePolicyBody, 'command'> & {
|
||||
definition?: DisplayableSqlFragment
|
||||
check?: DisplayableSqlFragment
|
||||
sql: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link GeneratedPolicy} whose `definition`/`check` have already been promoted to
|
||||
* `SafeSqlFragment`. Producing one of these is the contract that says: the user gesture
|
||||
* required to execute this SQL has already happened.
|
||||
*/
|
||||
export type AcceptedGeneratedPolicy = Omit<GeneratedPolicy, 'definition' | 'check'> & {
|
||||
definition?: SafeSqlFragment
|
||||
check?: SafeSqlFragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes a {@link GeneratedPolicy} to an {@link AcceptedGeneratedPolicy}.
|
||||
* ONLY call from an event handler tied to a deliberate user action (e.g. the Save click
|
||||
* on the table editor). Never call from useEffect, render, or any path that runs without
|
||||
* a user gesture.
|
||||
*/
|
||||
export const acceptGeneratedPolicy = (policy: GeneratedPolicy): AcceptedGeneratedPolicy => ({
|
||||
...policy,
|
||||
definition: policy.definition === undefined ? undefined : acceptUntrustedSql(policy.definition),
|
||||
check: policy.check === undefined ? undefined : acceptUntrustedSql(policy.check),
|
||||
})
|
||||
|
||||
type Relationship = {
|
||||
source_schema: string
|
||||
source_table_name: string
|
||||
source_column_name: string
|
||||
target_table_schema: string
|
||||
target_table_name: string
|
||||
target_column_name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets relationships for a specific table from FK constraints.
|
||||
* Returns relationships where the table is the source.
|
||||
*/
|
||||
const getRelationshipsForTable = ({
|
||||
schema,
|
||||
table,
|
||||
fkConstraints,
|
||||
}: {
|
||||
schema: string
|
||||
table: string
|
||||
fkConstraints: ForeignKeyConstraint[]
|
||||
}): Relationship[] => {
|
||||
return fkConstraints
|
||||
.filter((fk) => fk.source_schema === schema && fk.source_table === table)
|
||||
.flatMap((fk) =>
|
||||
fk.source_columns.map((sourceCol, i) => ({
|
||||
source_schema: fk.source_schema,
|
||||
source_table_name: fk.source_table,
|
||||
source_column_name: sourceCol,
|
||||
target_table_schema: fk.target_schema,
|
||||
target_table_name: fk.target_table,
|
||||
target_column_name: fk.target_columns[i],
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS to find shortest path from table to auth.users via foreign key relationships.
|
||||
* Returns null if no path exists within maxDepth.
|
||||
*/
|
||||
const findPathToAuthUsers = (
|
||||
startTable: { schema: string; name: string },
|
||||
allForeignKeyConstraints: ForeignKeyConstraint[],
|
||||
maxDepth = 3
|
||||
): Relationship[] | null => {
|
||||
const startRelationships = getRelationshipsForTable({
|
||||
schema: startTable.schema,
|
||||
table: startTable.name,
|
||||
fkConstraints: allForeignKeyConstraints,
|
||||
})
|
||||
|
||||
const queue: { table: { schema: string; name: string }; path: Relationship[] }[] = [
|
||||
{ table: startTable, path: [] },
|
||||
]
|
||||
const visited = new Set<string>()
|
||||
visited.add(`${startTable.schema}.${startTable.name}`)
|
||||
|
||||
while (queue.length > 0) {
|
||||
const queueItem = queue.shift()
|
||||
if (!queueItem) continue
|
||||
|
||||
const { table, path } = queueItem
|
||||
if (path.length >= maxDepth) continue
|
||||
|
||||
const relationships =
|
||||
path.length === 0
|
||||
? startRelationships
|
||||
: getRelationshipsForTable({
|
||||
schema: table.schema,
|
||||
table: table.name,
|
||||
fkConstraints: allForeignKeyConstraints,
|
||||
})
|
||||
|
||||
for (const rel of relationships) {
|
||||
// Found path to auth.users
|
||||
if (
|
||||
rel.target_table_schema === 'auth' &&
|
||||
rel.target_table_name === 'users' &&
|
||||
rel.target_column_name === 'id'
|
||||
) {
|
||||
return [...path, rel]
|
||||
}
|
||||
|
||||
const targetId = `${rel.target_table_schema}.${rel.target_table_name}`
|
||||
if (visited.has(targetId)) continue
|
||||
|
||||
// Add target table to queue for further exploration
|
||||
queue.push({
|
||||
table: { schema: rel.target_table_schema, name: rel.target_table_name },
|
||||
path: [...path, rel],
|
||||
})
|
||||
visited.add(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** Generates SQL expression for RLS policy based on FK path to auth.users */
|
||||
const buildPolicyExpression = (path: Relationship[]): SafeSqlFragment => {
|
||||
if (path.length === 0) return safeSql``
|
||||
|
||||
// Direct FK to auth.users
|
||||
if (path.length === 1) {
|
||||
return safeSql`(select auth.uid()) = ${ident(path[0].source_column_name)}`
|
||||
}
|
||||
|
||||
// Indirect path - build EXISTS with JOINs
|
||||
const [first, ...rest] = path
|
||||
const firstTarget = safeSql`${ident(first.target_table_schema)}.${ident(first.target_table_name)}`
|
||||
const source = safeSql`${ident(first.source_schema)}.${ident(first.source_table_name)}`
|
||||
const last = path[path.length - 1]
|
||||
|
||||
const joins = rest.slice(0, -1).reduce<SafeSqlFragment>(
|
||||
(acc, r) => {
|
||||
const targetSchema = ident(r.target_table_schema)
|
||||
const targetTable = ident(r.target_table_name)
|
||||
const targetColumn = ident(r.target_column_name)
|
||||
|
||||
const sourceSchema = ident(r.source_schema)
|
||||
const sourceTable = ident(r.source_table_name)
|
||||
const sourceColumn = ident(r.source_column_name)
|
||||
const join = safeSql`join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}`
|
||||
return acc.length === 0 ? join : safeSql`${acc}\n ${join}`
|
||||
},
|
||||
safeSql``
|
||||
)
|
||||
|
||||
return safeSql`exists (
|
||||
select 1 from ${firstTarget}
|
||||
${joins}
|
||||
where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)}
|
||||
and ${ident(last.source_schema)}.${ident(last.source_table_name)}.${ident(last.source_column_name)} = (select auth.uid())
|
||||
)`
|
||||
}
|
||||
|
||||
/** Builds policy SQL for all CRUD operations */
|
||||
const buildPoliciesForPath = (
|
||||
table: { name: string; schema: string },
|
||||
path: Relationship[]
|
||||
): GeneratedPolicy[] => {
|
||||
const expression = buildPolicyExpression(path)
|
||||
const targetCol = path[0].source_column_name
|
||||
|
||||
return (['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const).map((command) => {
|
||||
const name = `Enable ${command.toLowerCase()} access for users based on ${ident(targetCol)}`
|
||||
const base = `CREATE POLICY "${name}" ON ${ident(table.schema)}.${ident(table.name)} AS PERMISSIVE FOR ${command} TO authenticated`
|
||||
|
||||
const sql =
|
||||
command === 'INSERT'
|
||||
? `${base} WITH CHECK (${expression});`
|
||||
: command === 'UPDATE'
|
||||
? `${base} USING (${expression}) WITH CHECK (${expression});`
|
||||
: `${base} USING (${expression});`
|
||||
|
||||
// Structured data for mutation API
|
||||
const definition = command === 'INSERT' ? undefined : expression
|
||||
const check = command === 'SELECT' || command === 'DELETE' ? undefined : expression
|
||||
|
||||
return {
|
||||
name,
|
||||
sql,
|
||||
command,
|
||||
table: table.name,
|
||||
schema: table.schema,
|
||||
definition,
|
||||
check,
|
||||
action: 'PERMISSIVE' as const,
|
||||
roles: ['authenticated'],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates RLS policies programmatically based on FK relationships to auth.users.
|
||||
*/
|
||||
export const generateProgrammaticPoliciesForTable = ({
|
||||
table,
|
||||
foreignKeyConstraints,
|
||||
}: {
|
||||
table: { name: string; schema: string }
|
||||
foreignKeyConstraints: ForeignKeyConstraint[]
|
||||
}): GeneratedPolicy[] => {
|
||||
try {
|
||||
const path = findPathToAuthUsers(table, foreignKeyConstraints)
|
||||
|
||||
if (path?.length) {
|
||||
return buildPoliciesForPath(table, path)
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - caller will handle empty result
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates RLS policies using AI.
|
||||
*/
|
||||
export const generateAiPoliciesForTable = async ({
|
||||
table,
|
||||
columns,
|
||||
projectRef,
|
||||
connectionString,
|
||||
}: {
|
||||
table: { name: string; schema: string }
|
||||
columns: { name: string }[]
|
||||
projectRef: string
|
||||
connectionString?: string | null
|
||||
}): Promise<GeneratedPolicy[]> => {
|
||||
if (!connectionString) return []
|
||||
|
||||
try {
|
||||
return await generateSqlPolicy({
|
||||
tableName: table.name,
|
||||
schema: table.schema,
|
||||
columns: columns.map((col) => col.name.trim()),
|
||||
projectRef,
|
||||
connectionString: connectionString ?? '',
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('AI policy generation failed:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates RLS policies for a table.
|
||||
* First tries programmatic generation based on FK relationships to auth.users.
|
||||
* Falls back to AI generation if no path exists.
|
||||
*/
|
||||
export const generateStartingPoliciesForTable = async ({
|
||||
table,
|
||||
foreignKeyConstraints,
|
||||
columns,
|
||||
projectRef,
|
||||
connectionString,
|
||||
enableAi,
|
||||
}: {
|
||||
table: { name: string; schema: string }
|
||||
foreignKeyConstraints: ForeignKeyConstraint[]
|
||||
columns: { name: string }[]
|
||||
projectRef: string
|
||||
connectionString?: string | null
|
||||
enableAi: boolean
|
||||
}): Promise<GeneratedPolicy[]> => {
|
||||
// Try programmatic generation first
|
||||
const programmaticPolicies = generateProgrammaticPoliciesForTable({
|
||||
table,
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
if (programmaticPolicies.length > 0) {
|
||||
return programmaticPolicies
|
||||
}
|
||||
|
||||
// Fall back to AI generation
|
||||
if (enableAi) {
|
||||
return await generateAiPoliciesForTable({
|
||||
table,
|
||||
columns,
|
||||
projectRef,
|
||||
connectionString,
|
||||
})
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
-329
@@ -1,329 +0,0 @@
|
||||
import { safeSql } from '@supabase/pg-meta/src/pg-format'
|
||||
|
||||
import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants'
|
||||
|
||||
/**
|
||||
* ----------------------------------------------------------------
|
||||
* PostgreSQL policy templates for the auth policies page
|
||||
* ----------------------------------------------------------------
|
||||
* id: Unique identifier for the monaco editor to dynamically refresh
|
||||
* templateName: As a display for a more descriptive title for the policy
|
||||
* description: Additional details about the template and how to make it yours
|
||||
* statement: SQL statement template for the policy
|
||||
*
|
||||
* name: Actual policy name that will be used in the editor
|
||||
* definition: Actual policy using expression that will be used in the editor
|
||||
* check: Actual policy with check expression that will be used in the editor
|
||||
* command: Operation to create policy for
|
||||
*/
|
||||
|
||||
export const getGeneralPolicyTemplates = (schema: string, table: string): PolicyTemplate[] => [
|
||||
{
|
||||
id: 'policy-1',
|
||||
preview: false,
|
||||
templateName: 'Enable read access to everyone',
|
||||
description:
|
||||
'This policy gives read access to your table for all users via the SELECT operation.',
|
||||
statement: `
|
||||
create policy "Enable read access for all users"
|
||||
on "${schema}"."${table}"
|
||||
for select using (true);`.trim(),
|
||||
name: 'Enable read access for all users',
|
||||
definition: safeSql`true`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-2',
|
||||
preview: false,
|
||||
templateName: 'Enable insert access for authenticated users only',
|
||||
description: 'This policy gives insert access to your table for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "${schema}"."${table}"
|
||||
for insert to authenticated
|
||||
with check (true);`.trim(),
|
||||
name: 'Enable insert for authenticated users only',
|
||||
definition: safeSql``,
|
||||
check: safeSql`true`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-3',
|
||||
preview: false,
|
||||
templateName: 'Enable delete access for users based on their user ID *',
|
||||
description:
|
||||
'This policy assumes that your table has a column "user_id", and allows users to delete rows which the "user_id" column matches their ID',
|
||||
statement: `
|
||||
create policy "Enable delete for users based on user_id"
|
||||
on "${schema}"."${table}"
|
||||
for delete using (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable delete for users based on user_id',
|
||||
definition: safeSql`(select auth.uid()) = user_id`,
|
||||
check: safeSql``,
|
||||
command: 'DELETE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-4',
|
||||
preview: false,
|
||||
templateName: 'Enable insert access for users based on their user ID *',
|
||||
description:
|
||||
'This policy assumes that your table has a column "user_id", and allows users to insert rows which the "user_id" column matches their ID',
|
||||
statement: `
|
||||
create policy "Enable insert for users based on user_id"
|
||||
on "${schema}"."${table}"
|
||||
for insert with check (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable insert for users based on user_id',
|
||||
definition: safeSql``,
|
||||
check: safeSql`(select auth.uid()) = user_id`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-5',
|
||||
preview: true,
|
||||
name: 'Policy with table joins',
|
||||
templateName: 'Policy with table joins',
|
||||
description: `
|
||||
Query across tables to build more advanced RLS rules
|
||||
|
||||
Assuming 2 tables called \`teams\` and \`members\`, you can query both tables in the policy to control access to the members table.`,
|
||||
statement: `
|
||||
create policy "Members can update team details if they belong to the team"
|
||||
on teams for update using (
|
||||
(select auth.uid()) in (
|
||||
select user_id from members where team_id = id
|
||||
)
|
||||
);
|
||||
`.trim(),
|
||||
definition: safeSql`(select auth.uid()) in (select user_id from members where team_id = id)`,
|
||||
check: safeSql``,
|
||||
command: 'UPDATE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-6',
|
||||
preview: true,
|
||||
templateName: 'Policy with security definer functions',
|
||||
description: `
|
||||
Useful in a many-to-many relationship where you want to restrict access to the linking table.
|
||||
|
||||
Assuming 2 tables called \`teams\` and \`members\`, you can use a security definer function in combination with a policy to control access to the members table.`.trim(),
|
||||
statement: `
|
||||
create or replace function get_teams_for_user(user_id uuid)
|
||||
returns setof bigint as $$
|
||||
select team_id from members where user_id = $1
|
||||
$$ stable language sql security definer;
|
||||
|
||||
create policy "Team members can update team members if they belong to the team"
|
||||
on members
|
||||
for all using (
|
||||
team_id in (select get_teams_for_user(auth.uid()))
|
||||
);
|
||||
`.trim(),
|
||||
name: 'Policy with security definer functions',
|
||||
definition: safeSql`team_id in (select get_teams_for_user(auth.uid()))`,
|
||||
check: safeSql``,
|
||||
command: 'ALL',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-7',
|
||||
preview: true,
|
||||
name: 'Policy to implement Time To Live (TTL)',
|
||||
templateName: 'Policy to implement Time To Live (TTL)',
|
||||
description: `
|
||||
Implement a TTL-like feature that you see in Instagram stories or Snapchat where messages expire after a day.
|
||||
|
||||
Rows under the table are available only if they have been created within the last 24 hours.`,
|
||||
statement: `
|
||||
create policy "Stories are live for a day"
|
||||
on "${schema}"."${table}"
|
||||
for select using (
|
||||
created_at > (current_timestamp - interval '1 day')
|
||||
);
|
||||
`.trim(),
|
||||
definition: safeSql`created_at > (current_timestamp - interval '1 day')`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-8',
|
||||
preview: false,
|
||||
templateName: 'Allow users to only view their own data',
|
||||
description: 'Restrict users to reading only their own data.',
|
||||
statement: `
|
||||
create policy "Enable users to view their own data only"
|
||||
on "${schema}"."${table}"
|
||||
for select
|
||||
to authenticated
|
||||
using (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable users to view their own data only',
|
||||
definition: safeSql`(select auth.uid()) = user_id`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
]
|
||||
|
||||
export const getRealtimePolicyTemplates = (): PolicyTemplate[] => {
|
||||
const results = [
|
||||
{
|
||||
id: 'policy-broadcast-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts for authenticated users only',
|
||||
description: 'This policy allows listening for broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow listening for broadcasts for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-2',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts for authenticated users only',
|
||||
description: 'This policy allows pushing broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts for authenticated users only"
|
||||
ON realtime.messages for insert
|
||||
TO authenticated
|
||||
with check ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow pushing broadcasts for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
check: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts from a specific channel',
|
||||
description: 'This policy allows listening for broadcasts from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for broadcasts from a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-4',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts to specific channel',
|
||||
description: 'This policy allow pushing broadcasts to specific channel.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts to specific channel"
|
||||
ON realtime.messages for insert
|
||||
with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow pushing broadcasts to specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables listening for presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences on all channels for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'presence' );`.trim(),
|
||||
name: 'Allow listening for presences on all channels for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'presence'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-2',
|
||||
preview: false,
|
||||
templateName: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables broadcasting presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow broadcasting presences on all channels for authenticated users only"
|
||||
ON realtime.messages for insert
|
||||
TO authenticated
|
||||
with check ( realtime.messages.extension = 'presence' );
|
||||
;`.trim(),
|
||||
name: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'presence'`,
|
||||
check: safeSql`realtime.messages.extension = 'presence'`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences from a specific channel',
|
||||
description: 'This policy enables listening for presences from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for presences from a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-4',
|
||||
preview: false,
|
||||
templateName: 'Publish presence to a specific channel',
|
||||
description: 'This policy allows publishing presence to a specific channel.',
|
||||
statement: `
|
||||
create policy "Publish presence to a specific channel"
|
||||
ON realtime.messages for insert
|
||||
with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );
|
||||
;`.trim(),
|
||||
name: 'Publish presence to a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
] as PolicyTemplate[]
|
||||
return results
|
||||
}
|
||||
|
||||
export const getQueuePolicyTemplates = (): PolicyTemplate[] => {
|
||||
return [
|
||||
{
|
||||
id: 'policy-queues-1',
|
||||
preview: false,
|
||||
templateName: 'Allow access to queue',
|
||||
statement: ``.trim(),
|
||||
name: 'Allow anon and authenticated to access messages from queue',
|
||||
description:
|
||||
'Base policy to ensure that anon and authenticated can only access appropriate rows. USING and CHECK statements will need to be adjusted accordingly',
|
||||
definition: safeSql`true`,
|
||||
check: safeSql`true`,
|
||||
command: 'ALL',
|
||||
roles: ['anon', 'authenticated'],
|
||||
},
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ import {
|
||||
getGeneralPolicyTemplates,
|
||||
getQueuePolicyTemplates,
|
||||
getRealtimePolicyTemplates,
|
||||
} from '../PolicyEditorModal/PolicyEditorModal.constants'
|
||||
} from '../Policies.constants'
|
||||
import { Markdown } from '@/components/interfaces/Markdown'
|
||||
import CardButton from '@/components/ui/CardButton'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
|
||||
+12
-10
@@ -56,24 +56,26 @@ export const PolicyTableRowHeader = ({
|
||||
>
|
||||
<Table strokeWidth={1.5} size={16} className="text-foreground-muted" />
|
||||
<CardTitle className="m-0 normal-case">{table.name}</CardTitle>
|
||||
</EditorTablePageLink>
|
||||
<div className="flex items-center gap-x-1">
|
||||
{!table.rls_enabled && (
|
||||
<Badge variant="warning" className="shrink-0">
|
||||
<Badge variant="warning" className="h-5">
|
||||
RLS Disabled
|
||||
</Badge>
|
||||
)}
|
||||
{!isLoadingApiAccess && !hasApiAccess && (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
<Badge variant="default" className="h-5">
|
||||
API Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</EditorTablePageLink>
|
||||
{isTableLocked && (
|
||||
<Badge>
|
||||
<span className="flex gap-2 items-center text-xs uppercase text-foreground-lighter">
|
||||
<Lock size={12} /> Locked
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{isTableLocked && (
|
||||
<Badge className="h-5">
|
||||
<span className="flex gap-x-1 items-center text-foreground-lighter">
|
||||
<Lock size={10} /> Locked
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isTableLocked && (
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -94,7 +94,9 @@ const PolicyTableRowComponent = ({
|
||||
|
||||
return (
|
||||
<Card className={cn(isPubliclyReadable && 'border-warning-500')}>
|
||||
<CardHeader className={cn('py-3 px-4', status !== 'secured' && 'border-b-0')}>
|
||||
<CardHeader
|
||||
className={cn('py-3 px-4', status !== 'secured' && status !== 'unknown' && 'border-b-0')}
|
||||
>
|
||||
<PolicyTableRowHeader
|
||||
table={table}
|
||||
isLocked={isLocked}
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
|
||||
|
||||
export interface PolicyTemplate {
|
||||
id: string
|
||||
preview: boolean
|
||||
templateName: string
|
||||
description: string
|
||||
name: string
|
||||
statement: string
|
||||
definition: SafeSqlFragment
|
||||
check: SafeSqlFragment
|
||||
command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL'
|
||||
roles: Array<string>
|
||||
}
|
||||
+19
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
|
||||
import { DiskStorageSchemaType } from '../DiskManagement.schema'
|
||||
import { ComputeInstanceAddonVariantId } from '../DiskManagement.types'
|
||||
import {
|
||||
calculateComputeSizePrice,
|
||||
calculateDiskSizePrice,
|
||||
@@ -18,6 +19,13 @@ import {
|
||||
useSelectedProjectQuery,
|
||||
} from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
const COMPUTE_SIZES_BELOW_LARGE: Array<ComputeInstanceAddonVariantId> = [
|
||||
'ci_nano',
|
||||
'ci_micro',
|
||||
'ci_small',
|
||||
'ci_medium',
|
||||
]
|
||||
|
||||
export function useDiskManagementReviewChanges(
|
||||
form: UseFormReturn<DiskStorageSchemaType>,
|
||||
numReplicas: number
|
||||
@@ -131,6 +139,16 @@ export function useDiskManagementReviewChanges(
|
||||
// Show cooldown warning whenever any disk attribute that enforces the 4-hour lock changes
|
||||
const anyDiskAttributeChange = hasIOPSChanges || hasStorageTypeChanges || hasTotalSizeChanges
|
||||
|
||||
// Show extended downtime warning when resizing to/from a size below large
|
||||
const hasExtendedDowntimeRisk =
|
||||
hasComputeChanges &&
|
||||
(COMPUTE_SIZES_BELOW_LARGE.includes(
|
||||
(form.formState.defaultValues?.computeSize ?? 'ci_nano') as ComputeInstanceAddonVariantId
|
||||
) ||
|
||||
COMPUTE_SIZES_BELOW_LARGE.includes(
|
||||
form.getValues('computeSize') as ComputeInstanceAddonVariantId
|
||||
))
|
||||
|
||||
// Throughput is only a user-configurable, separately-billed attribute for GP3. For IO2 it is
|
||||
// derived from provisioned IOPS (0.256 MiB/s per IOPS) and isn't surfaced as its own value, so
|
||||
// the form clears it to 0 — rendering a misleading "→ 0 MB/s". Only show the row when the
|
||||
@@ -180,6 +198,7 @@ export function useDiskManagementReviewChanges(
|
||||
anyDiskAttributeChange,
|
||||
showThroughputRow,
|
||||
hasAnyBreakdownRows,
|
||||
hasExtendedDowntimeRisk,
|
||||
// labels
|
||||
oldComputeLabel,
|
||||
newComputeLabel,
|
||||
|
||||
+16
-1
@@ -1,5 +1,5 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { AlertTriangle, ArrowRight } from 'lucide-react'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import {
|
||||
Alert,
|
||||
@@ -84,6 +84,7 @@ export const DiskManagementReviewAndSubmitDialog = ({
|
||||
anyDiskAttributeChange,
|
||||
showThroughputRow,
|
||||
hasAnyBreakdownRows,
|
||||
hasExtendedDowntimeRisk,
|
||||
oldComputeLabel,
|
||||
newComputeLabel,
|
||||
} = useDiskManagementReviewChanges(form, numReplicas)
|
||||
@@ -267,6 +268,20 @@ export const DiskManagementReviewAndSubmitDialog = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasExtendedDowntimeRisk && (
|
||||
<>
|
||||
<DialogSectionSeparator />
|
||||
<DialogSection>
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>
|
||||
Resizes may require more downtime than normal on this project.
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
</DialogSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="px-5 py-4">
|
||||
<Button block size="large" variant="default" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { IS_PLATFORM, useFlag } from 'common'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode, useEffect, useMemo, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
logDrainHeaderEntriesSchema,
|
||||
type LogDrainHeaderRow,
|
||||
} from './LogDrains.utils'
|
||||
import { useEnabledLogDrainTypes } from './useEnabledLogDrainTypes'
|
||||
import { TaxDisclaimer } from '@/components/interfaces/Billing/TaxDisclaimer'
|
||||
import { Shortcut } from '@/components/ui/Shortcut'
|
||||
import { LogDrainData } from '@/data/log-drains/log-drains-query'
|
||||
@@ -347,12 +348,7 @@ export function LogDrainDestinationSheetForm({
|
||||
)
|
||||
}, [defaultValues, mode])
|
||||
|
||||
const sentryEnabled = useFlag('SentryLogDrain')
|
||||
const s3Enabled = useFlag('S3logdrain')
|
||||
const axiomEnabled = useFlag('axiomLogDrain')
|
||||
const otlpEnabled = useFlag('otlpLogDrain')
|
||||
const last9Enabled = useFlag('Last9LogDrain')
|
||||
const syslogEnabled = useFlag('syslogLogDrain')
|
||||
const enabledLogDrainTypes = useEnabledLogDrainTypes()
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
@@ -474,15 +470,7 @@ export function LogDrainDestinationSheetForm({
|
||||
{LOG_DRAIN_TYPES.find((t) => t.value === type)?.name}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_DRAIN_TYPES.filter((t) => {
|
||||
if (t.value === 'sentry') return sentryEnabled
|
||||
if (t.value === 's3') return s3Enabled
|
||||
if (t.value === 'axiom') return axiomEnabled
|
||||
if (t.value === 'otlp') return otlpEnabled
|
||||
if (t.value === 'last9') return last9Enabled
|
||||
if (t.value === 'syslog') return syslogEnabled
|
||||
return true
|
||||
}).map((type) => (
|
||||
{enabledLogDrainTypes.map((type) => (
|
||||
<SelectItem
|
||||
value={type.value}
|
||||
key={type.value}
|
||||
|
||||
@@ -3,19 +3,9 @@ import { useFlag } from 'common'
|
||||
import { LOG_DRAIN_TYPES } from './LogDrains.constants'
|
||||
|
||||
export function useEnabledLogDrainTypes() {
|
||||
const sentryEnabled = useFlag('SentryLogDrain')
|
||||
const s3Enabled = useFlag('S3logdrain')
|
||||
const axiomEnabled = useFlag('axiomLogDrain')
|
||||
const otlpEnabled = useFlag('otlpLogDrain')
|
||||
const last9Enabled = useFlag('Last9LogDrain')
|
||||
const syslogEnabled = useFlag('syslogLogDrain')
|
||||
|
||||
return LOG_DRAIN_TYPES.filter((t) => {
|
||||
if (t.value === 'sentry') return sentryEnabled
|
||||
if (t.value === 's3') return s3Enabled
|
||||
if (t.value === 'axiom') return axiomEnabled
|
||||
if (t.value === 'otlp') return otlpEnabled
|
||||
if (t.value === 'last9') return last9Enabled
|
||||
if (t.value === 'syslog') return syslogEnabled
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
type ChartConfig,
|
||||
} from 'ui'
|
||||
import { ChartEmptyState, ChartLoadingState } from 'ui-patterns/Chart'
|
||||
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
||||
@@ -47,6 +48,12 @@ const colorClassMap: Record<string, string> = {
|
||||
brand: 'bg-brand',
|
||||
}
|
||||
|
||||
const LEVEL_CHART_CONFIG: ChartConfig = {
|
||||
error_count: { label: 'Errors' },
|
||||
warning_count: { label: 'Warnings' },
|
||||
ok_count: { label: 'Infos' },
|
||||
}
|
||||
|
||||
const SERVICE_DESCRIPTIONS: Record<ServiceKey, string> = {
|
||||
db: 'PostgreSQL database health and performance',
|
||||
auth: 'Authentication and user management',
|
||||
@@ -171,6 +178,7 @@ const ServiceCell = ({
|
||||
hideDateRange
|
||||
hideXAxis
|
||||
data={data.eventChartData}
|
||||
chartConfig={LEVEL_CHART_CONFIG}
|
||||
DateTimeFormat={datetimeFormat}
|
||||
onBarClick={onBarClick}
|
||||
EmptyState={<ChartEmptyState className="h-full" description="No traffic" />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useFeatureFlags, useFlag, useParams } from 'common'
|
||||
import { useFeatureFlags, useParams } from 'common'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import type { CloudProvider } from 'shared-data'
|
||||
import {
|
||||
@@ -29,7 +29,7 @@ import { useDefaultRegionQuery } from '@/data/misc/get-default-region-query'
|
||||
import { useOrganizationAvailableRegionsQuery } from '@/data/organizations/organization-available-regions-query'
|
||||
import { useIncidentStatusQuery } from '@/data/platform/incident-status-query'
|
||||
import type { DesiredInstanceSize } from '@/data/projects/new-project.constants'
|
||||
import { BASE_PATH, PROVIDERS } from '@/lib/constants'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
|
||||
interface RegionSelectorProps {
|
||||
form: UseFormReturn<CreateProjectForm>
|
||||
@@ -70,7 +70,7 @@ export const RegionSelector = ({
|
||||
const cloudProvider = form.getValues('cloudProvider') as CloudProvider
|
||||
|
||||
const { hasLoaded: flagsLoaded } = useFeatureFlags()
|
||||
const smartRegionEnabled = useFlag('enableSmartRegion')
|
||||
const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS'
|
||||
|
||||
const { data: statusData } = useIncidentStatusQuery()
|
||||
const { incidents = [] } = statusData ?? {}
|
||||
@@ -100,7 +100,7 @@ export const RegionSelector = ({
|
||||
availableRegionsData?.recommendations.specific.map((region) => region.code)
|
||||
)
|
||||
|
||||
const availableRegions = getAvailableRegions(PROVIDERS[cloudProvider].id)
|
||||
const availableRegions = getAvailableRegions(cloudProvider)
|
||||
const regionsArray = Object.entries(availableRegions).map(([_key, value]) => {
|
||||
return {
|
||||
code: value.code,
|
||||
|
||||
@@ -207,14 +207,14 @@ export const ProjectUsageSectionDeltas = () => {
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-warning rounded-full" />
|
||||
<span className="heading-meta">Warn</span>
|
||||
<span className="heading-meta">Warnings</span>
|
||||
</div>
|
||||
<span className="text-foreground text-base">{s.warn.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-destructive rounded-full" />
|
||||
<span className="heading-meta">Err</span>
|
||||
<span className="heading-meta">Errors</span>
|
||||
</div>
|
||||
<span className="text-foreground text-base">{s.err.toLocaleString()}</span>
|
||||
</div>
|
||||
@@ -231,7 +231,7 @@ export const ProjectUsageSectionDeltas = () => {
|
||||
chartConfig={{
|
||||
error_count: { label: 'Errors' },
|
||||
warning_count: { label: 'Warnings' },
|
||||
ok_count: { label: 'Requests' },
|
||||
ok_count: { label: 'Infos' },
|
||||
}}
|
||||
EmptyState={
|
||||
isLoading ? (
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import { Monaco, OnMount } from '@monaco-editor/react'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { noop } from 'lodash'
|
||||
import { useRouter } from 'next/router'
|
||||
import { RefObject, useEffect, useRef, useState } from 'react'
|
||||
import { RefObject, useRef } from 'react'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
|
||||
import type { IStandaloneCodeEditor } from './SQLEditor.types'
|
||||
import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils'
|
||||
import { useSnippetEditor } from './useSnippetEditor'
|
||||
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { getEditorSelectionParts } from '@/components/ui/AIEditor/utils'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useIsShortcutEnabled } from '@/state/shortcuts/useIsShortcutEnabled'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { wasNeverPersisted } from '@/state/sql-editor/sql-editor-lifecycle'
|
||||
import { canEditSnippet } from '@/state/sql-editor/sql-editor-rules'
|
||||
import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator'
|
||||
import { useTabsStateSnapshot } from '@/state/tabs'
|
||||
|
||||
export type MonacoEditorProps = {
|
||||
id: string
|
||||
@@ -63,13 +55,6 @@ export const MonacoEditor = ({
|
||||
onPrompt,
|
||||
onMount,
|
||||
}: MonacoEditorProps) => {
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const { ref, content } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const tabsSnap = useTabsStateSnapshot()
|
||||
const aiSnap = useAiAssistantStateSnapshot()
|
||||
const { openSidebar, toggleSidebar } = useSidebarManagerSnapshot()
|
||||
|
||||
@@ -78,11 +63,14 @@ export const MonacoEditor = ({
|
||||
true
|
||||
)
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
const debouncedValue = useDebounce(value, 1000)
|
||||
const { snippet, disableEdit, handleEditorChange } = useSnippetEditor({ id, snippetName })
|
||||
|
||||
const snippet = snapV2.snippets[id]
|
||||
const disableEdit = !!snippet && !canEditSnippet(snippet.snippet, profile?.id)
|
||||
// The Monaco save action is registered once on mount, but `snippet` starts
|
||||
// undefined for a new/deep-linked snippet and is only created on first edit.
|
||||
// Read it through a ref so Cmd/Ctrl+S sees the latest value, not the stale
|
||||
// mount-time closure.
|
||||
const snippetRef = useRef(snippet)
|
||||
snippetRef.current = snippet
|
||||
|
||||
const executeExplainQueryRef = useRef(executeExplainQuery)
|
||||
executeExplainQueryRef.current = executeExplainQuery
|
||||
@@ -147,7 +135,8 @@ export const MonacoEditor = ({
|
||||
contextMenuGroupId: 'operation',
|
||||
contextMenuOrder: 0,
|
||||
run: () => {
|
||||
if (snippet) requestSaveRef.current(snippet.snippet.id)
|
||||
const currentSnippet = snippetRef.current
|
||||
if (currentSnippet) requestSaveRef.current(currentSnippet.snippet.id)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -215,46 +204,6 @@ export const MonacoEditor = ({
|
||||
onMount?.(editor)
|
||||
}
|
||||
|
||||
function handleEditorChange(value: string | undefined) {
|
||||
tabsSnap.makeActiveTabPermanent()
|
||||
if (id && value) {
|
||||
if (!snippet && ref && profile !== undefined && project !== undefined) {
|
||||
const snippet = createSqlSnippetSkeletonV2({
|
||||
idOverride: id,
|
||||
name: snippetName,
|
||||
sql: value,
|
||||
owner_id: profile?.id,
|
||||
project_id: project?.id,
|
||||
})
|
||||
snapV2.addSnippet({ projectRef: ref, snippet })
|
||||
// When the editor was seeded from a `content` deep-link, replace rather
|
||||
// than push. The caller navigated to `/sql/new?content=...` (a long,
|
||||
// one-shot URL); replacing collapses it out of history so Back returns to
|
||||
// the originating page instead of a wasted step that re-seeds the snippet.
|
||||
if (router.query.content !== undefined) {
|
||||
router.replace(`/project/${ref}/sql/${snippet.id}`, undefined, { shallow: true })
|
||||
} else {
|
||||
router.push(`/project/${ref}/sql/${snippet.id}`, undefined, { shallow: true })
|
||||
}
|
||||
}
|
||||
setValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedValue.length > 0 && snippet) {
|
||||
const shouldInvalidate = wasNeverPersisted(snippet.snippet.status)
|
||||
snapV2.setSql({ id, sql: value, shouldInvalidate })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedValue])
|
||||
|
||||
// if an SQL query is passed by the content parameter, set the editor value to its content. This
|
||||
// is usually used for sending the user to SQL editor from other pages with SQL.
|
||||
useEffect(() => {
|
||||
if (content && content.length > 0) handleEditorChange(content)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{disableEdit && (
|
||||
|
||||
@@ -126,7 +126,6 @@ export const SQLEditor = () => {
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
|
||||
const { aiOptInLevel } = useOrgAiOptInLevel()
|
||||
const showPrettyExplain = useFlag('ShowPrettyExplain')
|
||||
|
||||
// [Ali] Kill switch to hide the SQL Editor Explain tab and its entry points
|
||||
const disablePrettyExplain = useFlag('DisablePrettyExplainOnSqlEditor')
|
||||
@@ -236,7 +235,7 @@ export const SQLEditor = () => {
|
||||
if (id) {
|
||||
sessionSnap.addResult(id, data.result, vars.autoLimit)
|
||||
|
||||
if (!disablePrettyExplain && showPrettyExplain && isExplainQuery(data.result)) {
|
||||
if (!disablePrettyExplain && isExplainQuery(data.result)) {
|
||||
sessionSnap.addExplainResult(id, data.result)
|
||||
setActiveUtilityTab('explain')
|
||||
} else if (activeUtilityTab === 'explain') {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useParams } from 'common'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useEffectEvent } from 'react'
|
||||
|
||||
import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useProfile } from '@/lib/profile'
|
||||
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { wasNeverPersisted } from '@/state/sql-editor/sql-editor-lifecycle'
|
||||
import { canEditSnippet } from '@/state/sql-editor/sql-editor-rules'
|
||||
import { useTabsStateSnapshot } from '@/state/tabs'
|
||||
|
||||
/**
|
||||
* Owns the editing lifecycle of a single SQL snippet: creating the snippet in
|
||||
* the store on first edit (and routing to its URL), writing changes back to the
|
||||
* store via `setSql`, and seeding the editor from a `?content=` deep link.
|
||||
*
|
||||
* Edits are written to the store synchronously on every change; debouncing the
|
||||
* actual persistence is the save mechanism's job (see `createSaveMechanism`), so
|
||||
* the store — and therefore the snippet's dirty status — always reflects the
|
||||
* latest edit immediately.
|
||||
*
|
||||
* Extracted from `MonacoEditor` so that component stays a thin editor shell.
|
||||
*/
|
||||
export function useSnippetEditor({ id, snippetName }: { id: string; snippetName: string }) {
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const { ref, content } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const tabsSnap = useTabsStateSnapshot()
|
||||
|
||||
const snippet = snapV2.snippets[id]
|
||||
const disableEdit = !!snippet && !canEditSnippet(snippet.snippet, profile?.id)
|
||||
|
||||
function handleEditorChange(value: string | undefined) {
|
||||
tabsSnap.makeActiveTabPermanent()
|
||||
if (!id || !value) return
|
||||
|
||||
if (!snippet && ref && profile !== undefined && project !== undefined) {
|
||||
const newSnippet = createSqlSnippetSkeletonV2({
|
||||
idOverride: id,
|
||||
name: snippetName,
|
||||
sql: value,
|
||||
owner_id: profile?.id,
|
||||
project_id: project?.id,
|
||||
})
|
||||
snapV2.addSnippet({ projectRef: ref, snippet: newSnippet })
|
||||
// When the editor was seeded from a `content` deep-link, replace rather
|
||||
// than push. The caller navigated to `/sql/new?content=...` (a long,
|
||||
// one-shot URL); replacing collapses it out of history so Back returns to
|
||||
// the originating page instead of a wasted step that re-seeds the snippet.
|
||||
if (router.query.content !== undefined) {
|
||||
router.replace(`/project/${ref}/sql/${newSnippet.id}`, undefined, { shallow: true })
|
||||
} else {
|
||||
router.push(`/project/${ref}/sql/${newSnippet.id}`, undefined, { shallow: true })
|
||||
}
|
||||
}
|
||||
|
||||
// A snippet that has never been persisted needs the snippet/folder lists
|
||||
// invalidated so it shows up in the sidebar. If `snippet` was undefined at
|
||||
// render we just created one above (status 'new'), which always qualifies.
|
||||
const shouldInvalidate = snippet ? wasNeverPersisted(snippet.snippet.status) : true
|
||||
snapV2.setSql({ id, sql: value, shouldInvalidate })
|
||||
}
|
||||
|
||||
// if an SQL query is passed by the content parameter, set the editor value to its content. This
|
||||
// is usually used for sending the user to SQL editor from other pages with SQL.
|
||||
const seedFromContentParam = useEffectEvent(() => {
|
||||
if (content && content.length > 0) handleEditorChange(content)
|
||||
})
|
||||
useEffect(() => {
|
||||
seedFromContentParam()
|
||||
// The useEffectEvent return is stable and must not be a dependency; this
|
||||
// disable can go once our eslint version understands useEffectEvent.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return { snippet, disableEdit, handleEditorChange }
|
||||
}
|
||||
@@ -27,11 +27,17 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
|
||||
schema: 'storage',
|
||||
})
|
||||
|
||||
const { mutateAsync: deletePolicy, isPending: isDeletingPolicies } =
|
||||
useDatabasePolicyDeleteMutation()
|
||||
const { mutateAsync: deletePolicy } = useDatabasePolicyDeleteMutation()
|
||||
|
||||
const { mutate: deleteBucket, isPending: isDeletingBucket } = useBucketDeleteMutation({
|
||||
onSuccess: async () => {
|
||||
// Close the modal and navigate away as soon as the bucket itself is deleted, so
|
||||
// policy cleanup below (which can be slow) doesn't hold the loading state or block
|
||||
// the success feedback.
|
||||
toast.success(`Successfully deleted bucket ${bucket.id}`)
|
||||
onClose()
|
||||
if (bucketId) router.push(`/project/${projectRef}/storage/files`)
|
||||
|
||||
if (!project) return console.error('Project is required')
|
||||
|
||||
// Clean up policies from the corresponding bucket that was deleted
|
||||
@@ -42,20 +48,18 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
|
||||
return policyBucket === bucket.name
|
||||
})
|
||||
|
||||
if (bucketPolicies.length === 0) return
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
bucketPolicies.map((policy) =>
|
||||
deletePolicy({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
originalPolicy: policy,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
toast.success(`Successfully deleted bucket ${bucket.id}`)
|
||||
if (!!bucketId) router.push(`/project/${projectRef}/storage/files`)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.success(
|
||||
`Successfully deleted bucket ${bucket.id}. However, there was a problem deleting the policies tied to the bucket. Please review them in the storage policies section`
|
||||
@@ -76,7 +80,7 @@ export const DeleteBucketModal = ({ visible, bucket, onClose }: DeleteBucketModa
|
||||
size="medium"
|
||||
variant="destructive"
|
||||
title={`Delete bucket “${bucket.id}”`}
|
||||
loading={isDeletingBucket || isDeletingPolicies}
|
||||
loading={isDeletingBucket}
|
||||
confirmPlaceholder="Type bucket name"
|
||||
confirmString={bucket.id}
|
||||
confirmLabel="Delete bucket"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from './Storage.constants'
|
||||
import type { PolicyFormField } from '@/components/interfaces/Database/Policies/Policies.types'
|
||||
import { PolicyFormField } from './StoragePolicies/StoragePolicies.types'
|
||||
|
||||
export interface StoragePolicyFormField extends PolicyFormField {
|
||||
allowedOperations: string[]
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
@@ -0,0 +1,6 @@
|
||||
export const POLICY_MODAL_VIEWS = {
|
||||
SELECTION: 'SELECTION',
|
||||
TEMPLATES: 'TEMPLATES',
|
||||
EDITOR: 'EDITOR',
|
||||
REVIEW: 'REVIEW',
|
||||
}
|
||||
+2
-4
@@ -2,7 +2,7 @@ import { noop } from 'lodash'
|
||||
import { ChevronLeft, FlaskConical } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { POLICY_MODAL_VIEWS } from '../Policies.constants'
|
||||
import { POLICY_MODAL_VIEWS } from './PolicyEditorModal.constants'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
@@ -16,7 +16,7 @@ interface PolicyEditorModalTitleProps {
|
||||
onToggleFeaturePreviewModal: () => void
|
||||
}
|
||||
|
||||
const PolicyEditorModalTitle = ({
|
||||
export const PolicyEditorModalTitle = ({
|
||||
view,
|
||||
schema,
|
||||
table,
|
||||
@@ -65,5 +65,3 @@ const PolicyEditorModalTitle = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PolicyEditorModalTitle
|
||||
+12
-10
@@ -4,7 +4,10 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from 'ui'
|
||||
|
||||
import { POLICY_MODAL_VIEWS } from '../Policies.constants'
|
||||
import { PolicyEditor } from '../PolicyEditor'
|
||||
import { PolicyReview } from '../PolicyReview'
|
||||
import { PolicySelection } from '../PolicySelection'
|
||||
import { PolicyTemplates } from '../PolicyTemplates'
|
||||
import {
|
||||
DraftPostgresPolicyCreatePayload,
|
||||
DraftPostgresPolicyUpdatePayload,
|
||||
@@ -12,20 +15,19 @@ import {
|
||||
PolicyForReview,
|
||||
PostgresPolicyCreatePayload,
|
||||
PostgresPolicyUpdatePayload,
|
||||
} from '../Policies.types'
|
||||
} from '../StoragePolicies.types'
|
||||
import {
|
||||
createPayloadForCreatePolicy,
|
||||
createPayloadForUpdatePolicy,
|
||||
createSQLPolicy,
|
||||
} from '../Policies.utils'
|
||||
import { PolicyEditor } from '../PolicyEditor'
|
||||
import { PolicyReview } from '../PolicyReview'
|
||||
import PolicySelection from '../PolicySelection'
|
||||
import PolicyTemplates from '../PolicyTemplates'
|
||||
import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants'
|
||||
import { getGeneralPolicyTemplates } from './PolicyEditorModal.constants'
|
||||
import PolicyEditorModalTitle from './PolicyEditorModalTitle'
|
||||
} from '../StoragePolicies.utils'
|
||||
import { POLICY_MODAL_VIEWS } from './PolicyEditorModal.constants'
|
||||
import { PolicyEditorModalTitle } from './PolicyEditorModalTitle'
|
||||
import { useFeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import {
|
||||
getGeneralPolicyTemplates,
|
||||
type PolicyTemplate,
|
||||
} from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { useLatest } from '@/hooks/misc/useLatest'
|
||||
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
|
||||
+2
-4
@@ -2,7 +2,7 @@ import { isEmpty, noop } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { Button, DialogFooter, DialogSection } from 'ui'
|
||||
|
||||
import type { PolicyForReview } from './Policies.types'
|
||||
import type { PolicyForReview } from './StoragePolicies.types'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
|
||||
interface PolicyReviewProps {
|
||||
@@ -11,8 +11,6 @@ interface PolicyReviewProps {
|
||||
onSelectSave: () => void
|
||||
}
|
||||
|
||||
// [Joshen] This seems like dead code atm, clean up separately
|
||||
|
||||
export const PolicyReview = ({
|
||||
policy = {},
|
||||
onSelectBack = noop,
|
||||
@@ -24,7 +22,7 @@ export const PolicyReview = ({
|
||||
onSelectSave()
|
||||
}
|
||||
|
||||
let formattedSQLStatement = policy.statement || ''
|
||||
const formattedSQLStatement = policy.statement || ''
|
||||
|
||||
return (
|
||||
<>
|
||||
+1
-3
@@ -12,7 +12,7 @@ interface PolicySelectionProps {
|
||||
onToggleFeaturePreviewModal?: () => void
|
||||
}
|
||||
|
||||
const PolicySelection = ({
|
||||
export const PolicySelection = ({
|
||||
description = '',
|
||||
showAssistantPreview,
|
||||
onViewTemplates = noop,
|
||||
@@ -89,5 +89,3 @@ const PolicySelection = ({
|
||||
</DialogSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default PolicySelection
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { PolicyTemplate } from './PolicyTemplates.constants'
|
||||
import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
|
||||
interface TemplatePreviewProps {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { noop } from 'lodash'
|
||||
import { Menu } from 'ui'
|
||||
|
||||
import { PolicyTemplate } from './PolicyTemplates.constants'
|
||||
import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
|
||||
interface TemplatesListProps {
|
||||
templates: PolicyTemplate[]
|
||||
+2
-4
@@ -2,9 +2,9 @@ import { isEmpty, noop } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { Button, DialogSectionSeparator } from 'ui'
|
||||
|
||||
import { PolicyTemplate } from './PolicyTemplates.constants'
|
||||
import TemplatePreview from './TemplatePreview'
|
||||
import TemplatesList from './TemplatesList'
|
||||
import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
|
||||
interface PolicyTemplatesProps {
|
||||
templates?: PolicyTemplate[]
|
||||
@@ -12,7 +12,7 @@ interface PolicyTemplatesProps {
|
||||
onUseTemplate?: (template: PolicyTemplate) => void
|
||||
}
|
||||
|
||||
const PolicyTemplates = ({
|
||||
export const PolicyTemplates = ({
|
||||
templates = [],
|
||||
templatesNote = '',
|
||||
onUseTemplate = noop,
|
||||
@@ -45,5 +45,3 @@ const PolicyTemplates = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PolicyTemplates
|
||||
@@ -16,14 +16,14 @@ import {
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { formatPoliciesForStorage, UNGROUPED_POLICY_SYMBOL } from '../Storage.utils'
|
||||
import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
|
||||
import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection'
|
||||
import { StoragePoliciesEditPolicyModal } from './StoragePoliciesEditPolicyModal'
|
||||
import { PolicyEditorModal } from './PolicyEditorModal'
|
||||
import type {
|
||||
PostgresPolicyCreatePayload,
|
||||
PostgresPolicyUpdatePayload,
|
||||
} from '@/components/interfaces/Database/Policies/Policies.types'
|
||||
import { PolicyEditorModal } from '@/components/interfaces/Database/Policies/PolicyEditorModal'
|
||||
} from './StoragePolicies.types'
|
||||
import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
|
||||
import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection'
|
||||
import { StoragePoliciesEditPolicyModal } from './StoragePoliciesEditPolicyModal'
|
||||
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
|
||||
import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
|
||||
import { useDatabasePolicyCreateMutation } from '@/data/database-policies/database-policy-create-mutation'
|
||||
|
||||
+5
@@ -58,3 +58,8 @@ export interface DraftPostgresPolicyUpdatePayload extends Omit<
|
||||
definition?: DisplayableSqlFragment
|
||||
check?: DisplayableSqlFragment
|
||||
}
|
||||
|
||||
export interface PolicyForReview {
|
||||
description?: string
|
||||
statement?: string
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import { has, isEmpty, isEqual } from 'lodash'
|
||||
|
||||
import {
|
||||
DraftPostgresPolicyCreatePayload,
|
||||
DraftPostgresPolicyUpdatePayload,
|
||||
PolicyFormField,
|
||||
PolicyForReview,
|
||||
} from './StoragePolicies.types'
|
||||
|
||||
/**
|
||||
* Returns an array of SQL statements that will preview in the review step of the policy editor
|
||||
* @param {*} policyFormFields { name, using, check, command }
|
||||
*/
|
||||
|
||||
export const createSQLPolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
originalPolicyFormFields?: PGPolicy
|
||||
) => {
|
||||
const { definition, check } = policyFormFields
|
||||
const formattedPolicyFormFields = {
|
||||
...policyFormFields,
|
||||
definition: definition
|
||||
? definition.replace(/\s+/g, ' ').trim()
|
||||
: definition === undefined
|
||||
? null
|
||||
: definition,
|
||||
check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check,
|
||||
}
|
||||
|
||||
if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) {
|
||||
return createSQLStatementForCreatePolicy(formattedPolicyFormFields)
|
||||
}
|
||||
|
||||
// If there are no changes, return an empty object
|
||||
if (isEqual(policyFormFields, originalPolicyFormFields)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// Extract out all the fields that updated
|
||||
const fieldsToUpdate: any = {}
|
||||
if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) {
|
||||
fieldsToUpdate.name = formattedPolicyFormFields.name
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) {
|
||||
fieldsToUpdate.definition = formattedPolicyFormFields.definition
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) {
|
||||
fieldsToUpdate.check = formattedPolicyFormFields.check
|
||||
}
|
||||
if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) {
|
||||
fieldsToUpdate.roles = formattedPolicyFormFields.roles
|
||||
}
|
||||
|
||||
if (!isEmpty(fieldsToUpdate)) {
|
||||
return createSQLStatementForUpdatePolicy(formattedPolicyFormFields, fieldsToUpdate)
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): PolicyForReview => {
|
||||
const { name, definition, check, command, schema, table } = policyFormFields
|
||||
const roles = policyFormFields.roles.length === 0 ? ['public'] : policyFormFields.roles
|
||||
const description = `Add policy for the ${command} operation under the policy "${name}"`
|
||||
const statement = [
|
||||
`CREATE POLICY "${name}" ON "${schema}"."${table}"`,
|
||||
`AS PERMISSIVE FOR ${command}`,
|
||||
`TO ${roles.join(', ')}`,
|
||||
`${definition ? `USING (${definition})` : ''}`,
|
||||
`${check ? `WITH CHECK (${check})` : ''}`,
|
||||
].join('\n')
|
||||
|
||||
return { description, statement }
|
||||
}
|
||||
|
||||
const createSQLStatementForUpdatePolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
fieldsToUpdate: Partial<PolicyFormField>
|
||||
): PolicyForReview => {
|
||||
const { name, schema, table } = policyFormFields
|
||||
|
||||
const definitionChanged = has(fieldsToUpdate, ['definition'])
|
||||
const checkChanged = has(fieldsToUpdate, ['check'])
|
||||
const nameChanged = has(fieldsToUpdate, ['name'])
|
||||
const rolesChanged = has(fieldsToUpdate, ['roles'])
|
||||
|
||||
const parameters = Object.keys(fieldsToUpdate)
|
||||
const description = `Update policy's ${
|
||||
parameters.length === 1
|
||||
? parameters[0]
|
||||
: `${parameters.slice(0, parameters.length - 1).join(', ')} and ${
|
||||
parameters[parameters.length - 1]
|
||||
}`
|
||||
} `
|
||||
const roles =
|
||||
(fieldsToUpdate?.roles ?? []).length === 0 ? ['public'] : (fieldsToUpdate.roles as string[])
|
||||
|
||||
const alterStatement = `ALTER POLICY "${name}" ON "${schema}"."${table}"`
|
||||
const statement = [
|
||||
'BEGIN;',
|
||||
...(definitionChanged ? [` ${alterStatement} USING (${fieldsToUpdate.definition});`] : []),
|
||||
...(checkChanged ? [` ${alterStatement} WITH CHECK (${fieldsToUpdate.check});`] : []),
|
||||
...(rolesChanged ? [` ${alterStatement} TO ${roles.join(', ')};`] : []),
|
||||
...(nameChanged ? [` ${alterStatement} RENAME TO "${fieldsToUpdate.name}";`] : []),
|
||||
'COMMIT;',
|
||||
].join('\n')
|
||||
|
||||
return { description, statement }
|
||||
}
|
||||
|
||||
// These constructors return DRAFT payloads — `definition`/`check` are still
|
||||
// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture
|
||||
// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that
|
||||
// it was reached via a deliberate user action.
|
||||
export const createPayloadForCreatePolicy = (
|
||||
policyFormFields: PolicyFormField
|
||||
): DraftPostgresPolicyCreatePayload => {
|
||||
const { name, schema, table, command, definition, check, roles } = policyFormFields
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
table,
|
||||
action: 'PERMISSIVE',
|
||||
command: command || undefined,
|
||||
definition: !definition ? undefined : untrustedSql(definition),
|
||||
check: !check ? undefined : untrustedSql(check),
|
||||
roles: roles.length > 0 ? roles : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const createPayloadForUpdatePolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
originalPolicyFormFields: PGPolicy
|
||||
): DraftPostgresPolicyUpdatePayload => {
|
||||
const { definition, check } = policyFormFields
|
||||
const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition
|
||||
const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check
|
||||
|
||||
const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
|
||||
|
||||
if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) {
|
||||
payload.name = policyFormFields.name
|
||||
}
|
||||
if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) {
|
||||
payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition)
|
||||
}
|
||||
if (!isEqual(formattedCheck, originalPolicyFormFields.check)) {
|
||||
payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck)
|
||||
}
|
||||
if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) {
|
||||
if (policyFormFields.roles.length === 0) payload.roles = ['public']
|
||||
else payload.roles = policyFormFields.roles || undefined
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
+35
-30
@@ -8,13 +8,13 @@ import {
|
||||
createPayloadsForAddPolicy,
|
||||
createSQLPolicies,
|
||||
} from '../Storage.utils'
|
||||
import { POLICY_MODAL_VIEWS } from './PolicyEditorModal/PolicyEditorModal.constants'
|
||||
import { PolicySelection } from './PolicySelection'
|
||||
import { PolicyTemplates } from './PolicyTemplates'
|
||||
import { STORAGE_POLICY_TEMPLATES } from './StoragePolicies.constants'
|
||||
import StoragePoliciesEditor from './StoragePoliciesEditor'
|
||||
import StoragePoliciesReview from './StoragePoliciesReview'
|
||||
import { StoragePoliciesEditor } from './StoragePoliciesEditor'
|
||||
import { StoragePoliciesReview } from './StoragePoliciesReview'
|
||||
import { StoragePolicyEditorModalTitle } from './StoragePolicyEditorModalTitle'
|
||||
import { POLICY_MODAL_VIEWS } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import PolicySelection from '@/components/interfaces/Database/Policies/PolicySelection'
|
||||
import PolicyTemplates from '@/components/interfaces/Database/Policies/PolicyTemplates'
|
||||
|
||||
const newPolicyTemplate: any = {
|
||||
name: '',
|
||||
@@ -171,6 +171,7 @@ export const StoragePoliciesEditPolicyModal = ({
|
||||
<DialogContent
|
||||
size={view === POLICY_MODAL_VIEWS.SELECTION ? 'medium' : 'xxlarge'}
|
||||
hideClose={view !== POLICY_MODAL_VIEWS.TEMPLATES}
|
||||
className="flex max-h-[calc(100vh-8rem)] flex-col gap-0 overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -182,37 +183,41 @@ export const StoragePoliciesEditPolicyModal = ({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogSectionSeparator />
|
||||
{view === POLICY_MODAL_VIEWS.SELECTION ? (
|
||||
<PolicySelection
|
||||
description="PostgreSQL policies control access to your files and folders"
|
||||
onViewTemplates={onViewTemplates}
|
||||
onViewEditor={() => onViewEditor('new')}
|
||||
showAssistantPreview={false}
|
||||
/>
|
||||
) : view === POLICY_MODAL_VIEWS.EDITOR ? (
|
||||
<StoragePoliciesEditor
|
||||
policyFormFields={policyFormFields}
|
||||
onViewTemplates={onViewTemplates}
|
||||
onUpdatePolicyName={onUpdatePolicyName}
|
||||
onUpdatePolicyDefinition={onUpdatePolicyDefinition}
|
||||
onToggleOperation={onToggleOperation}
|
||||
onUpdatePolicyRoles={onUpdatePolicyRoles}
|
||||
onReviewPolicy={validatePolicyEditorFormFields}
|
||||
/>
|
||||
) : view === POLICY_MODAL_VIEWS.TEMPLATES ? (
|
||||
<PolicyTemplates
|
||||
templates={STORAGE_POLICY_TEMPLATES as any[]}
|
||||
onUseTemplate={onUseTemplate}
|
||||
templatesNote={''}
|
||||
/>
|
||||
) : view === POLICY_MODAL_VIEWS.REVIEW ? (
|
||||
{view === POLICY_MODAL_VIEWS.REVIEW ? (
|
||||
<StoragePoliciesReview
|
||||
policyStatements={policyStatementsForReview}
|
||||
onSelectBack={onViewEditor}
|
||||
onSelectSave={onReviewSave}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{view === POLICY_MODAL_VIEWS.SELECTION ? (
|
||||
<PolicySelection
|
||||
description="PostgreSQL policies control access to your files and folders"
|
||||
onViewTemplates={onViewTemplates}
|
||||
onViewEditor={() => onViewEditor('new')}
|
||||
showAssistantPreview={false}
|
||||
/>
|
||||
) : view === POLICY_MODAL_VIEWS.EDITOR ? (
|
||||
<StoragePoliciesEditor
|
||||
policyFormFields={policyFormFields}
|
||||
onViewTemplates={onViewTemplates}
|
||||
onUpdatePolicyName={onUpdatePolicyName}
|
||||
onUpdatePolicyDefinition={onUpdatePolicyDefinition}
|
||||
onToggleOperation={onToggleOperation}
|
||||
onUpdatePolicyRoles={onUpdatePolicyRoles}
|
||||
onReviewPolicy={validatePolicyEditorFormFields}
|
||||
/>
|
||||
) : view === POLICY_MODAL_VIEWS.TEMPLATES ? (
|
||||
<PolicyTemplates
|
||||
templates={STORAGE_POLICY_TEMPLATES as any[]}
|
||||
onUseTemplate={onUseTemplate}
|
||||
templatesNote={''}
|
||||
/>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Button, Checkbox, cn, DialogSection, DialogSectionSeparator } from 'ui'
|
||||
|
||||
import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from '../Storage.constants'
|
||||
import { deriveAllowedClientLibraryMethods } from '../Storage.utils'
|
||||
import { PolicyName } from '@/components/interfaces/Database/Policies/PolicyEditor/PolicyName'
|
||||
import { PolicyRoles } from '@/components/interfaces/Database/Policies/PolicyEditor/PolicyRoles'
|
||||
import { PolicyName } from './PolicyEditor/PolicyName'
|
||||
import { PolicyRoles } from './PolicyEditor/PolicyRoles'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
@@ -149,7 +149,7 @@ const PolicyEditorFooter = ({ onViewTemplates = () => {}, onReviewPolicy = () =>
|
||||
|
||||
// [Refactor] All these update methods could be summarised into one single function probably
|
||||
|
||||
const StoragePoliciesEditor = ({
|
||||
export const StoragePoliciesEditor = ({
|
||||
policyFormFields = {},
|
||||
onViewTemplates = noop,
|
||||
onUpdatePolicyName = noop,
|
||||
@@ -194,5 +194,3 @@ const StoragePoliciesEditor = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoragePoliciesEditor
|
||||
@@ -17,7 +17,7 @@ interface StoragePoliciesReviewProps {
|
||||
onSelectSave: any
|
||||
}
|
||||
|
||||
const StoragePoliciesReview = ({
|
||||
export const StoragePoliciesReview = ({
|
||||
policyStatements = [],
|
||||
onSelectBack = () => {},
|
||||
onSelectSave = () => {},
|
||||
@@ -74,5 +74,3 @@ const StoragePoliciesReview = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoragePoliciesReview
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { ChevronLeft, X } from 'lucide-react'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { POLICY_MODAL_VIEWS } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import { POLICY_MODAL_VIEWS } from './PolicyEditorModal/PolicyEditorModal.constants'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
|
||||
-165
@@ -1,165 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from 'ui'
|
||||
import { Admonition } from 'ui-patterns/admonition'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { useProjectStorageConfigUpdateUpdateMutation } from '@/data/config/project-storage-config-update-mutation'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
|
||||
// [Joshen] Will be decided by Storage team, temp setting to 15th December 2025 UTC (3 months buffer)
|
||||
const MIGRATION_DEADLINE = '2025-12-15T00:00:00'
|
||||
|
||||
export const StorageListV2MigrationCallout = () => {
|
||||
const deadline = dayjs(MIGRATION_DEADLINE).utc(true)
|
||||
const currentDate = dayjs.utc()
|
||||
const remainingMonths = Math.ceil(deadline.diff(currentDate, 'months', true))
|
||||
|
||||
return (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="A new version of Storage is available for your project"
|
||||
description={
|
||||
<>
|
||||
<p>
|
||||
Get access to the List-V2 endpoint for improved performance and the ability to enable
|
||||
Analytics buckets to your storage system.
|
||||
</p>
|
||||
{remainingMonths <= 1 && (
|
||||
<p>
|
||||
Your project's Storage will be automatically upgraded by{' '}
|
||||
<TimestampInfo
|
||||
displayAs="utc"
|
||||
utcTimestamp={MIGRATION_DEADLINE}
|
||||
className="text-sm text-inherit font-medium"
|
||||
labelFormat="DD MMM YYYY HH:mm (UTC)"
|
||||
/>{' '}
|
||||
if the upgrade is not completed by then.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
actions={<StorageListV2MigrationDialog />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const StorageListV2MigratingCallout = () => {
|
||||
return (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="Project storage is currently upgrading"
|
||||
description="This notice will be closed once the upgrade has been completed. Hang tight!"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const StorageListV2MigrationDialog = () => {
|
||||
const { ref } = useParams()
|
||||
const { can: canUpdateStorageSettings } = useAsyncCheckPermissions(
|
||||
PermissionAction.STORAGE_ADMIN_WRITE,
|
||||
'*'
|
||||
)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { mutate: updateStorageConfig, isPending: isUpdating } =
|
||||
useProjectStorageConfigUpdateUpdateMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Project's storage will be upgraded shortly!`)
|
||||
setOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const onConfirmUpgrade = () => {
|
||||
if (!ref) return console.error('Project ref is required')
|
||||
updateStorageConfig({ projectRef: ref, external: { upstreamTarget: 'canary' } })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<ButtonTooltip
|
||||
variant="primary"
|
||||
disabled={!canUpdateStorageSettings}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateStorageSettings
|
||||
? 'You need additional permissions to upgrade storage'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Upgrade Storage
|
||||
</ButtonTooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upgrade your project's Storage</DialogTitle>
|
||||
<DialogDescription>
|
||||
Get access to Analytics buckets and an improved list method
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<Admonition
|
||||
type="warning"
|
||||
className="rounded-none border-x-0 border-t-0"
|
||||
title="Migration required to optimise the database schema for upgrade"
|
||||
description="We recommend running the update during periods of lower activity, although minimal to no disruption is expected."
|
||||
/>
|
||||
|
||||
<DialogSection className="flex flex-col gap-y-2">
|
||||
<p className="text-sm">
|
||||
Depending on the number of objects in your Storage, the migration can take up to 24
|
||||
hours to finish.
|
||||
</p>
|
||||
|
||||
<p className="text-sm">
|
||||
The upgrade will increase your disk size to about 15 - 25% and IOPS will be used to
|
||||
create new efficient indexes as well as denormalising tables.
|
||||
</p>
|
||||
|
||||
<p className="text-sm">
|
||||
Ensure that your database instance has not{' '}
|
||||
<InlineLink href={`/project/${ref}/settings/compute-and-disk#disk-size`}>
|
||||
scaled disk
|
||||
</InlineLink>{' '}
|
||||
within the last 6h and you have at least 60%{' '}
|
||||
<InlineLink href={`/project/${ref}/settings/infrastructure#infrastructure-activity`}>
|
||||
CPU capacity
|
||||
</InlineLink>{' '}
|
||||
before proceeding.
|
||||
</p>
|
||||
</DialogSection>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="default" disabled={isUpdating} onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" loading={isUpdating} onClick={() => onConfirmUpgrade()}>
|
||||
Upgrade now
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { IS_PLATFORM, useFlag, useParams } from 'common'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
@@ -29,10 +29,6 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { StorageFileSizeLimitErrorMessage } from './StorageFileSizeLimitErrorMessage'
|
||||
import {
|
||||
StorageListV2MigratingCallout,
|
||||
StorageListV2MigrationCallout,
|
||||
} from './StorageListV2MigrationCallout'
|
||||
import {
|
||||
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED,
|
||||
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED,
|
||||
@@ -70,8 +66,6 @@ export const StorageSettings = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const showMigrationCallout = useFlag('storageMigrationCallout')
|
||||
|
||||
const { can: canReadStorageSettings, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
|
||||
PermissionAction.STORAGE_ADMIN_READ,
|
||||
'*'
|
||||
@@ -88,10 +82,6 @@ export const StorageSettings = () => {
|
||||
isSuccess,
|
||||
isError,
|
||||
} = useProjectStorageConfigQuery({ projectRef })
|
||||
const isListV2UpgradeAvailable =
|
||||
!!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'main'
|
||||
const isListV2Upgrading =
|
||||
!!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'canary'
|
||||
|
||||
const {
|
||||
runCondition: sizeLimitCheckCondition,
|
||||
@@ -282,209 +272,200 @@ export const StorageSettings = () => {
|
||||
/>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{showMigrationCallout && (
|
||||
<>
|
||||
{isListV2UpgradeAvailable && <StorageListV2MigrationCallout />}
|
||||
{isListV2Upgrading && <StorageListV2MigratingCallout />}
|
||||
</>
|
||||
)}
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="imageTransformationEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Enable image transformation"
|
||||
description={
|
||||
<>
|
||||
Optimize and resize images on the fly.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/storage/serving/image-transformations`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
size="large"
|
||||
disabled={
|
||||
!hasAccessToImageTransformations || !canUpdateStorageSettings
|
||||
}
|
||||
checked={hasAccessToImageTransformations && field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fileSizeLimit"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
hideMessage
|
||||
layout="flex-row-reverse"
|
||||
label="Global file size limit"
|
||||
description={
|
||||
<>
|
||||
Restrict the size of files uploaded across all buckets.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/storage/uploads/file-limits`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
.
|
||||
{!shouldAutoValidateBucketLimits && (
|
||||
<p>
|
||||
Ensure that the global limit is greater than that of
|
||||
individual buckets
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<div className="flex items-center justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e)
|
||||
form.clearErrors('fileSizeLimit')
|
||||
}}
|
||||
className="w-32 rounded-r-none border-r-0"
|
||||
disabled={
|
||||
!hasAccessToFileSizeConfiguration ||
|
||||
!canUpdateStorageSettings
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="unit"
|
||||
render={({ field: unitField }) => (
|
||||
<Select
|
||||
value={unitField.value}
|
||||
onValueChange={(val) => {
|
||||
unitField.onChange(val)
|
||||
form.clearErrors('fileSizeLimit')
|
||||
}}
|
||||
disabled={
|
||||
!hasAccessToFileSizeConfiguration ||
|
||||
!canUpdateStorageSettings
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[90px] text-xs font-mono rounded-l-none bg-surface-300">
|
||||
<SelectValue placeholder="Choose a prefix">
|
||||
{storageUnit}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(StorageSizeUnits).map((unit: string) => (
|
||||
<SelectItem
|
||||
key={unit}
|
||||
disabled={!hasAccessToFileSizeConfiguration}
|
||||
value={unit}
|
||||
>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
{sizeLimitCheckCondition === 'confirm' && (
|
||||
<ValidateSizeLimit
|
||||
onValidate={sizeLimitCheckQuery}
|
||||
projectRef={projectRef}
|
||||
isLoadingBucketEstimate={isBucketEstimatePending}
|
||||
/>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{fileSizeLimitError && (
|
||||
<FormMessage className="ml-auto mt-2 text-right w-1/2">
|
||||
<StorageFileSizeLimitErrorMessage
|
||||
error={fileSizeLimitError}
|
||||
projectRef={projectRef}
|
||||
/>
|
||||
</FormMessage>
|
||||
)}
|
||||
</CardContent>
|
||||
{hasLimitedStorageAccess && (
|
||||
<UpgradeToPro
|
||||
fullWidth
|
||||
variant="primary"
|
||||
source="storageSizeLimit"
|
||||
featureProposition="configure upload file size limits in Storage"
|
||||
primaryText="Free Plan has a fixed upload file size limit of 50 MB"
|
||||
secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes(
|
||||
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED
|
||||
)} and unlock image transformations.`}
|
||||
/>
|
||||
)}
|
||||
{isSpendCapOn && (
|
||||
<UpgradeToPro
|
||||
fullWidth
|
||||
addon="spendCap"
|
||||
variant="default"
|
||||
source="storageSizeLimit"
|
||||
featureProposition="increase the file upload size limits in Storage"
|
||||
buttonText="Disable spend cap"
|
||||
primaryText="Reduced max upload file size limit due to spend cap"
|
||||
secondaryText={`Disable your spend cap to allow file uploads of up to ${formatBytes(
|
||||
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED
|
||||
)}.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!canUpdateStorageSettings && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-light">
|
||||
You need additional permissions to update storage settings
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{form.formState.isDirty && (
|
||||
<Button
|
||||
variant="default"
|
||||
type="reset"
|
||||
onClick={() => form.reset()}
|
||||
disabled={
|
||||
!form.formState.isDirty || !canUpdateStorageSettings || isUpdating
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="imageTransformationEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
layout="flex-row-reverse"
|
||||
label="Enable image transformation"
|
||||
description={
|
||||
<>
|
||||
Optimize and resize images on the fly.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/storage/serving/image-transformations`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
.
|
||||
</>
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<FormControl>
|
||||
<Switch
|
||||
size="large"
|
||||
disabled={
|
||||
!hasAccessToImageTransformations || !canUpdateStorageSettings
|
||||
}
|
||||
checked={hasAccessToImageTransformations && field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fileSizeLimit"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
hideMessage
|
||||
layout="flex-row-reverse"
|
||||
label="Global file size limit"
|
||||
description={
|
||||
<>
|
||||
Restrict the size of files uploaded across all buckets.{' '}
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/storage/uploads/file-limits`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
.
|
||||
{!shouldAutoValidateBucketLimits && (
|
||||
<p>
|
||||
Ensure that the global limit is greater than that of
|
||||
individual buckets
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<div className="flex items-center justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e)
|
||||
form.clearErrors('fileSizeLimit')
|
||||
}}
|
||||
className="w-32 rounded-r-none border-r-0"
|
||||
disabled={
|
||||
!hasAccessToFileSizeConfiguration || !canUpdateStorageSettings
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="unit"
|
||||
render={({ field: unitField }) => (
|
||||
<Select
|
||||
value={unitField.value}
|
||||
onValueChange={(val) => {
|
||||
unitField.onChange(val)
|
||||
form.clearErrors('fileSizeLimit')
|
||||
}}
|
||||
disabled={
|
||||
!hasAccessToFileSizeConfiguration ||
|
||||
!canUpdateStorageSettings
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[90px] text-xs font-mono rounded-l-none bg-surface-300">
|
||||
<SelectValue placeholder="Choose a prefix">
|
||||
{storageUnit}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(StorageSizeUnits).map((unit: string) => (
|
||||
<SelectItem
|
||||
key={unit}
|
||||
disabled={!hasAccessToFileSizeConfiguration}
|
||||
value={unit}
|
||||
>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
{sizeLimitCheckCondition === 'confirm' && (
|
||||
<ValidateSizeLimit
|
||||
onValidate={sizeLimitCheckQuery}
|
||||
projectRef={projectRef}
|
||||
isLoadingBucketEstimate={isBucketEstimatePending}
|
||||
/>
|
||||
)}
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
{fileSizeLimitError && (
|
||||
<FormMessage className="ml-auto mt-2 text-right w-1/2">
|
||||
<StorageFileSizeLimitErrorMessage
|
||||
error={fileSizeLimitError}
|
||||
projectRef={projectRef}
|
||||
/>
|
||||
</FormMessage>
|
||||
)}
|
||||
</CardContent>
|
||||
{hasLimitedStorageAccess && (
|
||||
<UpgradeToPro
|
||||
fullWidth
|
||||
variant="primary"
|
||||
source="storageSizeLimit"
|
||||
featureProposition="configure upload file size limits in Storage"
|
||||
primaryText="Free Plan has a fixed upload file size limit of 50 MB"
|
||||
secondaryText={`Upgrade to Pro Plan for a configurable upload file size limit of ${formatBytes(
|
||||
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED
|
||||
)} and unlock image transformations.`}
|
||||
/>
|
||||
)}
|
||||
{isSpendCapOn && (
|
||||
<UpgradeToPro
|
||||
fullWidth
|
||||
addon="spendCap"
|
||||
variant="default"
|
||||
source="storageSizeLimit"
|
||||
featureProposition="increase the file upload size limits in Storage"
|
||||
buttonText="Disable spend cap"
|
||||
primaryText="Reduced max upload file size limit due to spend cap"
|
||||
secondaryText={`Disable your spend cap to allow file uploads of up to ${formatBytes(
|
||||
STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED
|
||||
)}.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!canUpdateStorageSettings && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-light">
|
||||
You need additional permissions to update storage settings
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="justify-end space-x-2">
|
||||
{form.formState.isDirty && (
|
||||
<Button
|
||||
variant={hasLimitedStorageAccess ? 'default' : 'primary'}
|
||||
type="submit"
|
||||
loading={isUpdating}
|
||||
variant="default"
|
||||
type="reset"
|
||||
onClick={() => form.reset()}
|
||||
disabled={
|
||||
!canUpdateStorageSettings || isUpdating || !form.formState.isDirty
|
||||
!form.formState.isDirty || !canUpdateStorageSettings || isUpdating
|
||||
}
|
||||
>
|
||||
Save
|
||||
Cancel
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant={hasLimitedStorageAccess ? 'default' : 'primary'}
|
||||
type="submit"
|
||||
loading={isUpdating}
|
||||
disabled={
|
||||
!canUpdateStorageSettings || isUpdating || !form.formState.isDirty
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
+2
-35
@@ -37,10 +37,6 @@ import type { ImportContent } from './TableEditor/TableEditor.types'
|
||||
import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations'
|
||||
import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
|
||||
import {
|
||||
acceptGeneratedPolicy,
|
||||
type GeneratedPolicy,
|
||||
} from '@/components/interfaces/Database/Policies/Policies.utils'
|
||||
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { databasePoliciesKeys } from '@/data/database-policies/keys'
|
||||
import { useDatabasePublicationCreateMutation } from '@/data/database-publications/database-publications-create-mutation'
|
||||
@@ -83,7 +79,6 @@ type SaveTableParamsBase = {
|
||||
columns: ColumnField[]
|
||||
foreignKeyRelations: ForeignKey[]
|
||||
resolve: () => void
|
||||
generatedPolicies?: GeneratedPolicy[]
|
||||
}
|
||||
|
||||
type SaveTableParamsNew = SaveTableParamsBase & {
|
||||
@@ -598,7 +593,6 @@ export const SidePanelEditor = ({
|
||||
configuration,
|
||||
columns,
|
||||
foreignKeyRelations,
|
||||
generatedPolicies = [],
|
||||
resolve,
|
||||
}: SaveTableParams) => {
|
||||
let toastId
|
||||
@@ -663,7 +657,6 @@ export const SidePanelEditor = ({
|
||||
'table.has_rls': isRLSEnabled ? 1 : 0,
|
||||
'table.has_foreign_keys': foreignKeyRelations.length > 0 ? 1 : 0,
|
||||
'table.has_import': importContent !== undefined ? 1 : 0,
|
||||
'table.generated_policies_count': generatedPolicies.length,
|
||||
'project.region': project?.region ?? 'local',
|
||||
...(project?.cloud_provider && {
|
||||
'project.cloud_provider': project.cloud_provider,
|
||||
@@ -674,13 +667,7 @@ export const SidePanelEditor = ({
|
||||
})
|
||||
|
||||
try {
|
||||
// The Save click is the explicit user gesture that promotes generated policy
|
||||
// SQL (programmatic or AI) to executable. Programmatic fragments are already
|
||||
// SafeSqlFragment; AI fragments are UntrustedSqlFragment — both are accepted
|
||||
// here before being passed into createTable.
|
||||
const acceptedPolicies = generatedPolicies.map(acceptGeneratedPolicy)
|
||||
|
||||
const { table, failedPolicies } = await createTable({
|
||||
const { table } = await createTable({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
toastId,
|
||||
@@ -689,13 +676,10 @@ export const SidePanelEditor = ({
|
||||
foreignKeyRelations,
|
||||
isRLSEnabled,
|
||||
importContent,
|
||||
generatedPolicies: acceptedPolicies,
|
||||
onCreatePoliciesSuccess: () => track('rls_generated_policies_created'),
|
||||
track,
|
||||
})
|
||||
|
||||
createTableSpan.setAttribute('table.created', 1)
|
||||
createTableSpan.setAttribute('table.failed_policies', failedPolicies.length)
|
||||
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.post_creation', op: 'db.table.post_creation' },
|
||||
@@ -736,24 +720,7 @@ export const SidePanelEditor = ({
|
||||
}
|
||||
)
|
||||
|
||||
// Show success toast after everything is complete
|
||||
if (failedPolicies.length > 0) {
|
||||
toast.success(
|
||||
`Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`,
|
||||
{
|
||||
id: toastId,
|
||||
description: (
|
||||
<ul className="list-disc pl-6">
|
||||
{failedPolicies.map((x) => (
|
||||
<li key={x.name}>{x.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.success(`Table ${table.name} is good to go!`, { id: toastId })
|
||||
}
|
||||
toast.success(`Table ${table.name} is good to go!`, { id: toastId })
|
||||
|
||||
onTableCreated(table)
|
||||
} catch (error) {
|
||||
|
||||
-2
@@ -132,7 +132,6 @@ describe('createTable', () => {
|
||||
})
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith('table_created', {
|
||||
has_generated_policies: false,
|
||||
method: 'table_editor',
|
||||
schema_name: 'public',
|
||||
table_name: 'test_table',
|
||||
@@ -148,7 +147,6 @@ describe('createTable', () => {
|
||||
)
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
failedPolicies: [],
|
||||
table: mockTableResult,
|
||||
})
|
||||
})
|
||||
|
||||
+2
-49
@@ -1,4 +1,5 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import type { PGTablePrimaryKey } from '@supabase/pg-meta'
|
||||
import pgMeta, {
|
||||
getAddForeignKeySQL,
|
||||
getAddPrimaryKeySQL,
|
||||
@@ -11,7 +12,6 @@ import pgMeta, {
|
||||
getUpdateIdentitySequenceSQL,
|
||||
type ForeignKey,
|
||||
} from '@supabase/pg-meta'
|
||||
import type { PGTablePrimaryKey } from '@supabase/pg-meta'
|
||||
import { joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { chunk, find, isEmpty, isEqual } from 'lodash'
|
||||
@@ -26,12 +26,10 @@ import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './Si
|
||||
import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils'
|
||||
import type { ImportContent } from './TableEditor/TableEditor.types'
|
||||
import type { SupaRow } from '@/components/grid/types'
|
||||
import { type AcceptedGeneratedPolicy } from '@/components/interfaces/Database/Policies/Policies.utils'
|
||||
import { SparkBar } from '@/components/ui/SparkBar'
|
||||
import { createDatabaseColumn } from '@/data/database-columns/database-column-create-mutation'
|
||||
import { deleteDatabaseColumn } from '@/data/database-columns/database-column-delete-mutation'
|
||||
import { updateDatabaseColumn } from '@/data/database-columns/database-column-update-mutation'
|
||||
import { createDatabasePolicy } from '@/data/database-policies/database-policy-create-mutation'
|
||||
import type { Constraint } from '@/data/database/constraints-query'
|
||||
import { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
|
||||
import { databaseKeys } from '@/data/database/keys'
|
||||
@@ -443,8 +441,6 @@ export const createTable = async ({
|
||||
foreignKeyRelations,
|
||||
isRLSEnabled,
|
||||
importContent,
|
||||
generatedPolicies = [],
|
||||
onCreatePoliciesSuccess,
|
||||
track,
|
||||
}: {
|
||||
projectRef: string
|
||||
@@ -459,8 +455,6 @@ export const createTable = async ({
|
||||
foreignKeyRelations: ForeignKey[]
|
||||
isRLSEnabled: boolean
|
||||
importContent?: ImportContent
|
||||
generatedPolicies?: AcceptedGeneratedPolicy[]
|
||||
onCreatePoliciesSuccess?: () => void
|
||||
track: Track
|
||||
}) => {
|
||||
const queryClient = getQueryClient()
|
||||
@@ -545,51 +539,10 @@ export const createTable = async ({
|
||||
}
|
||||
)
|
||||
|
||||
// 6. Create generated RLS policies if any
|
||||
// [Joshen] Possible area for optimization to create all policies in a single query call
|
||||
// Can be subsequently added to the table creation SQL as well for a single transaction
|
||||
|
||||
const failedPolicies: AcceptedGeneratedPolicy[] = []
|
||||
if (generatedPolicies.length > 0 && isRLSEnabled) {
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.create_policies', op: 'db.policies.create' },
|
||||
async (span) => {
|
||||
span.setAttribute('policies.count', generatedPolicies.length)
|
||||
toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId })
|
||||
await Promise.all(
|
||||
generatedPolicies.map(async (policy) => {
|
||||
try {
|
||||
return await createDatabasePolicy({
|
||||
projectRef,
|
||||
connectionString,
|
||||
payload: {
|
||||
name: policy.name,
|
||||
table: policy.table,
|
||||
schema: policy.schema,
|
||||
definition: policy.definition,
|
||||
check: policy.check,
|
||||
action: policy.action,
|
||||
command: policy.command,
|
||||
roles: policy.roles,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate policy', error.message)
|
||||
failedPolicies.push(policy)
|
||||
}
|
||||
})
|
||||
)
|
||||
span.setAttribute('policies.failed_count', failedPolicies.length)
|
||||
onCreatePoliciesSuccess?.()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
track('table_created', {
|
||||
method: 'table_editor',
|
||||
schema_name: payload.schema,
|
||||
table_name: payload.name,
|
||||
has_generated_policies: generatedPolicies.length > 0 && isRLSEnabled,
|
||||
})
|
||||
|
||||
if (isRLSEnabled) {
|
||||
@@ -726,7 +679,7 @@ export const createTable = async ({
|
||||
)
|
||||
|
||||
// Finally, return the created table
|
||||
return { table, failedPolicies }
|
||||
return { table }
|
||||
}
|
||||
|
||||
/** TODO: Refactor to do in a single transaction */
|
||||
|
||||
-3
@@ -242,7 +242,6 @@ export const TableEditor = ({
|
||||
columns,
|
||||
foreignKeyRelations: fkRelations,
|
||||
resolve,
|
||||
generatedPolicies: [],
|
||||
})
|
||||
} else if (isDuplicating) {
|
||||
const payload: SaveTablePayloadFor<'duplicate'> = {
|
||||
@@ -256,7 +255,6 @@ export const TableEditor = ({
|
||||
columns,
|
||||
foreignKeyRelations: fkRelations,
|
||||
resolve,
|
||||
generatedPolicies: [],
|
||||
})
|
||||
} else {
|
||||
const payload: SaveTablePayloadFor<'update'> = {
|
||||
@@ -271,7 +269,6 @@ export const TableEditor = ({
|
||||
columns,
|
||||
foreignKeyRelations: fkRelations,
|
||||
resolve,
|
||||
generatedPolicies: [],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
export const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const
|
||||
export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] as const
|
||||
export const LOG_TYPES_LABELS = {
|
||||
edge: 'API Gateway',
|
||||
postgres: 'Postgres',
|
||||
postgrest: 'PostgREST',
|
||||
auth: 'Auth',
|
||||
@@ -29,7 +30,7 @@ export const LOG_TYPES_LABELS = {
|
||||
|
||||
type LogType = keyof typeof LOG_TYPES_LABELS
|
||||
export const LOG_TYPES = Object.keys(LOG_TYPES_LABELS) as [LogType, ...LogType[]]
|
||||
export const DEFAULT_LOG_TYPES = ['postgres', 'postgrest'] as const
|
||||
export const DEFAULT_LOG_TYPES = ['postgres', 'edge'] as const
|
||||
|
||||
const parseAsSort = createParser({
|
||||
parse(queryValue: string) {
|
||||
@@ -72,6 +73,9 @@ export const SEARCH_PARAMS_PARSER = {
|
||||
|
||||
// View options
|
||||
show_connection_logs: parseAsBoolean.withDefault(true),
|
||||
edge_auth: parseAsBoolean.withDefault(true),
|
||||
edge_storage: parseAsBoolean.withDefault(true),
|
||||
edge_postgrest: parseAsBoolean.withDefault(true),
|
||||
}
|
||||
|
||||
const POSTGRES_STATUS_CODE_LABELS = {
|
||||
|
||||
@@ -28,7 +28,15 @@ export const filterFields = [
|
||||
options:
|
||||
// [Joshen] Nested options are treated as just boolean toggles atm for simplicity
|
||||
// Refer to DataTableFilterCheckbox for their logic
|
||||
value === 'postgres' ? [{ label: 'Connection logs', value: 'show_connection_logs' }] : [],
|
||||
value === 'edge'
|
||||
? [
|
||||
{ label: 'Auth', value: 'edge_auth' },
|
||||
{ label: 'Storage', value: 'edge_storage' },
|
||||
{ label: 'Postgrest', value: 'edge_postgrest' },
|
||||
]
|
||||
: value === 'postgres'
|
||||
? [{ label: 'Connection logs', value: 'show_connection_logs' }]
|
||||
: [],
|
||||
})),
|
||||
component: (props: Option) => {
|
||||
return (
|
||||
|
||||
@@ -17,31 +17,28 @@ const withFilters = (...entries: string[]) => ({ ...baseSearch, filter: entries
|
||||
|
||||
describe('UnifiedLogs.queries (OTEL flat)', () => {
|
||||
describe('getUnifiedLogsQuery', () => {
|
||||
it('defaults to postgres + postgrest log types when none specified', () => {
|
||||
it('defaults to postgres + edge log types when none specified', () => {
|
||||
const sql = getUnifiedLogsQuery(baseSearch)
|
||||
const where = sql.split(/\bWHERE\b/)[1] ?? ''
|
||||
expect(where).toContain(`source = 'postgres_logs'`)
|
||||
// postgrest = postgrest_logs OR edge_logs with /rest/ path
|
||||
expect(where).toContain(`source = 'postgrest_logs'`)
|
||||
expect(where).toContain(`log_attributes['request.path'] LIKE '%/rest/%'`)
|
||||
expect(where).toContain(`source = 'edge_logs'`)
|
||||
expect(where).not.toContain(`source = 'postgrest_logs'`)
|
||||
})
|
||||
|
||||
it('routes the `postgrest` log type to postgrest_logs or edge_logs /rest/', () => {
|
||||
it('routes the `postgrest` log type solely to postgrest_logs (mutually exclusive from edge_logs)', () => {
|
||||
const sql = getUnifiedLogsQuery(withFilters('log_type:eq:postgrest'))
|
||||
const where = sql.split(/\bWHERE\b/)[1] ?? ''
|
||||
expect(where).toContain(`source = 'postgrest_logs'`)
|
||||
expect(where).toContain(
|
||||
`source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/rest/%'`
|
||||
)
|
||||
expect(where).not.toContain(`source = 'edge_logs'`)
|
||||
expect(where).not.toContain(`log_attributes['request.path'] LIKE '%/rest/%'`)
|
||||
})
|
||||
|
||||
it('routes the `storage` log type to storage_logs or edge_logs /storage/', () => {
|
||||
it('routes the `storage` log type solely to storage_logs (mutually exclusive from edge_logs)', () => {
|
||||
const sql = getUnifiedLogsQuery(withFilters('log_type:eq:storage'))
|
||||
const where = sql.split(/\bWHERE\b/)[1] ?? ''
|
||||
expect(where).toContain(`source = 'storage_logs'`)
|
||||
expect(where).toContain(
|
||||
`source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/storage/%'`
|
||||
)
|
||||
expect(where).not.toContain(`source = 'edge_logs'`)
|
||||
expect(where).not.toContain(`log_attributes['request.path'] LIKE '%/storage/%'`)
|
||||
})
|
||||
|
||||
it('escapes single quotes in filter values to prevent SQL injection', () => {
|
||||
@@ -127,6 +124,45 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
|
||||
expect(sql).not.toContain("event_message NOT LIKE 'connection received%'")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['edge_auth', '%/auth/%'],
|
||||
['edge_storage', '%/storage/%'],
|
||||
['edge_postgrest', '%/rest/%'],
|
||||
] as const)('excludes %s-pathed requests from edge_logs when %s=false', (key, pathFilter) => {
|
||||
const sql = getUnifiedLogsQuery({ ...baseSearch, [key]: false } as any)
|
||||
expect(sql).toContain("source != 'edge_logs'")
|
||||
expect(sql).toContain(`log_attributes['request.path'] NOT LIKE '${pathFilter}'`)
|
||||
})
|
||||
|
||||
it('does not filter edge_logs by service path by default (all edge_* toggles true)', () => {
|
||||
const sql = getUnifiedLogsQuery(baseSearch)
|
||||
expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/auth/%'")
|
||||
expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/storage/%'")
|
||||
expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/rest/%'")
|
||||
})
|
||||
|
||||
it('leaves dedicated auth_logs/storage_logs/postgrest_logs rows untouched by the edge_* toggles', () => {
|
||||
// These toggles only hide traffic nested inside the `edge_logs` (API
|
||||
// Gateway) source — the dedicated sources are separate log types now
|
||||
// that log types are mutually exclusive, so they shouldn't be scoped by
|
||||
// a `source != 'edge_logs' OR ...` guard meant for gateway rows.
|
||||
const sql = getUnifiedLogsQuery({
|
||||
...baseSearch,
|
||||
edge_auth: false,
|
||||
edge_storage: false,
|
||||
edge_postgrest: false,
|
||||
} as any)
|
||||
expect(sql).toContain(
|
||||
"(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/auth/%')"
|
||||
)
|
||||
expect(sql).toContain(
|
||||
"(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/storage/%')"
|
||||
)
|
||||
expect(sql).toContain(
|
||||
"(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/rest/%')"
|
||||
)
|
||||
})
|
||||
|
||||
it('does not emit subqueries or CTEs (rejected by the OTEL endpoint)', () => {
|
||||
const sql = getUnifiedLogsQuery(baseSearch)
|
||||
expect(sql).not.toMatch(/WITH\s+\w+\s+AS\s*\(/i)
|
||||
@@ -163,7 +199,8 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
|
||||
const sql = getLogsCountQuery(withFilters('log_type:eq:storage'))
|
||||
// Assert on the WHERE only: value expressions mention other sources inline.
|
||||
const totalWhere = whereOfBranchContaining(sql, `'all'`)
|
||||
expect(totalWhere).toContain(`source = 'edge_logs'`)
|
||||
expect(totalWhere).toContain(`source = 'storage_logs'`)
|
||||
expect(totalWhere).not.toContain(`source = 'edge_logs'`)
|
||||
expect(totalWhere).not.toContain(`source = 'postgres_logs'`)
|
||||
})
|
||||
|
||||
@@ -181,6 +218,15 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
|
||||
expect(scan).toContain("event_message NOT LIKE 'connection received%'")
|
||||
}
|
||||
})
|
||||
|
||||
it('applies the edge_* service filters to every count scan so badges match the list', () => {
|
||||
const sql = getLogsCountQuery({ ...baseSearch, edge_postgrest: false } as any)
|
||||
const scans = sql.split(/\bUNION ALL\b/)
|
||||
expect(scans.length).toBeGreaterThan(1)
|
||||
for (const scan of scans) {
|
||||
expect(scan).toContain("log_attributes['request.path'] NOT LIKE '%/rest/%'")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLogsChartQuery', () => {
|
||||
|
||||
@@ -55,9 +55,9 @@ const HTTP_STATUS_EXPR: SafeLogSqlFragment = safeSql`if(source = 'auth_logs', lo
|
||||
* logs; the UI surfaces gateway HTTP traffic for those buckets.
|
||||
*/
|
||||
const LOG_TYPE_CONDITION: Record<string, SafeLogSqlFragment> = {
|
||||
edge: safeSql`source = 'edge_logs' AND ${ATTR.path} NOT LIKE '%/rest/%' AND ${ATTR.path} NOT LIKE '%/storage/%'`,
|
||||
postgrest: safeSql`source = 'postgrest_logs' OR (source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%')`,
|
||||
storage: safeSql`source = 'storage_logs' OR (source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%')`,
|
||||
edge: safeSql`source = 'edge_logs'`,
|
||||
postgrest: safeSql`source = 'postgrest_logs'`,
|
||||
storage: safeSql`source = 'storage_logs'`,
|
||||
postgres: safeSql`source = 'postgres_logs'`,
|
||||
'edge function': safeSql`source = 'function_edge_logs'`,
|
||||
auth: safeSql`source = 'auth_logs'`,
|
||||
@@ -67,11 +67,11 @@ const LOG_TYPE_CONDITION: Record<string, SafeLogSqlFragment> = {
|
||||
}
|
||||
|
||||
// Derived `log_type` column for SELECT / GROUP BY / countIf use.
|
||||
// WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest'
|
||||
// WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%' THEN 'storage'
|
||||
const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE
|
||||
WHEN source = 'postgrest_logs' THEN 'postgrest'
|
||||
WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest'
|
||||
WHEN source = 'storage_logs' THEN 'storage'
|
||||
WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%' THEN 'storage'
|
||||
WHEN source = 'edge_logs' THEN 'edge'
|
||||
WHEN source = 'postgres_logs' THEN 'postgres'
|
||||
WHEN source = 'function_edge_logs' THEN 'edge function'
|
||||
@@ -294,26 +294,54 @@ const buildBaseWhere = (
|
||||
}
|
||||
}
|
||||
|
||||
const connFilter = connectionLogsFilter(search)
|
||||
if (connFilter) parts.push(connFilter)
|
||||
const searchParamsFilter = applySearchParamsFilter(search)
|
||||
if (searchParamsFilter) parts.push(searchParamsFilter)
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
// Path substrings that identify which downstream service an `edge_logs`
|
||||
// (API Gateway) row was routed to. Mirrors the convention already used by
|
||||
// the sibling Logs Explorer (Logs.constants.ts / Logs.utils.otel.ts) and by
|
||||
// ServiceFlow.sql.ts within this same feature.
|
||||
const EDGE_SERVICE_PATH_FILTER: Record<'edge_auth' | 'edge_storage' | 'edge_postgrest', string> = {
|
||||
edge_auth: '%/auth/%',
|
||||
edge_storage: '%/storage/%',
|
||||
edge_postgrest: '%/rest/%',
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a WHERE condition that excludes Postgres connection lifecycle messages.
|
||||
* Shared by every query via `buildBaseWhere`, so the row list, chart and sidebar
|
||||
* facet counts all hide connection logs together (otherwise the badges over-count
|
||||
* by the connection rows the list hides).
|
||||
* Returns view-option WHERE conditions — toggles from the filter sidebar that
|
||||
* hide a subset of rows without being a `filter` URL param (Postgres
|
||||
* connection lifecycle messages, and per-service traffic nested inside the
|
||||
* API Gateway `edge_logs` source). Shared by every query via `buildBaseWhere`,
|
||||
* so the row list, chart and sidebar facet counts stay in sync (otherwise the
|
||||
* badges over-count by the rows the list hides).
|
||||
*/
|
||||
const connectionLogsFilter = (search: QuerySearchParamsType): SafeLogSqlFragment | null => {
|
||||
const applySearchParamsFilter = (search: QuerySearchParamsType): SafeLogSqlFragment | null => {
|
||||
const conditions: SafeLogSqlFragment[] = []
|
||||
|
||||
// Visible by default — only an explicit `false` hides connection logs.
|
||||
if (search.show_connection_logs !== false) return null
|
||||
return safeSql`(source != 'postgres_logs' OR (
|
||||
event_message NOT LIKE 'connection received%' AND
|
||||
event_message NOT LIKE 'connection authenticated%' AND
|
||||
event_message NOT LIKE 'connection authorized%'
|
||||
))`
|
||||
if (search.show_connection_logs === false) {
|
||||
conditions.push(safeSql`(source != 'postgres_logs' OR (
|
||||
event_message NOT LIKE 'connection received%' AND
|
||||
event_message NOT LIKE 'connection authenticated%' AND
|
||||
event_message NOT LIKE 'connection authorized%'
|
||||
))`)
|
||||
}
|
||||
|
||||
// Visible by default — only an explicit `false` hides that service's
|
||||
// requests within the API Gateway log type.
|
||||
for (const key of ['edge_auth', 'edge_storage', 'edge_postgrest'] as const) {
|
||||
if (search[key] === false) {
|
||||
conditions.push(
|
||||
safeSql`(source != 'edge_logs' OR ${ATTR.path} NOT LIKE ${lit(EDGE_SERVICE_PATH_FILTER[key])})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return null
|
||||
return safeSql`(${joinSqlFragments(conditions, ' AND ')})`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useFlag } from 'common'
|
||||
import { FlaskConical, Loader2, ScrollText, User2 } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
@@ -43,7 +42,6 @@ export function UserDropdown({
|
||||
const { theme, setTheme } = useTheme()
|
||||
const appStateSnapshot = useAppStateSnapshot()
|
||||
const profileShowEmailEnabled = useIsFeatureEnabled('profile:show_email')
|
||||
const timezonePickerEnabled = useFlag('timezonePicker')
|
||||
const { username, avatarUrl, primaryEmail, isLoading } = useProfileNameAndPicture()
|
||||
|
||||
const { toggleFeaturePreviewModal } = useFeaturePreviewModal()
|
||||
@@ -106,7 +104,9 @@ export function UserDropdown({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem className="flex gap-2 cursor-pointer" asChild>
|
||||
<Link
|
||||
@@ -142,6 +142,7 @@ export function UserDropdown({
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
@@ -161,14 +162,13 @@ export function UserDropdown({
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
{timezonePickerEnabled && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<TimezoneDropdown />
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<TimezoneDropdown />
|
||||
</DropdownMenuGroup>
|
||||
|
||||
{showUpgradeCta && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -23,8 +23,9 @@ import { useTrack } from '@/lib/telemetry/track'
|
||||
const AUTO_OPTION_VALUE = '__auto__'
|
||||
|
||||
export const TimezoneDropdown = () => {
|
||||
const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
|
||||
const track = useTrack()
|
||||
const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone()
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// The "Auto detect" row always advertises the browser's own timezone, even
|
||||
|
||||
@@ -1,53 +1,35 @@
|
||||
import { useParams } from 'common'
|
||||
import { ArrowUpRight } from 'lucide-react'
|
||||
|
||||
import {
|
||||
useIsColumnLevelPrivilegesEnabled,
|
||||
useIsMarketplaceEnabled,
|
||||
} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useIsColumnLevelPrivilegesEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha'
|
||||
import type {
|
||||
ProductMenuGroup,
|
||||
ProductMenuGroupItem,
|
||||
} from '@/components/ui/ProductMenu/ProductMenu.types'
|
||||
import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query'
|
||||
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
|
||||
const ExternalLinkIcon = <ArrowUpRight strokeWidth={1} className="h-4 w-4" />
|
||||
|
||||
export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const {
|
||||
databaseReplication: showPgReplicate,
|
||||
databaseRoles: showRoles,
|
||||
integrationsWrappers: showWrappers,
|
||||
} = useIsFeatureEnabled(['database:replication', 'database:roles', 'integrations:wrappers'])
|
||||
const { databaseReplication: showPgReplicate, databaseRoles: showRoles } = useIsFeatureEnabled([
|
||||
'database:replication',
|
||||
'database:roles',
|
||||
'integrations:wrappers',
|
||||
])
|
||||
|
||||
const { data } = useDatabaseExtensionsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef: project?.ref })
|
||||
|
||||
const pgNetExtensionExists = (data ?? []).some((ext) => ext.name === 'pg_net')
|
||||
const pitrEnabled = addons?.selected_addons.some((addon) => addon.type === 'pitr') ?? false
|
||||
const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled()
|
||||
const enablePgReplicate = useIsETLPrivateAlpha()
|
||||
|
||||
const getDatabaseURL = (path: string) => `/project/${ref}/database/${path}`
|
||||
|
||||
// In the new marketplace revamped page the `category=wrapper` query param has
|
||||
// changed to `type=wrapper`. So fix this link below based on which version is
|
||||
// the user viewing.
|
||||
const isMarketplaceEnabled = useIsMarketplaceEnabled()
|
||||
const wrappersLinkParamName = isMarketplaceEnabled ? 'type' : 'category'
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Database Management',
|
||||
@@ -159,42 +141,7 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => {
|
||||
url: getDatabaseURL('migrations'),
|
||||
shortcutId: SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS,
|
||||
},
|
||||
showWrappers && {
|
||||
name: 'Wrappers',
|
||||
key: 'wrappers',
|
||||
url: `/project/${ref}/integrations?${wrappersLinkParamName}=wrapper`,
|
||||
rightIcon: ExternalLinkIcon,
|
||||
},
|
||||
pgNetExtensionExists && {
|
||||
name: 'Database Webhooks',
|
||||
key: 'hooks',
|
||||
url: `/project/${ref}/integrations/webhooks/overview`,
|
||||
rightIcon: ExternalLinkIcon,
|
||||
},
|
||||
].filter(Boolean) as ProductMenuGroupItem[],
|
||||
},
|
||||
{
|
||||
title: 'Tools',
|
||||
items: [
|
||||
{
|
||||
name: 'Security Advisor',
|
||||
key: 'security-advisor',
|
||||
url: `/project/${ref}/advisors/security`,
|
||||
rightIcon: ExternalLinkIcon,
|
||||
},
|
||||
{
|
||||
name: 'Performance Advisor',
|
||||
key: 'performance-advisor',
|
||||
url: `/project/${ref}/advisors/performance`,
|
||||
rightIcon: ExternalLinkIcon,
|
||||
},
|
||||
{
|
||||
name: 'Query Performance',
|
||||
key: 'query-performance',
|
||||
url: `/project/${ref}/observability/query-performance`,
|
||||
rightIcon: ExternalLinkIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Settings } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, Label, Switch } from 'ui'
|
||||
import { Label, Popover, PopoverContent, PopoverTrigger, Switch } from 'ui'
|
||||
|
||||
import { useChartHoverState } from './useChartHoverState'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
@@ -14,46 +14,43 @@ export const ReportSettings = ({ chartId }: ReportSettingsProps) => {
|
||||
const { syncHover, syncTooltip, setSyncHover, setSyncTooltip } = useChartHoverState(chartId)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<ButtonTooltip
|
||||
variant="default"
|
||||
icon={<Settings />}
|
||||
className="w-7"
|
||||
tooltip={{ content: { side: 'bottom', text: 'Report settings' } }}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="bottom" className="w-64 p-3">
|
||||
<div className="space-y-4">
|
||||
<Label htmlFor="sync-hover" className="text-sm font-normal">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
Sync chart headers
|
||||
<Switch id="sync-hover" checked={syncHover} onCheckedChange={setSyncHover} />
|
||||
</div>
|
||||
<p className="text-xs text-foreground-light mt-1">
|
||||
When enabled, hovering over any chart will update headers across all charts
|
||||
</p>
|
||||
</Label>
|
||||
|
||||
<Label htmlFor="sync-tooltips" className="text-sm font-normal flex flex-col">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
Sync tooltips
|
||||
<Switch
|
||||
id="sync-tooltips"
|
||||
checked={syncTooltip}
|
||||
disabled={!syncHover}
|
||||
onCheckedChange={setSyncTooltip}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-foreground-light mt-1">
|
||||
When enabled, also shows tooltips on all charts.{' '}
|
||||
<span className={cn(syncHover ? 'text-foreground-light' : 'text-foreground')}>
|
||||
Requires header sync.
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" side="bottom" className="w-64 p-3 flex flex-col gap-y-4">
|
||||
<div className="flex items-start justify-between space-x-2">
|
||||
<Label htmlFor="sync-hover" className="text-xs">
|
||||
<p>Sync chart headers</p>
|
||||
<p className="text-xs text-foreground-light mt-1 text-balance">
|
||||
Hovering over any chart will update headers across all charts
|
||||
</p>
|
||||
</Label>
|
||||
<Switch id="sync-hover" checked={syncHover} onCheckedChange={setSyncHover} />
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{syncHover && (
|
||||
<div className="flex items-start justify-between space-x-2">
|
||||
<Label htmlFor="sync-tooltips" className="text-xs">
|
||||
<p>Sync tooltips</p>
|
||||
<p className="text-xs text-foreground-light mt-1 text-balance">
|
||||
Shows tooltips on all charts
|
||||
</p>
|
||||
</Label>
|
||||
<Switch
|
||||
id="sync-tooltips"
|
||||
checked={syncTooltip}
|
||||
disabled={!syncHover}
|
||||
onCheckedChange={setSyncTooltip}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export function DataTableFilterCheckbox<TData>({
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${value}-${option.value}`}
|
||||
className="flex w-full items-center justify-between gap-2 text-foreground/70 group-hover:text-accent-foreground text-[0.8rem] min-w-0"
|
||||
className="relative flex w-full items-center justify-between gap-2 text-foreground/70 group-hover:text-accent-foreground text-[0.8rem] min-w-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
{Component ? (
|
||||
@@ -126,7 +126,11 @@ export function DataTableFilterCheckbox<TData>({
|
||||
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={() => toggleExpanded(optionKey)}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-foreground-lighter hover:bg-selection hover:text-foreground mr-2.5"
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-foreground-lighter',
|
||||
'hover:bg-selection hover:text-foreground',
|
||||
'absolute top-0 right-10'
|
||||
)}
|
||||
>
|
||||
{isExpanded ? <Minus size={12} /> : <Plus size={12} />}
|
||||
</button>
|
||||
@@ -148,15 +152,17 @@ export function DataTableFilterCheckbox<TData>({
|
||||
'rounded-md ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
||||
)}
|
||||
>
|
||||
<span className="px-2">only</span>
|
||||
<span className="pr-2">only</span>
|
||||
</button>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{hasNested &&
|
||||
isExpanded &&
|
||||
option.options?.map((optionNested, nestedIndex) => {
|
||||
const nestedChecked = getBooleanParam(optionNested.value)
|
||||
const isLastNested = nestedIndex === (option.options?.length ?? 0) - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={optionNested.value}
|
||||
|
||||
@@ -45,9 +45,17 @@ export const useBucketDeleteMutation = ({
|
||||
return useMutation<BucketDeleteData, ResponseError, BucketDeleteVariables>({
|
||||
mutationFn: (vars) => deleteBucket(vars),
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await queryClient.invalidateQueries({ queryKey: storageKeys.buckets(projectRef) })
|
||||
const { projectRef, id } = variables
|
||||
|
||||
const deletedBucketQueryKey = storageKeys.bucket(projectRef, id)
|
||||
await queryClient.cancelQueries({ queryKey: deletedBucketQueryKey })
|
||||
queryClient.removeQueries({ queryKey: deletedBucketQueryKey })
|
||||
|
||||
await onSuccess?.(data, variables, context)
|
||||
|
||||
// Fire-and-forget: only the bucket list needs refreshing, and it shouldn't block
|
||||
// onSuccess (modal close/navigation) above.
|
||||
void queryClient.invalidateQueries({ queryKey: storageKeys.bucketsList(projectRef) })
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
|
||||
@@ -227,6 +227,11 @@ export const useBucketInfoQueryPreferCached = (bucketId?: string, projectRef?: s
|
||||
|
||||
const shouldRetryBucketsQuery = (failureCount: number, error: unknown) => {
|
||||
if (error instanceof ResponseError) {
|
||||
// If the bucket doesn't exist or was deleted, don't retry — it will never succeed.
|
||||
if (error.code === 404) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
error.message.includes('Missing tenant config') ||
|
||||
error.message.includes('Project has no active API keys')
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+28
-37
@@ -1,53 +1,44 @@
|
||||
import { Source_Code_Pro } from 'next/font/google'
|
||||
import { Manrope } from 'next/font/google'
|
||||
import localFont from 'next/font/local'
|
||||
|
||||
export const customFont = localFont({
|
||||
variable: '--font-custom',
|
||||
export const manrope = Manrope({
|
||||
variable: '--font-manrope',
|
||||
display: 'swap',
|
||||
fallback: ['Circular', 'custom-font', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
subsets: ['latin'],
|
||||
})
|
||||
|
||||
export const inter = localFont({
|
||||
variable: '--font-inter',
|
||||
display: 'swap',
|
||||
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
|
||||
src: [
|
||||
{
|
||||
path: './CustomFont-Book.woff2',
|
||||
weight: '400',
|
||||
path: './inter/InterVariable.woff2',
|
||||
weight: '100 900',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-BookItalic.woff2',
|
||||
weight: '400',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-Medium.woff2',
|
||||
weight: '500',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-Bold.woff2',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-BoldItalic.woff2',
|
||||
weight: '700',
|
||||
style: 'italic',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-Black.woff2',
|
||||
weight: '800',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './CustomFont-BlackItalic.woff2',
|
||||
weight: '800',
|
||||
path: './inter/InterVariable-Italic.woff2',
|
||||
weight: '100 900',
|
||||
style: 'italic',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export const sourceCodePro = Source_Code_Pro({
|
||||
subsets: ['latin'],
|
||||
fallback: ['Source Code Pro', 'Office Code Pro', 'Menlo', 'monospace'],
|
||||
export const sourceCodePro = localFont({
|
||||
variable: '--font-source-code-pro',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
fallback: ['Source Code Pro', 'Office Code Pro', 'Menlo', 'monospace'],
|
||||
src: [
|
||||
{
|
||||
path: './source-code-pro/SourceCodePro-Variable.woff2',
|
||||
weight: '200 900',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: './source-code-pro/SourceCodePro-Variable-Italic.woff2',
|
||||
weight: '200 900',
|
||||
style: 'italic',
|
||||
},
|
||||
],
|
||||
})
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -52,7 +52,7 @@ import { GlobalErrorBoundaryState } from '@/components/ui/ErrorBoundary/GlobalEr
|
||||
import { GlobalShortcuts } from '@/components/ui/GlobalShortcuts/GlobalShortcuts'
|
||||
import { getCLIReleaseVersion } from '@/data/misc/cli-release-version-query'
|
||||
import { useRootQueryClient } from '@/data/query-client'
|
||||
import { customFont, sourceCodePro } from '@/fonts'
|
||||
import { inter, manrope, sourceCodePro } from '@/fonts'
|
||||
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { AuthProvider } from '@/lib/auth'
|
||||
@@ -186,7 +186,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
{/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `:root{--font-custom:${customFont.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
|
||||
__html: `:root{--font-sans:${inter.style.fontFamily};--font-heading:${manrope.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
|
||||
}}
|
||||
/>
|
||||
{/* Speed up initial API loading times by pre-connecting to the API domain */}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document'
|
||||
|
||||
import { inter, manrope, sourceCodePro } from '@/fonts'
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps(ctx: DocumentContext) {
|
||||
const initialProps = await Document.getInitialProps(ctx)
|
||||
@@ -11,7 +13,7 @@ class MyDocument extends Document {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<body className={`${inter.variable} ${manrope.variable} ${sourceCodePro.variable}`}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
|
||||
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user