Merge branch 'master' into dnywh/prototype/warehouse

This commit is contained in:
Danny White committed 2026-07-01 16:17:36 -06:00
commit cbe143fb00
156 files changed
+4671 -2580

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
apps/docs/public/.well-known/security.txt
+3 -1
View File
@@ -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" />
+3 -2
View File
@@ -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>
+15
View File
@@ -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'],
})
+1
View File
@@ -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",
+94 -30
View File
@@ -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 []
}
@@ -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'],
},
]
}
@@ -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'
@@ -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}
@@ -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>
}
@@ -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,
@@ -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[]
@@ -0,0 +1,6 @@
export const POLICY_MODAL_VIEWS = {
SELECTION: 'SELECTION',
TEMPLATES: 'TEMPLATES',
EDITOR: 'EDITOR',
REVIEW: 'REVIEW',
}
@@ -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
@@ -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,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 (
<>
@@ -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,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,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,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'
@@ -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
}
@@ -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
@@ -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'
@@ -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>
)}
</>
)}
@@ -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) {
@@ -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,
})
})
@@ -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 */
@@ -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
View File
@@ -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.
+2 -2
View File
@@ -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 */}
+3 -1
View File
@@ -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