diff --git a/apps/docs/content/guides/ai/examples/headless-vector-search.mdx b/apps/docs/content/guides/ai/examples/headless-vector-search.mdx index fdf21d12cd57a..b158bb4cd5f43 100644 --- a/apps/docs/content/guides/ai/examples/headless-vector-search.mdx +++ b/apps/docs/content/guides/ai/examples/headless-vector-search.mdx @@ -84,7 +84,7 @@ const onSubmit = (e: Event) => { const query = new URLSearchParams({ query: inputRef.current!.value }) const projectUrl = `https://your-project-ref.supabase.co/functions/v1` - const queryURL = `${projectURL}/${query}` + const queryURL = `${projectUrl}/${query}` const eventSource = new EventSource(queryURL) eventSource.addEventListener("error", (err) => { diff --git a/apps/docs/content/guides/auth/auth-hooks/mfa-verification-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/mfa-verification-hook.mdx index bbaf4cc924bce..6e46c37860a8e 100644 --- a/apps/docs/content/guides/auth/auth-hooks/mfa-verification-hook.mdx +++ b/apps/docs/content/guides/auth/auth-hooks/mfa-verification-hook.mdx @@ -142,7 +142,7 @@ as $$ ( user_id, factor_id, - last_refreshed_at + last_failed_at ) values ( @@ -151,7 +151,7 @@ as $$ now() ) on conflict do update - set last_refreshed_at = now(); + set last_failed_at = now(); -- finally let Supabase Auth do the default behavior for a failed attempt return jsonb_build_object('decision', 'continue'); diff --git a/apps/docs/content/guides/database/debugging-performance.mdx b/apps/docs/content/guides/database/debugging-performance.mdx index a2f349d7547e2..f333c94bb566b 100644 --- a/apps/docs/content/guides/database/debugging-performance.mdx +++ b/apps/docs/content/guides/database/debugging-performance.mdx @@ -44,7 +44,7 @@ create table instruments ( name text ); -insert into books +insert into instruments (id, name) values (1, 'violin'), diff --git a/apps/docs/content/guides/database/drizzle.mdx b/apps/docs/content/guides/database/drizzle.mdx index 88a02df030ecf..852e0d356f4dd 100644 --- a/apps/docs/content/guides/database/drizzle.mdx +++ b/apps/docs/content/guides/database/drizzle.mdx @@ -81,8 +81,8 @@ If you plan on solely using Drizzle instead of the Supabase Data API (PostgREST) import postgres from 'postgres' let connectionString = process.env.DATABASE_URL - if (host.includes('postgres:postgres@supabase_db_')) { - const url = URL.parse(host)! + if (connectionString.includes('postgres:postgres@supabase_db_')) { + const url = URL.parse(connectionString)! url.hostname = url.hostname.split('_')[1] connectionString = url.href } diff --git a/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx b/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx index 17e8782a2340a..291f9c73a6f46 100644 --- a/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx +++ b/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx @@ -52,7 +52,7 @@ explain select * from book; (1 row) ``` -Now we can choose a `statement_cost_filter` value between the total cost for the single select (2.49) and the whole table select (135.0) so one statement will succeed and one will fail. +Now we can choose a `statement_cost_limit` value between the total cost for the single select (2.49) and the whole table select (135.0) so one statement will succeed and one will fail. {/* prettier-ignore */} ```sql diff --git a/apps/docs/content/guides/database/postgres/column-level-security.mdx b/apps/docs/content/guides/database/postgres/column-level-security.mdx index 4c4e6ffb12f39..d1e3ebbdb9335 100644 --- a/apps/docs/content/guides/database/postgres/column-level-security.mdx +++ b/apps/docs/content/guides/database/postgres/column-level-security.mdx @@ -134,7 +134,7 @@ supabase migration new create_posts_table user_id text, title text, content text, - created_at timestamptz default now() + created_at timestamptz default now(), updated_at timestamptz default now() ); diff --git a/apps/docs/content/guides/database/postgres/data-deletion.mdx b/apps/docs/content/guides/database/postgres/data-deletion.mdx index a8a0941bbdd8a..a27ac37074ce9 100644 --- a/apps/docs/content/guides/database/postgres/data-deletion.mdx +++ b/apps/docs/content/guides/database/postgres/data-deletion.mdx @@ -66,7 +66,7 @@ WHERE id IN ( This approach has the benefit of controlling when it runs, locking for a shorter period of time and minimising impact on other transactions. -If you know in advance that such large deletes will have to happen in the business cycle of your database, then you should seriously think about using (table parititioning)[/docs/guides/database/partitions] as a management tool. +If you know in advance that such large deletes will have to happen in the business cycle of your database, then you should seriously think about using [table partitioning](/docs/guides/database/partitions) as a management tool. ### Soft deletes diff --git a/apps/docs/content/guides/database/postgres/first-row-in-group.mdx b/apps/docs/content/guides/database/postgres/first-row-in-group.mdx index f94891a5c4a5e..b20a3eaa8b803 100644 --- a/apps/docs/content/guides/database/postgres/first-row-in-group.mdx +++ b/apps/docs/content/guides/database/postgres/first-row-in-group.mdx @@ -32,10 +32,9 @@ select distinct points from seasons -order BY - id, - points desc, - team; +order by + team, + points desc; ``` The important bits here are: diff --git a/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx index 7f6a4f66a687a..3e8a6c42fc357 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/redwoodjs.mdx @@ -145,7 +145,7 @@ hideToc: true Let's seed the database with a few instruments. - Update the file `scripts/seeds.ts` to contain the following code: + Update the file `scripts/seed.ts` to contain the following code: diff --git a/apps/docs/content/guides/getting-started/tutorials/with-flutter.mdx b/apps/docs/content/guides/getting-started/tutorials/with-flutter.mdx index 257064c748101..9c345a4d4dd45 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-flutter.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-flutter.mdx @@ -67,7 +67,7 @@ Add `CFBundleURLTypes` to enable deep linking: <$CodeTabs> -```xml name=ios/Runner/Info.plist" +```xml name=ios/Runner/Info.plist @@ -314,7 +314,7 @@ Let's create a new widget called `account_page.dart` for that. <$CodeTabs> -```dart name=lib/pages/account_page.dart" +```dart name=lib/pages/account_page.dart import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_quickstart/main.dart'; diff --git a/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx b/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx index 5246f956484f6..017961afb7c00 100644 --- a/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx +++ b/apps/docs/content/guides/local-development/cli/testing-and-linting.mdx @@ -5,7 +5,7 @@ description: 'Using the CLI to test your Supabase project.' subtitle: 'Using the CLI to test your Supabase project.' --- -The Supabase CLI provides a set of tools to help you test and lint your Postgres database and Edge` Functions. +The Supabase CLI provides a set of tools to help you test and lint your Postgres database and Edge Functions. ## Testing your database diff --git a/apps/docs/content/guides/telemetry/advanced-log-filtering.mdx b/apps/docs/content/guides/telemetry/advanced-log-filtering.mdx index 36dbb69b60c50..9b591e483af21 100644 --- a/apps/docs/content/guides/telemetry/advanced-log-filtering.mdx +++ b/apps/docs/content/guides/telemetry/advanced-log-filtering.mdx @@ -184,7 +184,7 @@ regexp_contains(event_message, '^connection') ```sql -- find only messages that ends with port=12345 -regexp_contains(event_message, '$port=12345') +regexp_contains(event_message, 'port=12345$') ``` ### Ignore case sensitivity: diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx index 7c0265a7a7e25..6f7670270f416 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectStepsSection.tsx @@ -190,6 +190,8 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS !ipv4Addon && (state.connectionMethod === 'direct' || (state.connectionMethod === 'transaction' && !state.useSharedPooler)) + const showSessionPoolerNotice = + deploymentMode.isPlatform && state.mode === 'direct' && state.connectionMethod === 'session' const showSelfHostedMcpNotice = deploymentMode.isSelfHosted && state.mode === 'mcp' @@ -200,8 +202,6 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS

Connect your app

- - {showIpv4AddonNotice && ( )} + {showSessionPoolerNotice && ( + + )} + {showSelfHostedMcpNotice && ( )} + +
{steps.map((step, index) => ( { }) }) +describe('buildConnectionStringWithPassword', () => { + test('returns the original string when input or password is empty', () => { + const uri = `postgresql://postgres:${PASSWORD_PLACEHOLDER}@localhost:5432/postgres` + + expect(buildConnectionStringWithPassword('', 'password')).toBe('') + expect(buildConnectionStringWithPassword(uri, '')).toBe(uri) + }) + + test('replaces every password placeholder with the encoded password', () => { + const uri = `postgresql://postgres:${PASSWORD_PLACEHOLDER}@localhost:5432/postgres?password=${PASSWORD_PLACEHOLDER}` + + expect(buildConnectionStringWithPassword(uri, 'p@ss/word#1')).toBe( + 'postgresql://postgres:p%40ss%2Fword%231@localhost:5432/postgres?password=p%40ss%2Fword%231' + ) + }) +}) + describe('resolveConnectionString', () => { const pooler = { transactionShared: 'tx-shared', diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts index 96c07fa8851c5..16c3e9004478b 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectionString.utils.ts @@ -92,6 +92,23 @@ export const buildSafeConnectionString = ( return `postgresql://${params.user}:${PASSWORD_PLACEHOLDER}@${params.host}:${params.port}/${params.database}${search}` } +export const buildConnectionStringWithPassword = ( + connectionString: string, + password: string +): string => { + if (!connectionString || !password) return connectionString + + const encodedPassword = (() => { + try { + return encodeURIComponent(password) + } catch { + return password + } + })() + + return connectionString.split(PASSWORD_PLACEHOLDER).join(encodedPassword) +} + export const buildConnectionParameters = (params: ConnectionParams) => [ { key: 'host', value: params.host }, { key: 'port', value: params.port }, diff --git a/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.test.ts b/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.test.ts new file mode 100644 index 0000000000000..49f7bacfa52ab --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest' + +import { buildConnectPrompt } from './CopyPromptAdmonition' + +describe('buildConnectPrompt', () => { + test('uses redacted copy values instead of temporarily visible passwords', () => { + const container = document.createElement('div') + container.innerHTML = ` +
+
+
+
postgresql://postgres:temporary-password@db.example.supabase.co:5432/postgres
+
+
+
+ ` + + const prompt = buildConnectPrompt(container) + + expect(prompt).toContain( + 'postgresql://postgres:[YOUR-PASSWORD]@db.example.supabase.co:5432/postgres' + ) + expect(prompt).not.toContain('temporary-password') + }) +}) diff --git a/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx b/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx index 9c70466cd7808..e4fb94dc9f083 100644 --- a/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.tsx @@ -8,117 +8,124 @@ interface CopyPromptAdmonitionProps { stepsContainerRef: RefObject } -export function CopyPromptAdmonition({ stepsContainerRef }: CopyPromptAdmonitionProps) { - const normalizeTextLines = (value: string) => { - return value - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .join('\n') - } - - const getStepTextContent = (contentElement: HTMLElement) => { - const clone = contentElement.cloneNode(true) as HTMLElement - clone - .querySelectorAll('pre, button, svg, input, textarea, select, [aria-hidden="true"]') - .forEach((element) => { - element.remove() - }) +const normalizeTextLines = (value: string) => { + return value + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') +} - clone.querySelectorAll('p, div').forEach((el) => { - el.appendChild(document.createTextNode('\n')) +const getStepTextContent = (contentElement: HTMLElement) => { + const clone = contentElement.cloneNode(true) as HTMLElement + clone + .querySelectorAll('pre, button, svg, input, textarea, select, [aria-hidden="true"]') + .forEach((element) => { + element.remove() }) - const text = clone.textContent ?? '' - return normalizeTextLines(text) + clone.querySelectorAll('p, div').forEach((el) => { + el.appendChild(document.createTextNode('\n')) + }) + + const text = clone.textContent ?? '' + return normalizeTextLines(text) +} + +const getStepCodeSnippets = (contentElement: HTMLElement) => { + const snippets: Array<{ label: string; snippet: string }> = [] + const seen = new Set() + + const addSnippet = (label: string, snippet: string) => { + if (!snippet || seen.has(snippet)) return + seen.add(snippet) + snippets.push({ label, snippet }) } - const getStepCodeSnippets = (contentElement: HTMLElement) => { - const snippets: Array<{ label: string; snippet: string }> = [] - const seen = new Set() + const getSnippet = (element: Element) => { + const copyValueElement = element.closest('[data-connect-copy-value]') as HTMLElement | null + return copyValueElement?.dataset.connectCopyValue?.trim() || element.textContent?.trim() + } - const addSnippet = (label: string, snippet: string) => { - if (!snippet || seen.has(snippet)) return - seen.add(snippet) - snippets.push({ label, snippet }) - } + const tabContents = Array.from( + contentElement.querySelectorAll('[data-connect-tab-content]') + ) as HTMLElement[] - const tabContents = Array.from( - contentElement.querySelectorAll('[data-connect-tab-content]') - ) as HTMLElement[] + tabContents.forEach((tabContent) => { + const label = tabContent.getAttribute('data-tab-label') || 'Code' + const tabSnippets = Array.from(tabContent.querySelectorAll('pre')) + .map(getSnippet) + .filter((snippet): snippet is string => Boolean(snippet)) - tabContents.forEach((tabContent) => { - const label = tabContent.getAttribute('data-tab-label') || 'Code' - const tabSnippets = Array.from(tabContent.querySelectorAll('pre')) - .map((pre) => pre.textContent?.trim()) + if (tabSnippets.length === 0) { + const inlineSnippets = Array.from(tabContent.querySelectorAll('code')) + .filter((code) => !code.closest('pre') && code.closest('.font-mono')) + .map((code) => code.textContent?.trim()) .filter((snippet): snippet is string => Boolean(snippet)) - - if (tabSnippets.length === 0) { - const inlineSnippets = Array.from(tabContent.querySelectorAll('code')) - .filter((code) => !code.closest('pre') && code.closest('.font-mono')) - .map((code) => code.textContent?.trim()) - .filter((snippet): snippet is string => Boolean(snippet)) - inlineSnippets.forEach((snippet, index) => { - const inlineLabel = inlineSnippets.length > 1 ? `${label} (part ${index + 1})` : label - addSnippet(inlineLabel, snippet) - }) - return - } - - tabSnippets.forEach((snippet, index) => { - const tabLabel = tabSnippets.length > 1 ? `${label} (part ${index + 1})` : label - addSnippet(tabLabel, snippet) + inlineSnippets.forEach((snippet, index) => { + const inlineLabel = inlineSnippets.length > 1 ? `${label} (part ${index + 1})` : label + addSnippet(inlineLabel, snippet) }) - }) + return + } - contentElement.querySelectorAll('pre').forEach((pre) => { - if (pre.closest('[data-connect-tab-content]')) return - const snippet = pre.textContent?.trim() - if (snippet) addSnippet('Code', snippet) + tabSnippets.forEach((snippet, index) => { + const tabLabel = tabSnippets.length > 1 ? `${label} (part ${index + 1})` : label + addSnippet(tabLabel, snippet) }) + }) + + contentElement.querySelectorAll('pre').forEach((pre) => { + if (pre.closest('[data-connect-tab-content]')) return + const snippet = getSnippet(pre) + if (snippet) addSnippet('Code', snippet) + }) + + contentElement.querySelectorAll('code').forEach((code) => { + if (code.closest('pre')) return + if (code.closest('[data-connect-tab-content]')) return + if (!code.closest('.font-mono')) return + const snippet = code.textContent?.trim() + if (snippet) addSnippet('Code', snippet) + }) + + return snippets +} - contentElement.querySelectorAll('code').forEach((code) => { - if (code.closest('pre')) return - if (code.closest('[data-connect-tab-content]')) return - if (!code.closest('.font-mono')) return - const snippet = code.textContent?.trim() - if (snippet) addSnippet('Code', snippet) +export const buildConnectPrompt = (stepsContainer: HTMLElement | null) => { + const stepElements = stepsContainer?.querySelectorAll('[data-connect-step]') + if (!stepElements?.length) return '' + + const promptContent = Array.from(stepElements) + .map((stepElement, index) => { + const title = stepElement.getAttribute('data-step-title') ?? `Step ${index + 1}` + const description = stepElement.getAttribute('data-step-description') ?? '' + const contentElement = stepElement.querySelector('[data-step-content]') as HTMLElement | null + + const details = contentElement ? getStepTextContent(contentElement) : '' + const codeSnippets = contentElement ? getStepCodeSnippets(contentElement) : [] + + const sections = [ + `${index + 1}. ${title}`, + description, + details ? `Details:\n${details}` : null, + codeSnippets.length + ? `Code:\n${codeSnippets + .map(({ label, snippet }) => `File: ${label}\n\`\`\`\n${snippet}\n\`\`\``) + .join('\n\n')}` + : null, + ].filter(Boolean) + + return sections.join('\n') }) + .join('\n\n') - return snippets - } + return promptContent +} +export function CopyPromptAdmonition({ stepsContainerRef }: CopyPromptAdmonitionProps) { const handleCopyPrompt = () => { - const stepElements = stepsContainerRef.current?.querySelectorAll('[data-connect-step]') - if (!stepElements?.length) return '' - - const promptContent = Array.from(stepElements) - .map((stepElement, index) => { - const title = stepElement.getAttribute('data-step-title') ?? `Step ${index + 1}` - const description = stepElement.getAttribute('data-step-description') ?? '' - const contentElement = stepElement.querySelector( - '[data-step-content]' - ) as HTMLElement | null - - const details = contentElement ? getStepTextContent(contentElement) : '' - const codeSnippets = contentElement ? getStepCodeSnippets(contentElement) : [] - - const sections = [ - `${index + 1}. ${title}`, - description, - details ? `Details:\n${details}` : null, - codeSnippets.length - ? `Code:\n${codeSnippets - .map(({ label, snippet }) => `File: ${label}\n\`\`\`\n${snippet}\n\`\`\``) - .join('\n\n')}` - : null, - ].filter(Boolean) - - return sections.join('\n') - }) - .join('\n\n') - - return promptContent + return buildConnectPrompt(stepsContainerRef.current) } return ( diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx index 7cfe723a0894f..60402805e54c8 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx @@ -1,16 +1,14 @@ import { useParams } from 'common' -import { useMemo } from 'react' +import { Check, KeyRound } from 'lucide-react' +import { useMemo, useState } from 'react' import { Badge } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { buildConnectionStringPooler, getConnectionStrings } from '../../../DatabaseSettings.utils' -import { IPv4StatusPanel, type IPv4Status } from './IPv4StatusPanel' import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { DATABASE_CONNECTION_TYPES, - IPV4_ADDON_TEXT, - PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT, type ConnectionStringMethod, type DatabaseConnectionType, } from '@/components/interfaces/ConnectSheet/Connect.constants' @@ -22,11 +20,13 @@ import type { import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/ConnectionParameters' import { buildConnectionParameters, + buildConnectionStringWithPassword, buildSafeConnectionString, parseConnectionParams, PASSWORD_PLACEHOLDER, resolveConnectionString, } from '@/components/interfaces/ConnectSheet/ConnectionString.utils' +import { ResetDbPasswordDialog } from '@/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog' import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query' import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' @@ -143,9 +143,9 @@ const CONNECTION_METHOD_TO_TELEMETRY: Record< */ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { const track = useTrack() - const { ref: projectRef } = useParams() const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler') const isHighAvailability = useIsHighAvailability() + const [temporaryDatabasePassword, setTemporaryDatabasePassword] = useState('') const connectionSource = state.connectionSource const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri' @@ -155,8 +155,6 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { const connectionStrings = useConnectionStringDatabases(deploymentMode) const connectionStringPooler: ConnectionStringPooler | undefined = connectionStrings[connectionSource as keyof typeof connectionStrings] - const hasIPv4Addon = connectionStringPooler?.ipv4SupportedForDedicatedPooler ?? false - // Determine which connection string to use const resolvedConnectionString = useMemo( () => @@ -178,7 +176,7 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { [resolvedConnectionString, connectionParams] ) - const connectionString = useMemo(() => { + const redactedConnectionString = useMemo(() => { switch (connectionType) { case 'psql': return buildPsqlCommand(connectionParams) @@ -192,6 +190,19 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { } }, [connectionType, connectionParams, safeConnectionString]) + const connectionString = useMemo(() => { + if (!temporaryDatabasePassword) return redactedConnectionString + + if (connectionType === 'psql') { + return `psql "${buildConnectionStringWithPassword( + safeConnectionString, + temporaryDatabasePassword + )}"` + } + + return buildConnectionStringWithPassword(redactedConnectionString, temporaryDatabasePassword) + }, [connectionType, redactedConnectionString, safeConnectionString, temporaryDatabasePassword]) + const trackCopy = () => { const typeConfig = DATABASE_CONNECTION_TYPES.find((t) => t.id === connectionType) track('connection_string_copied', { @@ -211,57 +222,6 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { ) } - const sharedPoolerPreferred = !hasDedicatedPooler - const ipv4AddOnUrl = { - text: 'IPv4 add-on', - url: `/project/${projectRef}/settings/addons?panel=ipv4`, - } - const ipv4SettingsUrl = { - text: 'IPv4 settings', - url: `/project/${projectRef}/settings/addons?panel=ipv4`, - } - const poolerSettingsUrl = { - text: 'Pooler settings', - url: `/project/${projectRef}/database/settings#connection-pooling`, - } - const buttonLinks = !hasIPv4Addon - ? [ipv4AddOnUrl, ...(sharedPoolerPreferred ? [poolerSettingsUrl] : [])] - : [ipv4SettingsUrl, ...(sharedPoolerPreferred ? [poolerSettingsUrl] : [])] - - let ipv4Status: IPv4Status - if (connectionMethod === 'direct') { - ipv4Status = { - type: !hasIPv4Addon ? 'error' : 'success', - title: !hasIPv4Addon ? 'Not IPv4 compatible' : 'IPv4 compatible', - description: - !sharedPoolerPreferred && !hasIPv4Addon - ? PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT - : sharedPoolerPreferred - ? 'Use Session Pooler if on a IPv4 network or purchase IPv4 add-on' - : IPV4_ADDON_TEXT, - links: buttonLinks, - } - } else if (connectionMethod === 'transaction') { - const isUsingSharedPooler = useSharedPooler || !hasDedicatedPooler - ipv4Status = { - type: !isUsingSharedPooler && !hasIPv4Addon ? 'error' : 'success', - title: !isUsingSharedPooler && !hasIPv4Addon ? 'Not IPv4 compatible' : 'IPv4 compatible', - description: - !isUsingSharedPooler && !hasIPv4Addon - ? PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT - : isUsingSharedPooler - ? 'Transaction pooler connections are IPv4 proxied for free.' - : IPV4_ADDON_TEXT, - links: !isUsingSharedPooler ? buttonLinks : undefined, - } - } else { - ipv4Status = { - type: 'success', - title: 'IPv4 compatible', - description: 'Session pooler connections are IPv4 proxied for free', - } - } - const poolerBadge = connectionMethod === 'transaction' ? useSharedPooler || !hasDedicatedPooler @@ -280,16 +240,39 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { {poolerBadge}
)} - - {connectionString} - +
+
+ + {connectionString} + +
+ {deploymentMode.isPlatform && ( +
+
+ {temporaryDatabasePassword ? ( + + + New password shown until refresh. + + ) : ( + 'Forgot your database password?' + )} +
+ } + onPasswordReset={setTemporaryDatabasePassword} + /> +
+ )} +
{showSelfHostedDirectNotice && (

Manually{' '} @@ -304,15 +287,6 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { for self-hosted Supabase.

)} - {deploymentMode.isPlatform && projectRef && !isHighAvailability && ( -
- -
- )} { + test('renders the value field as a textarea so multiline pastes are preserved', () => { + addAPIMock({ + method: 'get', + path: '/v1/projects/:ref/secrets', + response: () => HttpResponse.json([]), + }) + + customRender() + + const nameInput = screen.getByPlaceholderText('e.g. CLIENT_KEY') + expect(nameInput.tagName).toBe('INPUT') + + const textareas = screen.getAllByRole('textbox') + const valueTextarea = textareas.find((el) => el.tagName === 'TEXTAREA') + expect(valueTextarea).toBeDefined() + }) + + test('submits a multiline value with newlines intact', async () => { + const requests: Array<{ ref: string | undefined; body: unknown }> = [] + addAPIMock({ + method: 'post', + path: '/v1/projects/:ref/secrets', + response: async ({ request, params }) => { + requests.push({ ref: params.ref as string | undefined, body: await request.json() }) + return HttpResponse.json({}, { status: 201 }) + }, + }) + addAPIMock({ + method: 'get', + path: '/v1/projects/:ref/secrets', + response: () => HttpResponse.json([]), + }) + + customRender() + + const nameInput = screen.getByPlaceholderText('e.g. CLIENT_KEY') + const saveButton = screen.getByRole('button', { name: 'Save' }) + + await userEvent.type(nameInput, 'SSL_CERT') + + const textareas = screen.getAllByRole('textbox') + const valueTextarea = textareas.find((el) => el.tagName === 'TEXTAREA')! + await userEvent.type(valueTextarea, multilineValue) + + fireEvent.click(saveButton) + + await waitFor(() => { + expect(requests).toEqual([ + { + ref: 'default', + body: [{ name: 'SSL_CERT', value: multilineValue }], + }, + ]) + }) + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx index 740b5929094b0..b2aec39957810 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useParams } from 'common' -import { Eye, EyeOff, MinusCircle } from 'lucide-react' +import { Eye, EyeOff, Trash } from 'lucide-react' import { useState } from 'react' import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -11,14 +11,17 @@ import { CardFooter, CardHeader, CardTitle, + cn, + ExpandingTextArea, Form, FormControl, FormField, - FormItem, - FormLabel, - FormMessage, + Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' import { DuplicateSecretWarningModal } from './DuplicateSecretWarningModal' @@ -206,81 +209,114 @@ export const AddNewSecretForm = () => { {fields.map((fieldItem, index) => ( -
+
0 && + 'border-t border-default pt-4 -mx-(--card-padding-x) px-(--card-padding-x)' + )} + > ( - - Name - - handlePaste(e.nativeEvent)} + +
+ + handlePaste(e.nativeEvent)} + /> + +
+
)} /> ( - - Value + - +
+ + +
- } - /> + + + {isSecretVisible(fieldItem.id) ? 'Hide value' : 'Show value'} + + +
- - + )} /> - -
))} - -

Insert or update multiple secrets at once by pasting key-value pairs

- +
+ + +
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx index d0dfc795df03e..d39c49861f6f4 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EditSecretSheet.tsx @@ -17,8 +17,8 @@ import { SheetHeader, SheetSection, SheetTitle, + Textarea, } from 'ui' -import { Input as PasswordInput } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import z from 'zod' @@ -117,25 +117,42 @@ export function EditSecretSheet({ secret, visible, onClose }: EditSecretSheetPro description="Secrets can’t be retrieved once saved. Enter a new value to overwrite the existing value." > - -
- } - /> +
+