diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 03b1acdff9463..f8828ca5959d8 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -12,7 +12,7 @@
/apps/www/public/images/blog @supabase/marketing
/apps/www/lib/redirects.js
-/docker/ @supabase/dev-workflows @aantti
+/docker/ @supabase/dev-workflows @supabase/self-hosted
/apps/studio/csp.js @supabase/security
/apps/studio/components/interfaces/Billing/Payment @supabase/security
diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx
index 2bb69907c83ec..553c807886f63 100644
--- a/apps/design-system/__registry__/index.tsx
+++ b/apps/design-system/__registry__/index.tsx
@@ -2480,6 +2480,50 @@ export const Index: Record = {
subcategory: "undefined",
chunks: []
},
+ "metric-card": {
+ name: "metric-card",
+ type: "components:example",
+ registryDependencies: undefined,
+ component: React.lazy(() => import("@/registry/default/example/metric-card")),
+ source: "",
+ files: ["registry/default/example/metric-card.tsx"],
+ category: "undefined",
+ subcategory: "undefined",
+ chunks: []
+ },
+ "metric-card-minimal": {
+ name: "metric-card-minimal",
+ type: "components:example",
+ registryDependencies: undefined,
+ component: React.lazy(() => import("@/registry/default/example/metric-card-minimal")),
+ source: "",
+ files: ["registry/default/example/metric-card-minimal.tsx"],
+ category: "undefined",
+ subcategory: "undefined",
+ chunks: []
+ },
+ "metric-card-minimal-horizontal": {
+ name: "metric-card-minimal-horizontal",
+ type: "components:example",
+ registryDependencies: undefined,
+ component: React.lazy(() => import("@/registry/default/example/metric-card-minimal-horizontal")),
+ source: "",
+ files: ["registry/default/example/metric-card-minimal-horizontal.tsx"],
+ category: "undefined",
+ subcategory: "undefined",
+ chunks: []
+ },
+ "metric-card-with-icon-link-tooltip": {
+ name: "metric-card-with-icon-link-tooltip",
+ type: "components:example",
+ registryDependencies: undefined,
+ component: React.lazy(() => import("@/registry/default/example/metric-card-with-icon-link-tooltip")),
+ source: "",
+ files: ["registry/default/example/metric-card-with-icon-link-tooltip.tsx"],
+ category: "undefined",
+ subcategory: "undefined",
+ chunks: []
+ },
"chart-area-axes": {
name: "chart-area-axes",
type: "components:block",
diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts
index 775c8817e8e63..c517b8a066c76 100644
--- a/apps/design-system/config/docs.ts
+++ b/apps/design-system/config/docs.ts
@@ -140,6 +140,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/logs-bar-chart',
items: [],
},
+ {
+ title: 'Metric Card',
+ href: '/docs/fragments/metric-card',
+ items: [],
+ },
{
title: 'Table of Contents (TOC)',
href: '/docs/fragments/toc',
diff --git a/apps/design-system/content/docs/fragments/metric-card.mdx b/apps/design-system/content/docs/fragments/metric-card.mdx
new file mode 100644
index 0000000000000..e21bc5323485a
--- /dev/null
+++ b/apps/design-system/content/docs/fragments/metric-card.mdx
@@ -0,0 +1,21 @@
+---
+title: Metric Card
+description: A card to display pull out metrics at a glance.
+fragment: true
+---
+
+
+
+## Examples
+
+### Minimal
+
+
+
+### Minimal Horizontal
+
+
+
+### With Icon, Link and Tooltip
+
+
diff --git a/apps/design-system/registry/default/example/metric-card-minimal-horizontal.tsx b/apps/design-system/registry/default/example/metric-card-minimal-horizontal.tsx
new file mode 100644
index 0000000000000..cbb1452fcd5eb
--- /dev/null
+++ b/apps/design-system/registry/default/example/metric-card-minimal-horizontal.tsx
@@ -0,0 +1,49 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import {
+ MetricCard,
+ MetricCardHeader,
+ MetricCardLabel,
+ MetricCardContent,
+ MetricCardValue,
+ MetricCardDifferential,
+} from 'ui-patterns/MetricCard'
+
+export default function MetricsCardDemo() {
+ const [data, setData] = useState>([])
+
+ useEffect(() => {
+ const now = new Date()
+ setData(
+ Array.from({ length: 12 }, (_, i) => ({
+ value: Math.floor(4000 + i * 100 + (Math.random() * 2000 - 800)),
+ timestamp: new Date(now.getTime() - (11 - i) * 60 * 60 * 1000).toISOString(),
+ }))
+ )
+ }, [])
+
+ const averageValue = data.reduce((acc, curr) => acc + curr.value, 0) / data.length
+
+ const diff = data[data.length - 1]?.value - data[0]?.value || 0
+ const diffPercentage = (diff / averageValue) * 100
+
+ return (
+
+
+
+ Active Users
+
+
+
+ {averageValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+ 0 ? 'positive' : 'negative'}>
+ {diffPercentage > 0 ? '+' : '-'}
+ {Math.abs(diffPercentage).toFixed(1)}%
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/metric-card-minimal.tsx b/apps/design-system/registry/default/example/metric-card-minimal.tsx
new file mode 100644
index 0000000000000..0d38fb9ae9476
--- /dev/null
+++ b/apps/design-system/registry/default/example/metric-card-minimal.tsx
@@ -0,0 +1,49 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import {
+ MetricCard,
+ MetricCardHeader,
+ MetricCardLabel,
+ MetricCardContent,
+ MetricCardValue,
+ MetricCardDifferential,
+} from 'ui-patterns/MetricCard'
+
+export default function MetricsCardDemo() {
+ const [data, setData] = useState>([])
+
+ useEffect(() => {
+ const now = new Date()
+ setData(
+ Array.from({ length: 12 }, (_, i) => ({
+ value: Math.floor(4000 + i * 100 + (Math.random() * 2000 - 800)),
+ timestamp: new Date(now.getTime() - (11 - i) * 60 * 60 * 1000).toISOString(),
+ }))
+ )
+ }, [])
+
+ const averageValue = data.reduce((acc, curr) => acc + curr.value, 0) / data.length
+
+ const diff = data[data.length - 1]?.value - data[0]?.value || 0
+ const diffPercentage = (diff / averageValue) * 100
+
+ return (
+
+
+
+ Active Users
+
+
+
+ {averageValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+ 0 ? 'positive' : 'negative'}>
+ {diffPercentage > 0 ? '+' : '-'}
+ {Math.abs(diffPercentage).toFixed(1)}%
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/metric-card-with-icon-link-tooltip.tsx b/apps/design-system/registry/default/example/metric-card-with-icon-link-tooltip.tsx
new file mode 100644
index 0000000000000..072db7c70bdbc
--- /dev/null
+++ b/apps/design-system/registry/default/example/metric-card-with-icon-link-tooltip.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import {
+ MetricCard,
+ MetricCardHeader,
+ MetricCardIcon,
+ MetricCardLabel,
+ MetricCardContent,
+ MetricCardValue,
+ MetricCardDifferential,
+ MetricCardSparkline,
+} from 'ui-patterns/MetricCard'
+import { User2 } from 'lucide-react'
+
+export default function MetricsCardDemo() {
+ const [data, setData] = useState>([])
+
+ useEffect(() => {
+ const now = new Date()
+ setData(
+ Array.from({ length: 12 }, (_, i) => ({
+ value: Math.floor(4000 + i * 100 + (Math.random() * 2000 - 800)),
+ timestamp: new Date(now.getTime() - (11 - i) * 60 * 60 * 1000).toISOString(),
+ }))
+ )
+ }, [])
+
+ const averageValue = data.reduce((acc, curr) => acc + curr.value, 0) / data.length
+
+ const diff = data[data.length - 1]?.value - data[0]?.value || 0
+ const diffPercentage = (diff / averageValue) * 100
+
+ return (
+
+
+
+
+
+
+
+ Active Users
+
+
+
+
+ {averageValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+ 0 ? 'positive' : 'negative'}>
+ {diffPercentage > 0 ? '+' : '-'}
+ {Math.abs(diffPercentage).toFixed(1)}%
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/metric-card.tsx b/apps/design-system/registry/default/example/metric-card.tsx
new file mode 100644
index 0000000000000..fca2d8f781830
--- /dev/null
+++ b/apps/design-system/registry/default/example/metric-card.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import {
+ MetricCard,
+ MetricCardHeader,
+ MetricCardLabel,
+ MetricCardContent,
+ MetricCardValue,
+ MetricCardDifferential,
+ MetricCardSparkline,
+} from 'ui-patterns/MetricCard'
+
+export default function MetricsCardDemo() {
+ const [data, setData] = useState>([])
+
+ useEffect(() => {
+ const now = new Date()
+ setData(
+ Array.from({ length: 12 }, (_, i) => ({
+ value: Math.floor(4000 + i * 100 + (Math.random() * 2000 - 800)),
+ timestamp: new Date(now.getTime() - (11 - i) * 60 * 60 * 1000).toISOString(),
+ }))
+ )
+ }, [])
+
+ const averageValue = data.reduce((acc, curr) => acc + curr.value, 0) / data.length
+
+ const diff = data[data.length - 1]?.value - data[0]?.value || 0
+ const diffPercentage = (diff / averageValue) * 100
+
+ return (
+
+
+
+
+ Active Users
+
+
+
+
+ {averageValue.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+ 0 ? 'positive' : 'negative'}>
+ {diffPercentage > 0 ? '+' : '-'}
+ {Math.abs(diffPercentage).toFixed(1)}%
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts
index 1403f63039171..d33f7acbec645 100644
--- a/apps/design-system/registry/examples.ts
+++ b/apps/design-system/registry/examples.ts
@@ -1333,4 +1333,24 @@ export const examples: Registry = [
type: 'components:example',
files: ['example/empty-state-zero-items-data-grid.tsx'],
},
+ {
+ name: 'metric-card',
+ type: 'components:example',
+ files: ['example/metric-card.tsx'],
+ },
+ {
+ name: 'metric-card-minimal',
+ type: 'components:example',
+ files: ['example/metric-card-minimal.tsx'],
+ },
+ {
+ name: 'metric-card-minimal-horizontal',
+ type: 'components:example',
+ files: ['example/metric-card-minimal-horizontal.tsx'],
+ },
+ {
+ name: 'metric-card-with-icon-link-tooltip',
+ type: 'components:example',
+ files: ['example/metric-card-with-icon-link-tooltip.tsx'],
+ },
]
diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
index b00b3698b5dd5..6078c6ae7b5f4 100644
--- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
+++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
@@ -1408,6 +1408,10 @@ export const queues: NavMenuConstant = {
name: 'Consuming Messages with Edge Functions',
url: '/guides/queues/consuming-messages-with-edge-functions',
},
+ {
+ name: 'Expose Queues for local and self-hosted Supabase',
+ url: '/guides/queues/expose-self-hosted-queues',
+ },
],
},
{
diff --git a/apps/docs/content/_partials/uiLibCta.mdx b/apps/docs/content/_partials/uiLibCta.mdx
new file mode 100644
index 0000000000000..e5f61f46613b6
--- /dev/null
+++ b/apps/docs/content/_partials/uiLibCta.mdx
@@ -0,0 +1,7 @@
+
+
+UI components built on shadcn/ui that connect to Supabase via a single command.
+
+Explore Components
+
+
diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx
index 7df9374334b38..ba866f6c8a034 100644
--- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx
+++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx
@@ -35,7 +35,7 @@ hideToc: true
- [TypeScript](https://www.typescriptlang.org/)
- [Tailwind CSS](https://tailwindcss.com/)
- [See GitHub repo](https://github.com/vercel/next.js/tree/canary/examples/with-supabase)
+ <$Partial path="uiLibCta.mdx" />
diff --git a/apps/docs/content/guides/auth/quickstarts/react.mdx b/apps/docs/content/guides/auth/quickstarts/react.mdx
index 2f1b94c74fd5b..243d2c0d8ab5b 100644
--- a/apps/docs/content/guides/auth/quickstarts/react.mdx
+++ b/apps/docs/content/guides/auth/quickstarts/react.mdx
@@ -29,17 +29,10 @@ hideToc: true
- Create a React app using [Vite](https://vitejs.dev/).
+ Select React from the [list of UI Library quickstarts](/ui/docs/getting-started/quickstart) and follow the instructions to create a new project.
-
-
- ```bash name=Terminal
- npm create vite@latest my-app -- --template react
- ```
-
-
@@ -64,6 +57,8 @@ hideToc: true
+ <$Partial path="uiLibCta.mdx" />
+
In `App.jsx`, create a Supabase client using your Project URL and key.
<$Partial path="api_settings_steps.mdx" />
diff --git a/apps/docs/content/guides/functions/regional-invocation.mdx b/apps/docs/content/guides/functions/regional-invocation.mdx
index b2bd30ddb14c8..dd59ecb023b94 100644
--- a/apps/docs/content/guides/functions/regional-invocation.mdx
+++ b/apps/docs/content/guides/functions/regional-invocation.mdx
@@ -79,6 +79,16 @@ You can verify the execution region by looking at the `x-sb-edge-region` HTTP he
---
+## Region runtime information
+
+Functions have access to the following environment variables:
+
+SB_REGION: The AWS region function was invoked
+
+This is useful if you have read replicate and want to Postgres connect to a different replicate according of the Region.
+
+---
+
## Region outages
When you explicitly specify a region via the `x-region` header, requests will NOT be automatically
diff --git a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx
index 64d58d9dee472..b2e7ac7367452 100644
--- a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx
+++ b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx
@@ -22,7 +22,9 @@ hideToc: true
- [TypeScript](https://www.typescriptlang.org/)
- [Tailwind CSS](https://tailwindcss.com/)
-
+ <$Partial path="uiLibCta.mdx" />
+
+
diff --git a/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx
index d9a6a20cd747b..4a0c47a1e2bdc 100644
--- a/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx
+++ b/apps/docs/content/guides/getting-started/quickstarts/nuxtjs.mdx
@@ -17,7 +17,9 @@ hideToc: true
- Create a Nuxt app using the `npx nuxi` command.
+ - Create a Nuxt app using the `npx nuxi` command.
+
+ <$Partial path="uiLibCta.mdx" />
diff --git a/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx
index d9bf9f3d13892..ffafeb5391d37 100644
--- a/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx
+++ b/apps/docs/content/guides/getting-started/quickstarts/reactjs.mdx
@@ -17,7 +17,9 @@ hideToc: true
- Create a React app using a [Vite](https://vitejs.dev/guide/) template.
+ - Create a React app using a [Vite](https://vitejs.dev/guide/) template.
+
+ <$Partial path="uiLibCta.mdx" />
diff --git a/apps/docs/content/guides/getting-started/quickstarts/vue.mdx b/apps/docs/content/guides/getting-started/quickstarts/vue.mdx
index 715acdd2eafb7..0c73a9f5f6504 100644
--- a/apps/docs/content/guides/getting-started/quickstarts/vue.mdx
+++ b/apps/docs/content/guides/getting-started/quickstarts/vue.mdx
@@ -17,7 +17,9 @@ hideToc: true
- Create a Vue app using the `npm init` command.
+ - Create a Vue app using the `npm init` command.
+
+ <$Partial path="uiLibCta.mdx" />
diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx
index 4fe57e4e3aa12..77ea987cadea0 100644
--- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx
+++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx
@@ -3,6 +3,7 @@ title: 'Build a User Management App with Next.js'
description: 'Learn how to use Supabase in your Next.js App.'
---
+<$Partial path="uiLibCta.mdx" />
<$Partial path="quickstart_intro.mdx" />

diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nuxt-3.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nuxt-3.mdx
index e9e2205e10523..d4d1c79344488 100644
--- a/apps/docs/content/guides/getting-started/tutorials/with-nuxt-3.mdx
+++ b/apps/docs/content/guides/getting-started/tutorials/with-nuxt-3.mdx
@@ -3,6 +3,7 @@ title: 'Build a User Management App with Nuxt 3'
description: 'Learn how to use Supabase in your Nuxt 3 App.'
---
+<$Partial path="uiLibCta.mdx" />
<$Partial path="quickstart_intro.mdx" />

diff --git a/apps/docs/content/guides/getting-started/tutorials/with-react.mdx b/apps/docs/content/guides/getting-started/tutorials/with-react.mdx
index 6710edba74bb2..a877837bfa8a4 100644
--- a/apps/docs/content/guides/getting-started/tutorials/with-react.mdx
+++ b/apps/docs/content/guides/getting-started/tutorials/with-react.mdx
@@ -3,6 +3,7 @@ title: 'Build a User Management App with React'
description: 'Learn how to use Supabase in your React App.'
---
+<$Partial path="uiLibCta.mdx" />
<$Partial path="quickstart_intro.mdx" />

diff --git a/apps/docs/content/guides/getting-started/tutorials/with-vue-3.mdx b/apps/docs/content/guides/getting-started/tutorials/with-vue-3.mdx
index 72d9408a27a26..1d5185fd9310c 100644
--- a/apps/docs/content/guides/getting-started/tutorials/with-vue-3.mdx
+++ b/apps/docs/content/guides/getting-started/tutorials/with-vue-3.mdx
@@ -3,6 +3,7 @@ title: 'Build a User Management App with Vue 3'
description: 'Learn how to use Supabase in your Vue 3 App.'
---
+<$Partial path="uiLibCta.mdx" />
<$Partial path="quickstart_intro.mdx" />

diff --git a/apps/docs/content/guides/queues/expose-self-hosted-queues.mdx b/apps/docs/content/guides/queues/expose-self-hosted-queues.mdx
new file mode 100644
index 0000000000000..dca20a998e054
--- /dev/null
+++ b/apps/docs/content/guides/queues/expose-self-hosted-queues.mdx
@@ -0,0 +1,57 @@
+---
+title: Expose Queues for local and self-hosted Supabase
+subtitle: Learn how to expose Queues when running Supabase with Supabase CLI or Docker Compose
+---
+
+By default, local and self-hosted Supabase instances expose only core schemas like public and graphql_public.
+To allow client-side consumers to use your queues, you have to add `pgmq_public` schema to the list of exposed schemas.
+
+Before continuing, complete the step [Expose queues to client-side consumers](/docs/guides/queues/quickstart#expose-queues-to-client-side-consumers) from the Queues Quickstart guide. This creates the `pgmq_public` schema, which must exist before it can be exposed through the API.
+
+
+
+You only need to expose the `pgmq_public` schema manually when running Supabase locally with the Supabase CLI or self-hosting using Docker Compose.
+
+
+
+## Expose Queues with Supabase CLI
+
+When running Supabase locally with Supabase CLI, update your project's `config.toml` file.
+Locate the `[api]` section and add `pgmq_public` to the list of schemas.
+
+```toml
+[api]
+enabled = true
+port = 54321
+schemas = ["public", "graphql_public", "pgmq_public"]
+```
+
+Then restart your local Supabase stack.
+
+```bash
+supabase stop && supabase start
+```
+
+## Expose queues with Docker compose
+
+When running Supabase with Docker Compose, locate the `PGRST_DB_SCHEMAS` variable inside your `.env` file and add `pgmq_public` to it. This environment variable is passed to the `rest` service inside `docker-compose.yml`.
+
+```
+PGRST_DB_SCHEMAS=public,graphql_public,pgmq_public
+```
+
+Restart your containers for the changes to take effect.
+
+```bash
+docker compose down
+docker compose up -d
+```
+
+## Stop exposing queues
+
+If you no longer want to expose the `pgmq_public` schema, you can remove it from your configuration.
+
+- For Supabase CLI, remove `pgmq_public` from the `[api]` schemas list in your `config.toml` file.
+- For Docker Compose, remove `pgmq_public` from the `PGRST_DB_SCHEMAS` variable in your `.env` file.
+
+After updating your configuration, restart your containers for the changes to take effect.
diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt
index d40b44530ef61..4db31f999f8ab 100644
--- a/apps/docs/public/humans.txt
+++ b/apps/docs/public/humans.txt
@@ -165,6 +165,7 @@ Wen Bo Xie
Yorvi Arias
Yuliya Marinova
Yuri Santana
+Zach Marinov
Zsolt Pazmandy
____________
diff --git a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx
index cd0cda84cca04..55d7a6d3c6b31 100644
--- a/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx
+++ b/apps/studio/components/interfaces/APIKeys/PublishableAPIKeys.tsx
@@ -1,4 +1,3 @@
-import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useMemo } from 'react'
import { InputVariants } from '@ui/components/shadcn/ui/input'
@@ -6,7 +5,6 @@ import { useParams } from 'common'
import CopyButton from 'components/ui/CopyButton'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
-import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
cn,
EyeOffIcon,
@@ -17,6 +15,7 @@ import {
TooltipTrigger,
WarningIcon,
} from 'ui'
+import { useApiKeysVisibility } from './hooks/useApiKeysVisibility'
// to add in later with follow up PR
// import CreatePublishableAPIKeyDialog from './CreatePublishableAPIKeyDialog'
@@ -25,22 +24,19 @@ import {
export const PublishableAPIKeys = () => {
const { ref: projectRef } = useParams()
+
+ const { canReadAPIKeys, isLoading: isLoadingVisibility } = useApiKeysVisibility()
const {
data: apiKeysData,
isLoading: isLoadingApiKeys,
error,
- } = useAPIKeysQuery({ projectRef, reveal: false })
+ } = useAPIKeysQuery({ projectRef, reveal: false }, { enabled: canReadAPIKeys })
const publishableApiKeys = useMemo(
() => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [],
[apiKeysData]
)
- const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_WRITE,
- '*'
- )
-
// The default publisahble key will always be the first one
const apiKey = publishableApiKeys[0]
@@ -63,7 +59,7 @@ export const PublishableAPIKeys = () => {
size="tiny"
type="default"
className="px-2 rounded-full"
- disabled={isPermissionsLoading || isLoadingApiKeys || !canReadAPIKeys}
+ disabled={isLoadingVisibility || isLoadingApiKeys || !canReadAPIKeys}
text={apiKey?.api_key}
/>
@@ -95,21 +91,17 @@ export const PublishableAPIKeys = () => {
const ApiKeyInput = () => {
const { ref: projectRef } = useParams()
+ const { canReadAPIKeys, isLoading: isPermissionsLoading } = useApiKeysVisibility()
const {
data: apiKeysData,
isLoading: isApiKeysLoading,
error,
- } = useAPIKeysQuery({ projectRef, reveal: false })
+ } = useAPIKeysQuery({ projectRef, reveal: false }, { enabled: canReadAPIKeys })
const publishableApiKeys = useMemo(
() => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [],
[apiKeysData]
)
-
- const { can: canReadAPIKeys, isLoading: isPermissionsLoading } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_WRITE,
- '*'
- )
// The default publisahble key will always be the first one
const apiKey = publishableApiKeys[0]
@@ -117,19 +109,19 @@ const ApiKeyInput = () => {
'flex-1 grow gap-1 rounded-full min-w-0 max-w-[200px] sm:max-w-[300px] md:max-w-[400px] lg:min-w-[24rem]'
const size = 'tiny'
- if (isApiKeysLoading || isPermissionsLoading) {
+ if (!canReadAPIKeys && !isPermissionsLoading) {
return (
-
-
+
+
+ You do not have permission to read API Key
)
}
- if (!canReadAPIKeys) {
+ if (isApiKeysLoading || isPermissionsLoading) {
return (
-
-
- You do not have permission to read API Key
+
+
)
}
diff --git a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx
index 9bd053a40ce7e..9497705b5e388 100644
--- a/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx
+++ b/apps/studio/components/interfaces/APIKeys/SecretAPIKeys.tsx
@@ -1,4 +1,3 @@
-import { PermissionAction } from '@supabase/shared-types/out/constants'
import dayjs from 'dayjs'
import { useMemo, useRef } from 'react'
@@ -7,7 +6,6 @@ import AlertError from 'components/ui/AlertError'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { APIKeysData, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import useLogsQuery from 'hooks/analytics/useLogsQuery'
-import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Card, EyeOffIcon } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import {
@@ -19,6 +17,7 @@ import {
} from 'ui/src/components/shadcn/ui/table'
import { APIKeyRow } from './APIKeyRow'
import CreateSecretAPIKeyDialog from './CreateSecretAPIKeyDialog'
+import { useApiKeysVisibility } from './hooks/useApiKeysVisibility'
interface LastSeenData {
[hash: string]: { timestamp: string }
@@ -51,17 +50,14 @@ function useLastSeen(projectRef: string): LastSeenData {
export const SecretAPIKeys = () => {
const { ref: projectRef } = useParams()
+
+ const { canReadAPIKeys, isLoading: isLoadingPermissions } = useApiKeysVisibility()
const {
data: apiKeysData,
error,
isLoading: isLoadingApiKeys,
isError: isErrorApiKeys,
- } = useAPIKeysQuery({ projectRef, reveal: false })
-
- const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_WRITE,
- '*'
- )
+ } = useAPIKeysQuery({ projectRef, reveal: false }, { enabled: canReadAPIKeys })
const lastSeen = useLastSeen(projectRef!)
@@ -83,9 +79,7 @@ export const SecretAPIKeys = () => {
actions={
}
/>
- {isLoadingApiKeys || isLoadingPermissions ? (
-
- ) : !canReadAPIKeys ? (
+ {!canReadAPIKeys && !isLoadingPermissions ? (
@@ -97,6 +91,8 @@ export const SecretAPIKeys = () => {
+ ) : isLoadingApiKeys || isLoadingPermissions ? (
+
) : isErrorApiKeys ? (
) : empty ? (
diff --git a/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts b/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts
index 7cedd8a9f1b44..accb9ac4a093f 100644
--- a/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts
+++ b/apps/studio/components/interfaces/APIKeys/hooks/useApiKeysVisibility.ts
@@ -19,12 +19,18 @@ interface ApiKeysVisibilityState {
*/
export function useApiKeysVisibility(): ApiKeysVisibilityState {
const { ref: projectRef } = useParams()
- const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.READ, 'api_keys')
+ const { can: canReadAPIKeys, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
+ PermissionAction.SECRETS_READ,
+ '*'
+ )
- const { data: apiKeysData, isLoading } = useAPIKeysQuery({
- projectRef,
- reveal: false,
- })
+ const { data: apiKeysData, isLoading: isLoadingApiKeys } = useAPIKeysQuery(
+ {
+ projectRef,
+ reveal: false,
+ },
+ { enabled: canReadAPIKeys }
+ )
const publishableApiKeys = useMemo(
() => apiKeysData?.filter(({ type }) => type === 'publishable') ?? [],
@@ -36,14 +42,14 @@ export function useApiKeysVisibility(): ApiKeysVisibilityState {
const hasApiKeys = publishableApiKeys.length > 0
// Can initialize API keys when in rollout, has permissions, not loading, and no API keys yet
- const canInitApiKeys = canReadAPIKeys && !isLoading && !hasApiKeys
+ const canInitApiKeys = canReadAPIKeys && !isLoadingApiKeys && !hasApiKeys
// Disable UI for publishable keys and secrets keys if flag is not enabled OR no API keys created yet
const shouldDisableUI = !hasApiKeys
return {
hasApiKeys,
- isLoading,
+ isLoading: isLoadingPermissions || (canReadAPIKeys && isLoadingApiKeys),
canReadAPIKeys,
canInitApiKeys,
shouldDisableUI,
diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx
index 10c0e5df99004..07ecb3652d182 100644
--- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx
+++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx
@@ -1,6 +1,7 @@
import { Key } from 'lucide-react'
import { useMemo } from 'react'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { Badge, copyToClipboard } from 'ui'
@@ -24,88 +25,92 @@ export function useApiKeysCommands() {
const { data: project } = useSelectedProjectQuery()
const ref = project?.ref || '_'
- const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref, reveal: true })
- const { anonKey, serviceKey, publishableKey, allSecretKeys } = getKeys(apiKeys)
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ { projectRef: project?.ref, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
+ const commands = useMemo(() => {
+ const { anonKey, serviceKey, publishableKey, allSecretKeys } = canReadAPIKeys
+ ? getKeys(apiKeys)
+ : {}
- const commands = useMemo(
- () =>
- [
- project &&
- anonKey && {
- id: 'anon-key',
- name: `Copy anonymous API key`,
- action: () => {
- copyToClipboard(anonKey.api_key ?? '')
- setIsOpen(false)
- },
- badge: () => (
-
- Project: {project?.name}
- Public
- {anonKey.type}
-
- ),
- icon: () =>
,
+ return [
+ project &&
+ anonKey && {
+ id: 'anon-key',
+ name: `Copy anonymous API key`,
+ action: () => {
+ copyToClipboard(anonKey.api_key ?? '')
+ setIsOpen(false)
},
- project &&
- serviceKey && {
- id: 'service-key',
- name: `Copy service API key`,
- action: () => {
- copyToClipboard(serviceKey.api_key ?? '')
- setIsOpen(false)
- },
- badge: () => (
-
- Project: {project?.name}
- Secret
- {serviceKey.type}
-
- ),
- icon: () =>
,
+ badge: () => (
+
+ Project: {project?.name}
+ Public
+ {anonKey.type}
+
+ ),
+ icon: () =>
,
+ },
+ project &&
+ serviceKey && {
+ id: 'service-key',
+ name: `Copy service API key`,
+ action: () => {
+ copyToClipboard(serviceKey.api_key ?? '')
+ setIsOpen(false)
+ },
+ badge: () => (
+
+ Project: {project?.name}
+ Secret
+ {serviceKey.type}
+
+ ),
+ icon: () =>
,
+ },
+ project &&
+ publishableKey && {
+ id: 'publishable-key',
+ name: `Copy publishable key`,
+ action: () => {
+ copyToClipboard(publishableKey.api_key ?? '')
+ setIsOpen(false)
},
- project &&
- publishableKey && {
- id: 'publishable-key',
- name: `Copy publishable key`,
+ badge: () => (
+
+ Project: {project?.name}
+ {publishableKey.type}
+
+ ),
+ icon: () =>
,
+ },
+ ...(project && allSecretKeys
+ ? allSecretKeys.map((key) => ({
+ id: key.id,
+ name: `Copy secret key (${key.name})`,
action: () => {
- copyToClipboard(publishableKey.api_key ?? '')
+ copyToClipboard(key.api_key ?? '')
setIsOpen(false)
},
badge: () => (
Project: {project?.name}
- {publishableKey.type}
+ {key.type}
),
icon: () =>
,
- },
- ...(project && allSecretKeys
- ? allSecretKeys.map((key) => ({
- id: key.id,
- name: `Copy secret key (${key.name})`,
- action: () => {
- copyToClipboard(key.api_key ?? '')
- setIsOpen(false)
- },
- badge: () => (
-
- Project: {project?.name}
- {key.type}
-
- ),
- icon: () =>
,
- }))
- : []),
- !(anonKey || serviceKey) && {
- id: 'api-keys-project-settings',
- name: 'See API keys in Project Settings',
- route: `/project/${ref}/settings/api`,
- icon: () =>
,
- },
- ].filter(Boolean) as ICommand[],
- [anonKey, serviceKey, project, setIsOpen]
- )
+ }))
+ : []),
+ !(anonKey || serviceKey) && {
+ id: 'api-keys-project-settings',
+ name: 'See API keys in Project Settings',
+ route: `/project/${ref}/settings/api-keys`,
+ icon: () =>
,
+ },
+ ].filter(Boolean) as ICommand[]
+ }, [apiKeys, canReadAPIKeys, project, ref, setIsOpen])
useRegisterPage(
API_KEYS_PAGE_NAME,
@@ -119,7 +124,7 @@ export function useApiKeysCommands() {
},
],
},
- { deps: [commands], enabled: !!project }
+ { deps: [commands], enabled: !!project && commands.length > 0 }
)
useRegisterCommands(
@@ -133,7 +138,7 @@ export function useApiKeysCommands() {
},
],
{
- enabled: !!project,
+ enabled: !!project && commands.length > 0,
orderSection: orderCommandSectionsByPriority,
sectionMeta: { priority: 3 },
}
diff --git a/apps/studio/components/interfaces/Connect/Connect.tsx b/apps/studio/components/interfaces/Connect/Connect.tsx
index 792661303b9d0..86c648b974607 100644
--- a/apps/studio/components/interfaces/Connect/Connect.tsx
+++ b/apps/studio/components/interfaces/Connect/Connect.tsx
@@ -217,7 +217,7 @@ export const Connect = () => {
return []
}
- const { data: apiKeys } = useAPIKeysQuery({ projectRef })
+ const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { anonKey, publishableKey } = canReadAPIKeys
? getKeys(apiKeys)
: { anonKey: null, publishableKey: null }
diff --git a/apps/studio/components/interfaces/Database/ETL/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/ETL/DestinationPanel/DestinationPanel.tsx
index 1cb9cd8f7c09c..c37832da73a18 100644
--- a/apps/studio/components/interfaces/Database/ETL/DestinationPanel/DestinationPanel.tsx
+++ b/apps/studio/components/interfaces/Database/ETL/DestinationPanel/DestinationPanel.tsx
@@ -44,6 +44,7 @@ import { AnalyticsBucketFields, BigQueryFields } from './DestinationPanelFields'
import { DestinationTypeSelection } from './DestinationTypeSelection'
import { NoDestinationsAvailable } from './NoDestinationsAvailable'
import { PublicationSelection } from './PublicationSelection'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
const formId = 'destination-editor'
@@ -126,7 +127,11 @@ export const DestinationPanel = ({
pipelineId: existingDestination?.pipelineId,
})
- const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ { projectRef, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
const { serviceKey } = getKeys(apiKeys)
const catalogToken = serviceKey?.api_key ?? ''
diff --git a/apps/studio/components/interfaces/Database/ETL/DestinationPanel/DestinationPanelFields.tsx b/apps/studio/components/interfaces/Database/ETL/DestinationPanel/DestinationPanelFields.tsx
index 4bba98af5c716..67810925baa1d 100644
--- a/apps/studio/components/interfaces/Database/ETL/DestinationPanel/DestinationPanelFields.tsx
+++ b/apps/studio/components/interfaces/Database/ETL/DestinationPanel/DestinationPanelFields.tsx
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { InlineLink } from 'components/ui/InlineLink'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
@@ -125,7 +126,11 @@ export const AnalyticsBucketFields = ({
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
- const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ { projectRef, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
const { serviceKey } = getKeys(apiKeys)
const serviceApiKey = serviceKey?.api_key ?? ''
diff --git a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx
index eedcb49b1cc72..b72c5594ea873 100644
--- a/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx
+++ b/apps/studio/components/interfaces/Database/Hooks/FormContents.tsx
@@ -3,6 +3,7 @@ import Image from 'next/legacy/image'
import { MutableRefObject, useEffect } from 'react'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
@@ -50,7 +51,11 @@ export const FormContents = ({
const restUrl = project?.restUrl
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
- const { data: keys = [] } = useAPIKeysQuery({ projectRef: ref, reveal: true })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: keys = [] } = useAPIKeysQuery(
+ { projectRef: ref, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
const { data: functions = [], isSuccess: isSuccessEdgeFunctions } = useEdgeFunctionsQuery({
projectRef: ref,
})
diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx
index fbf3f3e3dd136..d15c54b571e9c 100644
--- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx
+++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx
@@ -2,6 +2,7 @@ import { ChevronDown, Plus, X } from 'lucide-react'
import Link from 'next/link'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
@@ -51,7 +52,11 @@ const HTTPRequestFields = ({
const { data: selectedProject } = useSelectedProjectQuery()
const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
- const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref, reveal: true })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ { projectRef: ref, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
const edgeFunctions = functions ?? []
const { serviceKey, secretKey } = getKeys(apiKeys)
diff --git a/apps/studio/components/interfaces/Docs/Authentication.tsx b/apps/studio/components/interfaces/Docs/Authentication.tsx
index 3662e4572f5fa..43ec3fefbab8d 100644
--- a/apps/studio/components/interfaces/Docs/Authentication.tsx
+++ b/apps/studio/components/interfaces/Docs/Authentication.tsx
@@ -3,6 +3,7 @@ import Link from 'next/link'
import { useParams } from 'common'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
+import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility'
import CodeSnippet from './CodeSnippet'
import Snippets from './Snippets'
@@ -13,7 +14,8 @@ interface AuthenticationProps {
const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => {
const { ref: projectRef } = useParams()
- const { data: apiKeys } = useAPIKeysQuery({ projectRef })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { anonKey, serviceKey } = getKeys(apiKeys)
diff --git a/apps/studio/components/interfaces/Docs/LangSelector.tsx b/apps/studio/components/interfaces/Docs/LangSelector.tsx
index 5b8c76559103b..6a053ecfab57b 100644
--- a/apps/studio/components/interfaces/Docs/LangSelector.tsx
+++ b/apps/studio/components/interfaces/Docs/LangSelector.tsx
@@ -15,6 +15,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
+import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility'
const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' }
@@ -33,10 +34,14 @@ export const LangSelector = ({
}: LangSelectorProps) => {
const { ref: projectRef } = useParams()
- const { data: apiKeys = [], isLoading: isLoadingAPIKeys } = useAPIKeysQuery({
- projectRef,
- reveal: false,
- })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys = [], isLoading: isLoadingAPIKeys } = useAPIKeysQuery(
+ {
+ projectRef,
+ reveal: false,
+ },
+ { enabled: canReadAPIKeys }
+ )
const legacyKeys = useMemo(() => apiKeys.filter(({ type }) => type === 'legacy'), [apiKeys])
const publishableKeys = useMemo(
@@ -70,97 +75,101 @@ export const LangSelector = ({
>
Bash
- {selectedLang == 'bash' && !isLoadingAPIKeys && apiKeys && apiKeys.length > 0 && (
-
-
-
- Project API key:
-
-
-
-
- {selectedApiKey.name === 'hide' ? 'Hide keys' : selectedApiKey.name}
-
-
-
-
- setSelectedApiKey(DEFAULT_KEY)}
- >
- Hide keys
-
+ {selectedLang == 'bash' &&
+ canReadAPIKeys &&
+ !isLoadingAPIKeys &&
+ apiKeys &&
+ apiKeys.length > 0 && (
+
+
+
+ Project API key:
+
+
+
+
+ {selectedApiKey.name === 'hide' ? 'Hide keys' : selectedApiKey.name}
+
+
+
+
+ setSelectedApiKey(DEFAULT_KEY)}
+ >
+ Hide keys
+
- {publishableKeys.length > 0 && (
- <>
-
- Publishable keys
- {publishableKeys.map((key) => {
- const value = key.api_key
- return (
-
- setSelectedApiKey({
- name: `Publishable key: ${key.name}`,
- key: value,
- })
- }
- >
- {key.name}
-
- )
- })}
- >
- )}
+ {publishableKeys.length > 0 && (
+ <>
+
+ Publishable keys
+ {publishableKeys.map((key) => {
+ const value = key.api_key
+ return (
+
+ setSelectedApiKey({
+ name: `Publishable key: ${key.name}`,
+ key: value,
+ })
+ }
+ >
+ {key.name}
+
+ )
+ })}
+ >
+ )}
+
+ {secretKeys.length > 0 && (
+ <>
+
+ Secret keys
+ {secretKeys.map((key) => {
+ const value = key.prefix + '...'
+ return (
+
+ setSelectedApiKey({ name: `Secret key: ${key.name}`, key: value })
+ }
+ >
+ {key.name}
+
+ )
+ })}
+ >
+ )}
- {secretKeys.length > 0 && (
- <>
-
- Secret keys
- {secretKeys.map((key) => {
- const value = key.prefix + '...'
+
+
+
+ JWT-based legacy keys
+ {legacyKeys.map((key) => {
+ const value = key.api_key
return (
- setSelectedApiKey({ name: `Secret key: ${key.name}`, key: value })
+ setSelectedApiKey({ name: `Legacy key: ${key.name}`, key: value })
}
>
{key.name}
)
})}
- >
- )}
-
-
-
-
- JWT-based legacy keys
- {legacyKeys.map((key) => {
- const value = key.api_key
- return (
-
- setSelectedApiKey({ name: `Legacy key: ${key.name}`, key: value })
- }
- >
- {key.name}
-
- )
- })}
-
-
-
-
-
- )}
+
+
+
+
+
+ )}
)
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx
index e3ee1011ba7c2..f32bddfdc4776 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx
@@ -10,6 +10,7 @@ import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import AlertError from 'components/ui/AlertError'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
@@ -83,7 +84,13 @@ export const EdgeFunctionDetails = () => {
'*'
)
- const { data: apiKeys } = useAPIKeysQuery({ projectRef })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ {
+ projectRef,
+ },
+ { enabled: canReadAPIKeys }
+ )
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
const {
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx
index 11366aa59eaf4..06585eba6e0bd 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx
@@ -5,6 +5,7 @@ import { useFieldArray, useForm } from 'react-hook-form'
import * as z from 'zod'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query'
@@ -87,7 +88,8 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester
const [response, setResponse] = useState(null)
const [error, setError] = useState(null)
- const { data: apiKeys } = useAPIKeysQuery({ projectRef })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
diff --git a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx
index 3de5cd9e0b0f0..42d3abc5dcffb 100644
--- a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx
+++ b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx
@@ -16,6 +16,7 @@ import {
CollapsibleTrigger_Shadcn_,
Collapsible_Shadcn_,
} from 'ui'
+import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility'
import type { Commands } from './Functions.types'
interface TerminalInstructionsProps extends ComponentPropsWithoutRef {
@@ -32,7 +33,8 @@ export const TerminalInstructions = forwardRef<
const [showInstructions, setShowInstructions] = useState(!closable)
const { data: tokens } = useAccessTokensQuery()
- const { data: apiKeys } = useAPIKeysQuery({ projectRef })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx
index 60298fafa9d9d..49ca5fafef116 100644
--- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx
+++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx
@@ -1,15 +1,14 @@
-import { PermissionAction } from '@supabase/shared-types/out/constants'
import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
import { AlertCircle, Loader } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import Panel from 'components/ui/Panel'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
-import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { Input, SimpleCodeBlock } from 'ui'
@@ -56,10 +55,10 @@ export const APIKeys = () => {
isLoading: isProjectSettingsLoading,
} = useProjectSettingsV2Query({ projectRef })
- const { data: apiKeys } = useAPIKeysQuery({ projectRef })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery({ projectRef }, { enabled: canReadAPIKeys })
const { anonKey, serviceKey } = getKeys(apiKeys)
- // API keys should not be empty. However it can be populated with a delay on project creation
const isApiKeysEmpty = !anonKey && !serviceKey
const {
@@ -77,11 +76,6 @@ export const APIKeys = () => {
const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus
- const { can: canReadAPIKeys } = useAsyncCheckPermissions(
- PermissionAction.READ,
- 'service_api_keys'
- )
-
const isNotUpdatingJwtSecret =
jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated
diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx
index fb4cb9998131c..e1c3b04232205 100644
--- a/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx
+++ b/apps/studio/components/interfaces/Integrations/CronJobs/HttpHeaderFieldsSection.tsx
@@ -2,6 +2,7 @@ import { ChevronDown, Plus, Trash } from 'lucide-react'
import { useFieldArray } from 'react-hook-form'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import {
Button,
@@ -32,7 +33,11 @@ export const HTTPHeaderFieldsSection = ({ variant }: HTTPHeaderFieldsSectionProp
})
const { ref } = useParams()
- const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref, reveal: true })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ { projectRef: ref, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
const { serviceKey, secretKey } = getKeys(apiKeys)
const apiKey = secretKey?.api_key ?? serviceKey?.api_key ?? '[YOUR API KEY]'
diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx
index 2f5cd906fc9d9..303779f0a3ffe 100644
--- a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx
+++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx
@@ -5,6 +5,7 @@ import { useMemo } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import GraphiQL from 'components/interfaces/GraphQL/GraphiQL'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query'
@@ -21,7 +22,11 @@ export const GraphiQLTab = () => {
const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
- const { data: apiKeys, isFetched } = useAPIKeysQuery({ projectRef, reveal: true })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys, isFetched } = useAPIKeysQuery(
+ { projectRef, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
const { serviceKey, secretKey } = getKeys(apiKeys)
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx
index a50b127c2d59c..84aa3e82fb06c 100644
--- a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx
+++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx
@@ -31,10 +31,12 @@ import {
FormField_Shadcn_,
FormItem_Shadcn_,
Switch,
+ TextLink,
} from 'ui'
import { Admonition } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import { IS_PLATFORM } from 'lib/constants'
// [Joshen] Not convinced with the UI and layout but getting the functionality out first
@@ -86,18 +88,20 @@ export const QueuesSettings = () => {
const { mutateAsync: updateTable } = useTableUpdateMutation()
+ const onPostgrestConfigUpdateSuccess = () => {
+ if (enable) {
+ toast.success('Queues can now be managed through client libraries or PostgREST endpoints!')
+ } else {
+ toast.success(
+ 'Queues can no longer be managed through client libraries or PostgREST endpoints'
+ )
+ }
+ setIsToggling(false)
+ form.reset({ enable })
+ }
+
const { mutate: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation({
- onSuccess: () => {
- if (enable) {
- toast.success('Queues can now be managed through client libraries or PostgREST endpoints!')
- } else {
- toast.success(
- 'Queues can no longer be managed through client libraries or PostgREST endpoints'
- )
- }
- setIsToggling(false)
- form.reset({ enable })
- },
+ onSuccess: onPostgrestConfigUpdateSuccess,
onError: (error) => {
setIsToggling(false)
toast.error(`Failed to toggle queue exposure via PostgREST: ${error.message}`)
@@ -106,6 +110,7 @@ export const QueuesSettings = () => {
const { mutate: toggleExposeQueuePostgrest } = useDatabaseQueueToggleExposeMutation({
onSuccess: (_, values) => {
+ if (!IS_PLATFORM) return onPostgrestConfigUpdateSuccess()
if (project && config) {
if (values.enable) {
const updatedSchemas = schemas.concat([QUEUES_SCHEMA])
@@ -220,6 +225,20 @@ export const QueuesSettings = () => {
archive, and{' '}
delete
+ {!IS_PLATFORM ? (
+
+ When running Supabase locally with the CLI or self-hosting using
+ Docker Compose, you also need to update your configuration to expose
+ the {QUEUES_SCHEMA} schema.
+
+
+
+ ) : null}
>
}
>
diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx
index 9a4375be508d5..933e854a0f2c4 100644
--- a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx
+++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx
@@ -1,9 +1,10 @@
import { AnimatePresence } from 'framer-motion'
-import { RotateCw, Timer } from 'lucide-react'
+import { AlertCircle, RotateCw, Timer } from 'lucide-react'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useFlag, useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query'
import { useJWTSigningKeyDeleteMutation } from 'data/jwt-signing-keys/jwt-signing-key-delete-mutation'
@@ -57,14 +58,21 @@ export const JWTSecretKeysTable = () => {
const [selectedKeyToUpdate, setSelectedKeyToUpdate] = useState()
const [shownDialog, setShownDialog] = useState()
- const { data: signingKeys, isLoading: isLoadingSigningKeys } = useJWTSigningKeysQuery({
- projectRef,
- })
- const { data: legacyKey, isLoading: isLoadingLegacyKey } = useLegacyJWTSigningKeyQuery({
- projectRef,
- })
+ const { canReadAPIKeys, isLoading: isLoadingCanReadAPIKeys } = useApiKeysVisibility()
+ const { data: signingKeys, isLoading: isLoadingSigningKeys } = useJWTSigningKeysQuery(
+ {
+ projectRef,
+ },
+ { enabled: canReadAPIKeys }
+ )
+ const { data: legacyKey, isLoading: isLoadingLegacyKey } = useLegacyJWTSigningKeyQuery(
+ {
+ projectRef,
+ },
+ { enabled: canReadAPIKeys }
+ )
const { data: legacyAPIKeysStatus, isLoading: isLoadingLegacyAPIKeysStatus } =
- useLegacyAPIKeysStatusQuery({ projectRef })
+ useLegacyAPIKeysStatusQuery({ projectRef }, { enabled: canReadAPIKeys })
const { mutate: migrateJWTSecret, isLoading: isMigrating } = useLegacyJWTSigningKeyCreateMutation(
{
@@ -152,6 +160,20 @@ export const JWTSecretKeysTable = () => {
)
}
+ if (!canReadAPIKeys && !isLoadingCanReadAPIKeys) {
+ return (
+
+
+
+
+ You don't have permission to view JWT signing keys. These keys are restricted to users
+ with higher access levels.
+
+
+
+ )
+ }
+
if (isLoading) {
return
}
@@ -163,7 +185,7 @@ export const JWTSecretKeysTable = () => {
return (
<>
- {legacyKey ? (
+ {!canReadAPIKeys ? null : legacyKey ? (
<>
{standbyKey && (
{
const { mutateAsync: updateJwt, isLoading: isSubmittingJwtSecretUpdateRequest } =
useJwtSecretUpdateMutation()
- const { data: legacyKey } = useLegacyJWTSigningKeyQuery({
- projectRef,
- })
- const { data: legacyAPIKeysStatus } = useLegacyAPIKeysStatusQuery({ projectRef })
-
- const {
- data: authConfig,
- error: authConfigError,
- isLoading: isLoadingAuthConfig,
- isSuccess: isSuccessAuthConfig,
- } = useAuthConfigQuery({ projectRef })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: legacyKey } = useLegacyJWTSigningKeyQuery(
+ {
+ projectRef,
+ },
+ { enabled: canReadAPIKeys }
+ )
+ const { data: legacyAPIKeysStatus } = useLegacyAPIKeysStatusQuery(
+ { projectRef },
+ { enabled: canReadAPIKeys }
+ )
+
+ const { data: authConfig, isLoading: isLoadingAuthConfig } = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isLoading: isUpdatingAuthConfig } =
useAuthConfigUpdateMutation()
diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx
index cd12e9babf80f..0f17f7a6fbddd 100644
--- a/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx
+++ b/apps/studio/components/interfaces/ProjectAPIDocs/Content/Introduction.tsx
@@ -1,6 +1,7 @@
import { useParams } from 'common'
import { Button, Input, copyToClipboard } from 'ui'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
@@ -13,7 +14,8 @@ import type { ContentProps } from './Content.types'
export const Introduction = ({ showKeys, language, apikey, endpoint }: ContentProps) => {
const { ref } = useParams()
- const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery({ projectRef: ref }, { enabled: canReadAPIKeys })
const { data } = useProjectSettingsV2Query({ projectRef: ref })
const { data: org } = useSelectedOrganizationQuery()
const { mutate: sendEvent } = useSendEventMutation()
diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx
index ac99c9d48a282..f2b73315aeb24 100644
--- a/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx
+++ b/apps/studio/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.tsx
@@ -6,6 +6,7 @@ import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useAppStateSnapshot } from 'state/app-state'
+import { useApiKeysVisibility } from '../APIKeys/hooks/useApiKeysVisibility'
import { Bucket } from './Content/Bucket'
import { EdgeFunction } from './Content/EdgeFunction'
import { EdgeFunctions } from './Content/EdgeFunctions'
@@ -43,9 +44,10 @@ export const ProjectAPIDocs = () => {
const [showKeys, setShowKeys] = useState(false)
const language = snap.docsLanguage
+ const { canReadAPIKeys } = useApiKeysVisibility()
const { data: apiKeys } = useAPIKeysQuery(
{ projectRef: ref },
- { enabled: snap.showProjectApiDocs }
+ { enabled: snap.showProjectApiDocs && canReadAPIKeys }
)
const { data: settings } = useProjectSettingsV2Query(
{ projectRef: ref },
diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover.tsx
index 096529b7163dc..6e6e23c37230d 100644
--- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover.tsx
+++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover.tsx
@@ -2,6 +2,7 @@ import { Dispatch, SetStateAction, useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query'
@@ -23,10 +24,14 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens
const { data: org } = useSelectedOrganizationQuery()
const snap = useRoleImpersonationStateSnapshot()
- const { data: apiKeys } = useAPIKeysQuery({
- projectRef: config.projectRef,
- reveal: true,
- })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ {
+ projectRef: config.projectRef,
+ reveal: true,
+ },
+ { enabled: canReadAPIKeys }
+ )
const { anonKey, publishableKey } = getKeys(apiKeys)
const { data: postgrestConfig } = useProjectPostgrestConfigQuery(
diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx
index 68cbd5a1b59f2..db8302bf6011d 100644
--- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx
+++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx
@@ -8,6 +8,7 @@ import { toast } from 'sonner'
import z from 'zod'
import { useFlag, useParams } from 'common'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
@@ -156,7 +157,11 @@ export const ConnectTablesDialogContent = ({
const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef })
- const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ { projectRef, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
const { serviceKey } = getKeys(apiKeys)
const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({
diff --git a/apps/studio/components/ui/OrganizationProjectSelector.tsx b/apps/studio/components/ui/OrganizationProjectSelector.tsx
index 9fe5e3fd3ee56..2886dd2dce1f9 100644
--- a/apps/studio/components/ui/OrganizationProjectSelector.tsx
+++ b/apps/studio/components/ui/OrganizationProjectSelector.tsx
@@ -145,6 +145,7 @@ export const OrganizationProjectSelector = ({
)}
{
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isAppsWarningOpen, setIsAppsWarningOpen] = useState(false)
- const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } =
- useLegacyAPIKeysStatusQuery({ projectRef })
-
- const { data: legacyJWTSecret } = useLegacyJWTSigningKeyQuery({ projectRef })
-
+ const { canReadAPIKeys } = useApiKeysVisibility()
const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = useAsyncCheckPermissions(
PermissionAction.SECRETS_WRITE,
'*'
)
+ const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } =
+ useLegacyAPIKeysStatusQuery({ projectRef }, { enabled: canReadAPIKeys })
+
+ const { data: legacyJWTSecret } = useLegacyJWTSigningKeyQuery(
+ { projectRef },
+ { enabled: canReadAPIKeys }
+ )
+
const { data: authorizedApps = [], isSuccess: isAuthorizedAppsSuccess } = useAuthorizedAppsQuery({
slug: org?.slug,
})
diff --git a/apps/studio/data/api-keys/temp-api-keys-utils.test.ts b/apps/studio/data/api-keys/temp-api-keys-utils.test.ts
new file mode 100644
index 0000000000000..b4ab3d910e4e1
--- /dev/null
+++ b/apps/studio/data/api-keys/temp-api-keys-utils.test.ts
@@ -0,0 +1,298 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ createTemporaryUploadKey,
+ isTemporaryUploadKeyValid,
+ type TemporaryUploadKey,
+} from './temp-api-keys-utils'
+
+describe('createTemporaryUploadKey', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should create a temporary upload key with correct apiKey', () => {
+ const apiKey = 'test-api-key-123'
+ const expiryInSeconds = 3600
+
+ const result = createTemporaryUploadKey(apiKey, expiryInSeconds)
+
+ expect(result.apiKey).toBe(apiKey)
+ })
+
+ it('should create a temporary upload key with expiry time 1 hour in the future', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const apiKey = 'test-api-key-123'
+ const expiryInSeconds = 3600 // 1 hour
+
+ const result = createTemporaryUploadKey(apiKey, expiryInSeconds)
+
+ expect(result.expiryTime).toBe(now + 3600 * 1000)
+ })
+
+ it('should handle short expiry durations', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const apiKey = 'test-api-key-123'
+ const expiryInSeconds = 60 // 1 minute
+
+ const result = createTemporaryUploadKey(apiKey, expiryInSeconds)
+
+ expect(result.expiryTime).toBe(now + 60 * 1000)
+ })
+
+ it('should create keys with different expiry times when called at different times', () => {
+ const apiKey = 'test-api-key-123'
+ const expiryInSeconds = 3600
+
+ const now1 = 1000000
+ vi.setSystemTime(now1)
+ const result1 = createTemporaryUploadKey(apiKey, expiryInSeconds)
+
+ const now2 = 2000000
+ vi.setSystemTime(now2)
+ const result2 = createTemporaryUploadKey(apiKey, expiryInSeconds)
+
+ expect(result1.expiryTime).toBe(now1 + expiryInSeconds * 1000)
+ expect(result2.expiryTime).toBe(now2 + expiryInSeconds * 1000)
+ expect(result1.expiryTime).not.toBe(result2.expiryTime)
+ })
+})
+
+describe('isTemporaryUploadKeyValid', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should return false for null key', () => {
+ const result = isTemporaryUploadKeyValid(null)
+
+ expect(result).toBe(false)
+ })
+
+ it('should return false for undefined key', () => {
+ const result = isTemporaryUploadKeyValid(undefined)
+
+ expect(result).toBe(false)
+ })
+
+ it('should return true for a key with more than 60 seconds remaining', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key: TemporaryUploadKey = {
+ apiKey: 'test-key',
+ expiryTime: now + 120000, // 2 minutes from now
+ }
+
+ const result = isTemporaryUploadKeyValid(key)
+
+ expect(result).toBe(true)
+ })
+
+ it('should return false for a key with exactly 60 seconds remaining', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key: TemporaryUploadKey = {
+ apiKey: 'test-key',
+ expiryTime: now + 60000, // Exactly 60 seconds
+ }
+
+ const result = isTemporaryUploadKeyValid(key)
+
+ expect(result).toBe(false)
+ })
+
+ it('should return false for a key with less than 60 seconds remaining', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key: TemporaryUploadKey = {
+ apiKey: 'test-key',
+ expiryTime: now + 30000, // 30 seconds from now
+ }
+
+ const result = isTemporaryUploadKeyValid(key)
+
+ expect(result).toBe(false)
+ })
+
+ it('should return false for an expired key', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key: TemporaryUploadKey = {
+ apiKey: 'test-key',
+ expiryTime: now - 1000, // 1 second ago
+ }
+
+ const result = isTemporaryUploadKeyValid(key)
+
+ expect(result).toBe(false)
+ })
+
+ it('should return false for a key that expired long ago', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key: TemporaryUploadKey = {
+ apiKey: 'test-key',
+ expiryTime: now - 3600000, // 1 hour ago
+ }
+
+ const result = isTemporaryUploadKeyValid(key)
+
+ expect(result).toBe(false)
+ })
+
+ it('should return true for a key with exactly 61 seconds remaining', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key: TemporaryUploadKey = {
+ apiKey: 'test-key',
+ expiryTime: now + 61000, // 61 seconds from now
+ }
+
+ const result = isTemporaryUploadKeyValid(key)
+
+ expect(result).toBe(true)
+ })
+
+ it('should handle time advancing correctly', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key: TemporaryUploadKey = {
+ apiKey: 'test-key',
+ expiryTime: now + 120000, // 2 minutes from now
+ }
+
+ // Initially valid
+ expect(isTemporaryUploadKeyValid(key)).toBe(true)
+
+ // Advance time by 59 seconds (should still be valid - 61 seconds remaining)
+ vi.advanceTimersByTime(59000)
+ expect(isTemporaryUploadKeyValid(key)).toBe(true)
+
+ // Advance time by 2 more seconds (should be invalid - 59 seconds remaining)
+ vi.advanceTimersByTime(2000)
+ expect(isTemporaryUploadKeyValid(key)).toBe(false)
+ })
+
+ it('should return true for a key missing apiKey property', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key = {
+ expiryTime: now + 120000,
+ } as TemporaryUploadKey
+
+ const result = isTemporaryUploadKeyValid(key)
+
+ // While the key has expiryTime, it's missing apiKey, but since we're checking
+ // the structure, we expect it to still pass the time check if the expiryTime exists
+ // Actually, the function only checks time, not the apiKey presence
+ // Let's verify the actual behavior
+ expect(result).toBe(true)
+ })
+
+ it('should return false for a key missing expiryTime property', () => {
+ const key = {
+ apiKey: 'test-key',
+ } as TemporaryUploadKey
+
+ const result = isTemporaryUploadKeyValid(key)
+
+ // Without expiryTime, the calculation will be NaN and fail the > 60000 check
+ expect(result).toBe(false)
+ })
+})
+
+describe('integration: createTemporaryUploadKey and isTemporaryUploadKeyValid', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('should create a key that is immediately valid', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const key = createTemporaryUploadKey('test-api-key', 3600)
+
+ expect(isTemporaryUploadKeyValid(key)).toBe(true)
+ })
+
+ it('should create a key that becomes invalid after expiry time minus 60 seconds', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ const expiryInSeconds = 120 // 2 minutes
+ const key = createTemporaryUploadKey('test-api-key', expiryInSeconds)
+
+ // Initially valid
+ expect(isTemporaryUploadKeyValid(key)).toBe(true)
+
+ // Advance to 59 seconds before expiry (should still be valid - 61 seconds remaining)
+ vi.advanceTimersByTime((expiryInSeconds - 61) * 1000)
+ expect(isTemporaryUploadKeyValid(key)).toBe(true)
+
+ // Advance to 60 seconds before expiry (should be invalid - 60 seconds remaining)
+ vi.advanceTimersByTime(1000)
+ expect(isTemporaryUploadKeyValid(key)).toBe(false)
+ })
+
+ it('should handle very short expiry durations', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ // Create a key that expires in 30 seconds (less than the 60 second buffer)
+ const key = createTemporaryUploadKey('test-api-key', 30)
+
+ // Should be invalid immediately because it will expire in less than 60 seconds
+ expect(isTemporaryUploadKeyValid(key)).toBe(false)
+ })
+
+ it('should handle expiry duration of exactly 60 seconds', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ // Create a key that expires in exactly 60 seconds
+ const key = createTemporaryUploadKey('test-api-key', 60)
+
+ // Should be invalid because it has exactly 60 seconds remaining (not more than 60)
+ expect(isTemporaryUploadKeyValid(key)).toBe(false)
+ })
+
+ it('should handle expiry duration of 61 seconds', () => {
+ const now = Date.now()
+ vi.setSystemTime(now)
+
+ // Create a key that expires in 61 seconds
+ const key = createTemporaryUploadKey('test-api-key', 61)
+
+ // Should be valid because it has 61 seconds remaining (more than 60)
+ expect(isTemporaryUploadKeyValid(key)).toBe(true)
+
+ // Advance by 1 second
+ vi.advanceTimersByTime(1000)
+
+ // Should now be invalid because it has exactly 60 seconds remaining
+ expect(isTemporaryUploadKeyValid(key)).toBe(false)
+ })
+})
diff --git a/apps/studio/data/api-keys/temp-api-keys-utils.ts b/apps/studio/data/api-keys/temp-api-keys-utils.ts
new file mode 100644
index 0000000000000..f713d9a83a4d5
--- /dev/null
+++ b/apps/studio/data/api-keys/temp-api-keys-utils.ts
@@ -0,0 +1,24 @@
+export interface TemporaryUploadKey {
+ apiKey: string
+ expiryTime: number
+}
+
+export function createTemporaryUploadKey(
+ apiKey: string,
+ expiryInSeconds: number
+): TemporaryUploadKey {
+ return {
+ apiKey,
+ expiryTime: Date.now() + expiryInSeconds * 1000,
+ }
+}
+
+export function isTemporaryUploadKeyValid(
+ key: TemporaryUploadKey | null | undefined
+): key is TemporaryUploadKey {
+ if (!key) return false
+
+ const now = Date.now()
+ const timeRemaining = key.expiryTime - now
+ return timeRemaining > 60000 // More than 60 seconds remaining
+}
diff --git a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts
index d8703c1824180..15f0c37d46c5b 100644
--- a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts
+++ b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts
@@ -1,5 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { useApiKeysVisibility } from 'components/interfaces/APIKeys/hooks/useApiKeysVisibility'
import { WRAPPERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants'
import {
getAnalyticsBucketFDWName,
@@ -19,7 +20,11 @@ import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation'
export const useIcebergWrapperCreateMutation = () => {
const { data: project } = useSelectedProjectQuery()
- const { data: apiKeys } = useAPIKeysQuery({ projectRef: project?.ref, reveal: true })
+ const { canReadAPIKeys } = useApiKeysVisibility()
+ const { data: apiKeys } = useAPIKeysQuery(
+ { projectRef: project?.ref, reveal: true },
+ { enabled: canReadAPIKeys }
+ )
const { secretKey, serviceKey } = getKeys(apiKeys)
const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref })
diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx
index 751080cd58c23..080047058973b 100644
--- a/apps/studio/state/storage-explorer.tsx
+++ b/apps/studio/state/storage-explorer.tsx
@@ -37,6 +37,11 @@ import {
import { convertFromBytes } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils'
import { InlineLink } from 'components/ui/InlineLink'
import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query'
+import {
+ createTemporaryUploadKey,
+ isTemporaryUploadKeyValid,
+ type TemporaryUploadKey,
+} from 'data/api-keys/temp-api-keys-utils'
import { configKeys } from 'data/config/keys'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { ProjectStorageConfigResponse } from 'data/config/project-storage-config-query'
@@ -101,6 +106,9 @@ function createStorageExplorerState({
resumableUploadUrl,
uploadProgresses: [] as UploadProgress[],
+ // Temporary API key management for batch uploads
+ temporaryUploadKey: undefined as TemporaryUploadKey | undefined,
+
// abortController,
abortApiCalls: () => {
if (abortController) {
@@ -115,6 +123,29 @@ function createStorageExplorerState({
state.abortUploadCallbacks[toastId] = []
},
+ // Get or refresh the temporary upload key
+ getOrRefreshTemporaryUploadKey: async () => {
+ if (isTemporaryUploadKeyValid(state.temporaryUploadKey)) {
+ return state.temporaryUploadKey.apiKey
+ }
+
+ // Generate new key with 10 minutes expiry
+ const expiryInSeconds = 600
+ const data = await getTemporaryAPIKey({
+ projectRef: state.projectRef,
+ expiry: expiryInSeconds,
+ })
+
+ state.temporaryUploadKey = createTemporaryUploadKey(data.api_key, expiryInSeconds)
+
+ return data.api_key
+ },
+
+ // Clear the temporary upload key
+ clearTemporaryUploadKey: () => {
+ state.temporaryUploadKey = undefined
+ },
+
columns: [] as StorageColumn[],
popColumn: () => {
state.abortApiCalls()
@@ -1098,6 +1129,15 @@ function createStorageExplorerState({
.map((folder) => folder.name)
.join('/')
+ // Generate temporary API key for the batch upload
+ try {
+ await state.getOrRefreshTemporaryUploadKey()
+ } catch (error) {
+ console.error('Failed to get temporary API key:', error)
+ toast.error('Failed to initialize upload session. Please try again.')
+ return
+ }
+
const toastId = state.onUploadProgress()
// Upload files in batches
@@ -1196,10 +1236,12 @@ function createStorageExplorerState({
chunkSize,
onBeforeRequest: async (req) => {
try {
- const data = await getTemporaryAPIKey({ projectRef: state.projectRef })
- req.setHeader('apikey', data.api_key)
+ // Use the shared temporary key for batch uploads
+ // This checks if the key is still valid and refreshes if needed
+ const apiKey = await state.getOrRefreshTemporaryUploadKey()
+ req.setHeader('apikey', apiKey)
if (!IS_PLATFORM) {
- req.setHeader('Authorization', `Bearer ${data.api_key}`)
+ req.setHeader('Authorization', `Bearer ${apiKey}`)
}
} catch (error) {
throw error
@@ -1356,6 +1398,9 @@ function createStorageExplorerState({
closeButton: true,
duration: SONNER_DEFAULT_DURATION,
})
+ } finally {
+ // Clear the temporary API key after batch upload completes
+ state.clearTemporaryUploadKey()
}
const t2 = new Date()
diff --git a/docker/CHANGELOG.md b/docker/CHANGELOG.md
index 98986099606fe..2dca7e182ad6d 100644
--- a/docker/CHANGELOG.md
+++ b/docker/CHANGELOG.md
@@ -5,10 +5,16 @@ All notable changes to the Supabase self-hosted Docker configuration.
Changes are grouped by service rather than by change type. See [versions.md](./versions.md)
for complete image version history and rollback information.
+Check updates, changelogs, and release notes for each service to learn more.
+
+## Unreleased
+
## [2025-11-12]
### Studio
- Updated to `2025.11.10-sha-5291fe3` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083)
+- Added log drains - [PR #28297](https://github.com/supabase/supabase/pull/28297)
+- Fixed issue with Studio using `postgres` role instead of `supabase_admin` - [PR #39946](https://github.com/supabase/supabase/pull/39946)
### Auth
- Updated to `v2.182.1` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md#21821-2025-11-05) | [Release](https://github.com/supabase/auth/releases/tag/v2.182.1)
@@ -26,3 +32,41 @@ for complete image version history and rollback information.
- Updated to `2.7.4` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.4)
---
+
+## [2025-11-05]
+
+### Studio
+- Fixed issue with Studio failing to connect to Postgres with non-default settings - [PR #40169](https://github.com/supabase/supabase/pull/40169)
+
+### Realtime
+- Fixed issue with realtime logs not showing in Studio - [PR #39963](https://github.com/supabase/supabase/pull/39963)
+
+---
+
+## [2025-10-28]
+
+### Studio
+- Updated to `2025.10.27-sha-85b84e0` - [Dashboard updates](https://github.com/orgs/supabase/discussions/39709)
+- Fixed broken authentication when uploading files to Storage - [PR #39829](https://github.com/supabase/supabase/pull/39829)
+
+### Realtime
+- Updated to `v2.57.2` - [Release](https://github.com/supabase/realtime/releases/tag/v2.57.2)
+
+### Storage
+- Updated to `v1.28.2` - [Release](https://github.com/supabase/storage/releases/tag/v1.28.2)
+
+### Postgres Meta
+- Updated to `v0.93.1` - [Release](https://github.com/supabase/postgres-meta/releases/tag/v0.93.1)
+
+### Edge Runtime
+- Updated to `v1.69.15` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.15)
+
+---
+
+## [2025-10-27]
+
+### Studio
+- Added additional Kong configuration for MCP server routes - [PR #39849](https://github.com/supabase/supabase/pull/39849)
+- Added [documentation page](https://supabase.com/docs/guides/self-hosting/enable-mcp) describing MCP server configuration - [PR #39952](https://github.com/supabase/supabase/pull/39952)
+
+---
diff --git a/docker/README.md b/docker/README.md
index 9ab215b9027f4..70140af578763 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -1,3 +1,87 @@
-# Supabase Docker
+# Self-Hosted Supabase with Docker
-This is a minimal Docker Compose setup for self-hosting Supabase. Follow the steps [here](https://supabase.com/docs/guides/hosting/docker) to get started.
+This is the official Docker Compose setup for self-hosted Supabase. It provides a complete stack with all Supabase services running locally or on your infrastructure.
+
+## Getting Started
+
+Follow the detailed setup guide in our documentation: [Self-Hosting with Docker](https://supabase.com/docs/guides/self-hosting/docker)
+
+The guide covers:
+- Prerequisites (Git and Docker)
+- Initial setup and configuration
+- Securing your installation
+- Accessing services
+- Updating your instance
+
+## What's Included
+
+This Docker Compose configuration includes the following services:
+
+- **[Studio](https://github.com/supabase/supabase/tree/master/apps/studio)** - A dashboard for managing your self-hosted Supabase project
+- **[Kong](https://github.com/Kong/kong)** - Kong API gateway
+- **[GoTrue](https://github.com/supabase/auth)** - JWT-based authentication API for user sign-ups, logins, and session management
+- **[PostgREST](https://github.com/PostgREST/postgrest)** - Web server that turns your PostgreSQL database directly into a RESTful API
+- **[Realtime](https://github.com/supabase/realtime)** - Elixir server that listens to PostgreSQL database changes and broadcasts them over websockets
+- **[Storage](https://github.com/supabase/storage)** - RESTful API for managing files in S3, with Postgres handling permissions
+- **[ImgProxy](https://github.com/imgproxy/imgproxy)** - Fast and secure image processing server
+- **[postgres-meta](https://github.com/supabase/postgres-meta)** - RESTful API for managing Postgres (fetch tables, add roles, run queries)
+- **[PostgreSQL](https://github.com/supabase/postgres)** - Object-relational database with over 30 years of active development
+- **[Edge Runtime](https://github.com/supabase/edge-runtime)** - Web server based on Deno runtime for running JavaScript, TypeScript, and WASM services
+- **[Logflare](https://github.com/Logflare/logflare)** - Log management and event analytics platform
+- **[Vector](https://github.com/vectordotdev/vector)** - High-performance observability data pipeline for logs
+- **[Supavisor](https://github.com/supabase/supavisor)** - Supabase's Postgres connection pooler
+
+## Documentation
+
+- **[Documentation](https://supabase.com/docs/guides/self-hosting/docker)** - Setup and configuration guides
+- **[CHANGELOG.md](./CHANGELOG.md)** - Track recent updates and changes to services
+- **[versions.md](./versions.md)** - Complete history of Docker image versions for rollback reference
+
+## Updates
+
+To update your self-hosted Supabase instance:
+
+1. Review [CHANGELOG.md](./CHANGELOG.md) for breaking changes
+2. Check [versions.md](./versions.md) for new image versions
+3. Update `docker-compose.yml` if there are configuration changes
+4. Pull the latest images: `docker compose pull`
+5. Stop services: `docker compose down`
+6. Start services with new configuration: `docker compose up -d`
+
+**Note:** Consider to always backup your database before updating.
+
+## Community & Support
+
+For troubleshooting common issues, see:
+- [GitHub Discussions](https://github.com/orgs/supabase/discussions?discussions_q=is%3Aopen+label%3Aself-hosted) - Questions, feature requests, and workarounds
+- [GitHub Issues](https://github.com/supabase/supabase/issues?q=is%3Aissue%20state%3Aopen%20label%3Aself-hosted) - Known issues
+- [Documentation](https://supabase.com/docs/guides/self-hosting) - Setup and configuration guides
+
+Self-hosted Supabase is community-supported. Get help and connect with other users:
+
+- [Discord](https://discord.supabase.com) - Real-time chat and community support
+- [Reddit](https://www.reddit.com/r/Supabase/) - Community forum
+
+Share your self-hosting experience and read what's working for other users:
+
+- [GitHub Discussions](https://github.com/orgs/supabase/discussions/39820) - Self-hosting: What's working (and what's not)?)
+
+## Important Notes
+
+### Security
+
+⚠️ **The default configuration is not secure for production use.**
+
+Before deploying to production, you must:
+- Update all default passwords and secrets in the `.env` file
+- Generate new JWT secrets
+- Review and update CORS settings
+- Consider setting up a secure proxy in front of self-hosted Supabase
+- Review and adjust network security configuration (ACLs, etc.)
+- Set up proper backup procedures
+
+See the [security section](https://supabase.com/docs/guides/self-hosting/docker#securing-your-services) in the documentation.
+
+## License
+
+This repository is licensed under the Apache 2.0 License. See the main [Supabase repository](https://github.com/supabase/supabase) for details.
diff --git a/e2e/studio/.env.local.example b/e2e/studio/.env.local.example
index 400bfc19a613b..578b8354cc2f5 100644
--- a/e2e/studio/.env.local.example
+++ b/e2e/studio/.env.local.example
@@ -1,8 +1,8 @@
# Copy and paste this file and rename it to .env.local
-STUDIO_URL=http://127.0.0.1:54323
-API_URL=http://127.0.0.1:54323
+STUDIO_URL=http://localhost:8082
+API_URL=http://127.0.0.1:54321
IS_PLATFORM=false
# Used to run e2e tests against vercel previews
diff --git a/e2e/studio/README.md b/e2e/studio/README.md
index b3d031623e458..c59cf163e9ccc 100644
--- a/e2e/studio/README.md
+++ b/e2e/studio/README.md
@@ -16,16 +16,6 @@ cd e2e/studio
pnpm exec playwright install
```
-### Run a local Supabase instance
-
-Make sure you have Supabase CLI installed
-
-```bash
-cd e2e/studio
-
-supabase start
-```
-
---
## Running the tests
diff --git a/e2e/studio/playwright.config.ts b/e2e/studio/playwright.config.ts
index eec7e9d939d68..dfb75c6ef29e6 100644
--- a/e2e/studio/playwright.config.ts
+++ b/e2e/studio/playwright.config.ts
@@ -3,10 +3,20 @@ import { env, STORAGE_STATE_PATH } from './env.config'
import dotenv from 'dotenv'
import path from 'path'
-dotenv.config({ path: path.resolve(__dirname, '.env.local') })
+dotenv.config({
+ path: path.resolve(__dirname, '.env.local'),
+})
const IS_CI = !!process.env.CI
+const webServerConfig = IS_CI
+ ? undefined
+ : {
+ command: 'pnpm -w run e2e:setup',
+ port: 8082,
+ timeout: 5 * 60 * 1000,
+ }
+
export default defineConfig({
timeout: 90 * 1000,
testDir: './features',
@@ -51,4 +61,5 @@ export default defineConfig({
['html', { open: 'never' }],
['json', { outputFile: 'test-results/test-results.json' }],
],
+ webServer: webServerConfig,
})
diff --git a/examples/edge-functions/supabase/functions/_shared/jwt/clerk.ts b/examples/edge-functions/supabase/functions/_shared/jwt/clerk.ts
new file mode 100644
index 0000000000000..8c170902010e1
--- /dev/null
+++ b/examples/edge-functions/supabase/functions/_shared/jwt/clerk.ts
@@ -0,0 +1,60 @@
+// Clerk as a third-party provider alongside Supabase Auth.
+// Use this template to validate tokens issued by Clerk integration
+import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts";
+
+// Obtain from https://clerk.com/setup/supabase
+// Must supply this value from function env
+const AUTH_THIRD_PARTY_CLERK_DOMAIN = Deno.env.get(
+ "AUTH_THIRD_PARTY_CLERK_DOMAIN",
+);
+
+export function getAuthToken(req: Request) {
+ const authHeader = req.headers.get("authorization");
+ if (!authHeader) {
+ throw new Error("Missing authorization header");
+ }
+ const [bearer, token] = authHeader.split(" ");
+ if (bearer !== "Bearer") {
+ throw new Error(`Auth header is not 'Bearer {token}'`);
+ }
+ return token;
+}
+
+async function verifyJWT(jwt: string): Promise {
+ try {
+ const JWK = jose.createRemoteJWKSet(
+ new URL(AUTH_THIRD_PARTY_CLERK_DOMAIN ?? ""),
+ );
+ await jose.jwtVerify(jwt, JWK, {
+ algorithms: ["RS256"],
+ });
+ } catch (err) {
+ console.error(err);
+ return false;
+ }
+ return true;
+}
+
+// Validates authorization header
+export async function AuthMiddleware(
+ req: Request,
+ next: (req: Request) => Promise,
+) {
+ if (req.method === "OPTIONS") return await next(req);
+
+ try {
+ const token = getAuthToken(req);
+ const isValidJWT = await verifyJWT(token);
+
+ if (isValidJWT) return await next(req);
+
+ return Response.json({ msg: "Invalid JWT" }, {
+ status: 401,
+ });
+ } catch (e) {
+ console.error(e);
+ return Response.json({ msg: e?.toString() }, {
+ status: 401,
+ });
+ }
+}
diff --git a/examples/edge-functions/supabase/functions/_shared/jwt/default.ts b/examples/edge-functions/supabase/functions/_shared/jwt/default.ts
new file mode 100644
index 0000000000000..a7b8df5e3c874
--- /dev/null
+++ b/examples/edge-functions/supabase/functions/_shared/jwt/default.ts
@@ -0,0 +1,55 @@
+// Default supabase JWT verification
+// Use this template to validate tokens issued by Supabase default auth
+
+import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts";
+
+// Automatically supplied by Supabase
+const JWT_SECRET = Deno.env.get("JWT_SECRET");
+
+export function getAuthToken(req: Request) {
+ const authHeader = req.headers.get("authorization");
+ if (!authHeader) {
+ throw new Error("Missing authorization header");
+ }
+ const [bearer, token] = authHeader.split(" ");
+ if (bearer !== "Bearer") {
+ throw new Error(`Auth header is not 'Bearer {token}'`);
+ }
+ return token;
+}
+
+async function verifyJWT(jwt: string): Promise {
+ const encoder = new TextEncoder();
+ const secretKey = encoder.encode(JWT_SECRET);
+ try {
+ await jose.jwtVerify(jwt, secretKey);
+ } catch (err) {
+ console.error(err);
+ return false;
+ }
+ return true;
+}
+
+// Validates authorization header
+export async function AuthMiddleware(
+ req: Request,
+ next: (req: Request) => Promise,
+) {
+ if (req.method === "OPTIONS") return await next(req);
+
+ try {
+ const token = getAuthToken(req);
+ const isValidJWT = await verifyJWT(token);
+
+ if (isValidJWT) return await next(req);
+
+ return Response.json({ msg: "Invalid JWT" }, {
+ status: 401,
+ });
+ } catch (e) {
+ console.error(e);
+ return Response.json({ msg: e?.toString() }, {
+ status: 401,
+ });
+ }
+}
diff --git a/examples/edge-functions/supabase/functions/custom-jwt-validation/README.md b/examples/edge-functions/supabase/functions/custom-jwt-validation/README.md
new file mode 100644
index 0000000000000..02af65755f7bc
--- /dev/null
+++ b/examples/edge-functions/supabase/functions/custom-jwt-validation/README.md
@@ -0,0 +1,21 @@
+# custom-jwt-validation
+
+This function exemplifies how to use a custom JWT validation.
+
+Since Supabase legacy JWT Secret will be deprecated, users that would like to verify JWT or integrate with a custom provider should implement it manually.
+
+> see [Upcoming changes to Supabase API Keys #29260](https://github.com/orgs/supabase/discussions/29260)
+
+To simplify this task, Supabase provides a collection of JWT validation examples
+that can be found at [`_shared/jwt/`](https://github.com/supabase/supabase/blob/main/examples/edge-functions/supabase/functions/_shared/jwt) folder.
+
+## Setup
+
+1. Copy/download the JWT template, then import and use it inside your edge function.
+
+```bash
+wget https://raw.githubusercontent.com/supabase/supabase/refs/heads/main/examples/edge-functions/supabase/functions/_shared/jwt/.ts
+```
+
+2. Add any required Environment-Variable to a `.env` file, see inside of the
+ respective `_shared/jwt/template.ts` file to find which variables is required.
diff --git a/examples/edge-functions/supabase/functions/custom-jwt-validation/index.ts b/examples/edge-functions/supabase/functions/custom-jwt-validation/index.ts
new file mode 100644
index 0000000000000..211c40292e26d
--- /dev/null
+++ b/examples/edge-functions/supabase/functions/custom-jwt-validation/index.ts
@@ -0,0 +1,19 @@
+// Using 'default' Supabase auth middleware
+// Change to a specific provider by importing like that:
+// import { AuthMiddleware } from "../_shared/jwt/clerk.ts";
+import { AuthMiddleware } from "../_shared/jwt/default.ts";
+
+interface reqPayload {
+ name: string;
+}
+
+Deno.serve((r) =>
+ AuthMiddleware(r, async (req) => {
+ const { name }: reqPayload = await req.json();
+ const data = {
+ message: `Hello ${name} from foo!`,
+ };
+
+ return Response.json(data);
+ })
+);
diff --git a/package.json b/package.json
index ab856099955ec..1ad7ff5124381 100644
--- a/package.json
+++ b/package.json
@@ -29,15 +29,8 @@
"test:ui-patterns": "turbo run test --filter=ui-patterns",
"test:studio": "turbo run test --filter=studio",
"test:studio:watch": "turbo run test --filter=studio -- watch",
+ "e2e:setup": "pnpm setup:cli && NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" pnpm run build:studio && NODE_ENV=test pnpm --prefix ./apps/studio start --port 8082",
"e2e": "pnpm --prefix e2e/studio run e2e",
- "e2e:dev-hosted": "pnpm --prefix e2e/studio run e2e:dev-hosted",
- "e2e:dev-selfhosted": "pnpm --prefix e2e/studio run e2e:dev-selfhosted",
- "e2e:selfhosted": "pnpm --prefix e2e/studio run e2e:selfhosted",
- "e2e:staging": "pnpm --prefix e2e/studio run e2e:staging",
- "e2e:prod": "pnpm --prefix e2e/studio run e2e:prod",
- "e2e:ci": "pnpm --prefix e2e/studio run e2e:ci",
- "e2e:supabase:start": "pnpm --prefix e2e/studio run supabase:start",
- "e2e:supabase:stop": "pnpm --prefix e2e/studio run supabase:stop",
"perf:kong": "ab -t 5 -c 20 -T application/json http://localhost:8000/",
"perf:meta": "ab -t 5 -c 20 -T application/json http://localhost:5555/tables",
"setup:cli": "supabase start -x studio && supabase status --output json > keys.json && node scripts/generateLocalEnv.js",
diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json
index fb2309218dbb4..e74c2cd3db7ef 100644
--- a/packages/ui-patterns/package.json
+++ b/packages/ui-patterns/package.json
@@ -601,6 +601,10 @@
"./types/assets.d": {
"import": "./src/types/assets.d.ts",
"types": "./src/types/assets.d.ts"
+ },
+ "./MetricCard": {
+ "import": "./src/MetricCard/index.tsx",
+ "types": "./src/MetricCard/index.tsx"
}
},
"dependencies": {
diff --git a/packages/ui-patterns/src/MetricCard/index.tsx b/packages/ui-patterns/src/MetricCard/index.tsx
new file mode 100644
index 0000000000000..653ec273a7284
--- /dev/null
+++ b/packages/ui-patterns/src/MetricCard/index.tsx
@@ -0,0 +1,269 @@
+'use client'
+
+import * as React from 'react'
+import { useContext } from 'react'
+import {
+ Button,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ Card,
+ CardTitle,
+ cn,
+ CardContent,
+ Skeleton,
+} from 'ui'
+import { ExternalLink, HelpCircle } from 'lucide-react'
+import Link from 'next/link'
+import {
+ AreaChart,
+ Area,
+ ResponsiveContainer,
+ Tooltip as RechartsTooltip,
+ TooltipProps as RechartsTooltipProps,
+} from 'recharts'
+import dayjs from 'dayjs'
+
+interface MetricCardContextValue {
+ isLoading?: boolean
+ isDisabled?: boolean
+}
+
+const MetricCardContext = React.createContext({
+ isLoading: false,
+ isDisabled: false,
+})
+
+const useMetricCard = () => {
+ return useContext(MetricCardContext)
+}
+
+interface MetricCardProps extends React.HTMLAttributes {
+ isLoading?: boolean
+ isDisabled?: boolean
+}
+
+const MetricCard = React.forwardRef(
+ ({ isLoading = false, isDisabled = false, className, children, ...props }, ref) => {
+ return (
+
+
+ {children}
+
+
+ )
+ }
+)
+MetricCard.displayName = 'MetricCard'
+
+interface MetricCardHeaderProps extends React.HTMLAttributes {
+ href?: string
+ children: React.ReactNode
+}
+
+const MetricCardHeader = React.forwardRef(
+ ({ className, href, children, ...props }, ref) => {
+ return (
+
+
{children}
+ {href && (
+
+
+
+ More information
+
+
+ )}
+
+ )
+ }
+)
+MetricCardHeader.displayName = 'MetricCardHeader'
+
+interface MetricCardContentProps extends React.HTMLAttributes {
+ orientation?: 'horizontal' | 'vertical'
+}
+
+const MetricCardContent = React.forwardRef(
+ ({ className, orientation = 'vertical', ...props }, ref) => (
+
+ )
+)
+MetricCardContent.displayName = 'MetricCardContent'
+
+const MetricCardIcon = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+MetricCardIcon.displayName = 'MetricCardIcon'
+
+interface MetricCardLabelProps extends React.HTMLAttributes {
+ tooltip?: string
+ children: React.ReactNode
+}
+
+const MetricCardLabel = React.forwardRef(
+ ({ className, tooltip, children, ...props }, ref) => {
+ return (
+
+ {children}
+ {tooltip && (
+
+
+
+
+ {tooltip}
+
+ )}
+
+ )
+ }
+)
+MetricCardLabel.displayName = 'MetricCardLabel'
+
+const MetricCardValue = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const { isLoading } = useMetricCard()
+
+ if (isLoading) {
+ return
+ }
+
+ return (
+
+ )
+ }
+)
+
+MetricCardValue.displayName = 'MetricCardValue'
+
+interface MetricCardDifferentialProps extends React.HTMLAttributes {
+ variant?: 'positive' | 'negative' | 'default'
+}
+
+const MetricCardDifferential = React.forwardRef(
+ ({ className, variant = 'default', ...props }, ref) => {
+ const { isLoading } = useMetricCard()
+
+ if (isLoading) {
+ return
+ }
+
+ return (
+
+ )
+ }
+)
+
+MetricCardDifferential.displayName = 'MetricCardDifferential'
+
+const SparklineTooltip = ({ active, payload, label }: RechartsTooltipProps) => {
+ if (!active || !payload || !payload.length) return null
+
+ const formatTimestamp = (timestamp: string) => {
+ const date = dayjs(timestamp)
+ const hour = date.hour()
+ const period = hour >= 12 ? 'pm' : 'am'
+ const displayHour = hour % 12 || 12
+
+ return `${date.format('MMM D')}, ${displayHour}${period}`
+ }
+
+ return (
+
+ {label && (
+
+ {formatTimestamp(payload[0].payload.timestamp)}
+
+ )}
+
{payload[0].value.toLocaleString(undefined, { maximumFractionDigits: 0 })}
+
+ )
+}
+interface MetricCardSparklineProps extends React.HTMLAttributes {
+ data?: Array<{ value: number; [key: string]: any }>
+ dataKey?: string
+ className?: string
+}
+
+const MetricCardSparkline = React.forwardRef(
+ ({ className, data, dataKey, ...props }, ref) => {
+ const { isLoading } = useMetricCard()
+ if (isLoading) {
+ return
+ }
+
+ if (!data || data.length === 0) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ } />
+
+
+
+
+ )
+ }
+)
+MetricCardSparkline.displayName = 'MetricCardSparkline'
+
+export {
+ MetricCard,
+ MetricCardHeader,
+ MetricCardIcon,
+ MetricCardLabel,
+ MetricCardContent,
+ MetricCardValue,
+ MetricCardDifferential,
+ MetricCardSparkline,
+ useMetricCard,
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 45d2f69d5d96c..c4efbb1bddc9c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9679,9 +9679,6 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
- '@types/ws@8.5.10':
- resolution: {integrity: sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==}
-
'@types/zxcvbn@4.4.2':
resolution: {integrity: sha512-T7SEL8b/eN7AEhHQ8oFt7c6Y+l3p8OpH7KwJIe+5oBOPLMMioPeMsUTB3huNgEnXhiittV8Ohdw21Jg8E/f70Q==}
@@ -13623,6 +13620,7 @@ packages:
intersection-observer@0.10.0:
resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==}
+ deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
@@ -22836,7 +22834,7 @@ snapshots:
'@graphql-tools/executor-legacy-ws': 1.1.17(graphql@16.11.0)
'@graphql-tools/utils': 10.8.6(graphql@16.11.0)
'@graphql-tools/wrap': 10.0.35(graphql@16.11.0)
- '@types/ws': 8.5.10
+ '@types/ws': 8.18.1
'@whatwg-node/fetch': 0.10.6
'@whatwg-node/promise-helpers': 1.3.1
graphql: 16.11.0
@@ -29758,10 +29756,6 @@ snapshots:
dependencies:
'@types/node': 22.13.14
- '@types/ws@8.5.10':
- dependencies:
- '@types/node': 22.13.14
-
'@types/zxcvbn@4.4.2': {}
'@typescript-eslint/eslint-plugin@8.34.1(@typescript-eslint/parser@7.2.0(eslint@9.37.0(jiti@2.5.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.2))(eslint@9.37.0(jiti@2.5.1)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@5.9.2)':