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. + + + + 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" /> ![Supabase User Management example](/docs/img/user-management-demo.png) 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" /> ![Supabase User Management example](/docs/img/user-management-demo.png) 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" /> ![Supabase User Management example](/docs/img/user-management-demo.png) 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" /> ![Supabase User Management example](/docs/img/user-management-demo.png) 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: -
- - - - - - - setSelectedApiKey(DEFAULT_KEY)} - > - Hide keys - + {selectedLang == 'bash' && + canReadAPIKeys && + !isLoadingAPIKeys && + apiKeys && + apiKeys.length > 0 && ( +
+
+ + Project API key: +
+ + + + + + + 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/