From a59b797216d6459ef466a209ee90b404826f9b9b Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 12 Jun 2026 12:34:24 +0200 Subject: [PATCH 1/6] fix: improve redirect validation (#46794) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Bug fix ## What is the current behavior? returnTo is not correctly validated ## What is the new behavior? returnTo is validated ## Summary by CodeRabbit * **Bug Fixes** * Improved validation of redirect URLs used after organization creation; when a return URL is provided it is now validated before redirecting, while auth-related query parameters are still preserved. --- .../components/interfaces/Organization/NewOrg/NewOrgForm.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index dd67eda9d45ff..d88e6280a1936 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -57,6 +57,7 @@ import { useConfirmPendingSubscriptionCreateMutation } from '@/data/subscription import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { PRICING_TIER_LABELS_ORG, STRIPE_PUBLIC_KEY } from '@/lib/constants' +import { validateReturnTo } from '@/lib/gotrue' import { useProfile } from '@/lib/profile' interface NewOrgFormProps { @@ -279,7 +280,7 @@ export const NewOrgForm = ({ : 'My Project' if (searchParams.returnTo) { - const url = new URL(searchParams.returnTo, window.location.origin) + const url = new URL(validateReturnTo(searchParams.returnTo, '/'), window.location.origin) if (searchParams.auth_id) { url.searchParams.set('auth_id', searchParams.auth_id) } From d8fadcc1c8b25e6f51f801c16946570486544294 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 12 Jun 2026 23:38:18 +1000 Subject: [PATCH 2/6] makes it possible to reset password from connect dialog (#46866) Makes it possible to reset password from the connect sheet. Once reset the password is shown temporarily in the connection string for copy. The copy prompt action does not copy the password. image ## Summary by CodeRabbit * **New Features** * Session Pooler notice for direct connections on IPv4 networks * Option to show a temporary database password during direct-connection setup * **Improvements** * New password reset dialog with strength checks and generation * Connection-copy behavior now redacts temporary passwords and produces cleaner copy prompts * **Tests** * Added tests covering connection-string password insertion/replacement and copy-prompt behavior --- .../ConnectSheet/ConnectStepsSection.tsx | 14 +- .../ConnectionString.utils.test.ts | 18 ++ .../ConnectSheet/ConnectionString.utils.ts | 17 ++ .../ConnectSheet/CopyPromptAdmonition.test.ts | 31 +++ .../ConnectSheet/CopyPromptAdmonition.tsx | 195 ++++++++-------- .../steps/direct-connection/content.tsx | 130 +++++------ .../DatabaseSettings/ResetDbPassword.tsx | 212 +++--------------- .../ResetDbPasswordDialog.tsx | 202 +++++++++++++++++ 8 files changed, 458 insertions(+), 361 deletions(-) create mode 100644 apps/studio/components/interfaces/ConnectSheet/CopyPromptAdmonition.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog.tsx 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 && ( -
- -
- )} { - const { ref } = useParams() - const isProjectActive = useIsProjectActive() - const { data: project } = useSelectedProjectQuery() - - const { can: canResetDbPassword } = useAsyncCheckPermissions( - PermissionAction.UPDATE, - 'projects', - { - resource: { - project_id: project?.id, - }, - } - ) - - const [showResetDbPass, setShowResetDbPass] = useState(false) - - const [password, setPassword] = useState('') - const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('') - const [passwordStrengthWarning, setPasswordStrengthWarning] = useState('') - const [passwordStrengthScore, setPasswordStrengthScore] = useState(0) - - const { mutate: resetDatabasePassword, isPending: isUpdatingPassword } = - useDatabasePasswordResetMutation({ - onSuccess: async () => { - toast.success('Successfully updated database password') - setShowResetDbPass(false) - }, - }) - - useEffect(() => { - if (showResetDbPass) { - setPassword('') - setPasswordStrengthMessage('') - setPasswordStrengthWarning('') - setPasswordStrengthScore(0) - } - }, [showResetDbPass]) - - async function checkPasswordStrength(value: any) { - const { message, warning, strength } = await passwordStrength(value) - setPasswordStrengthScore(strength) - setPasswordStrengthWarning(warning) - setPasswordStrengthMessage(message) - } - - const onDbPassChange = (e: any) => { - const value = e.target.value - setPassword(value) - if (value == '') { - setPasswordStrengthScore(-1) - setPasswordStrengthMessage('') - } else checkPasswordStrength(value) - } - - const confirmResetDbPass = async () => { - if (!ref) return console.error('Project ref is required') - - if (passwordStrengthScore >= DEFAULT_MINIMUM_PASSWORD_STRENGTH) { - resetDatabasePassword({ ref, password }) - } - } - - function generatePassword() { - const password = generateStrongPassword() - setPassword(password) - checkPasswordStrength(password) - } - return ( - <> - - - - Database password - - Used for direct Postgres connections - - - - - -
-

Reset database password

-

- The database password isn’t viewable after creation. Resetting it will break any - existing connections. -

-
- setShowResetDbPass(open)}> - - - Reset password - - - - - Reset database password - - - - - } - > - 0} - aria-invalid={!!passwordStrengthWarning} - type="password" - placeholder="Type in a strong password" - value={password} - autoComplete="off" - onChange={onDbPassChange} - /> - - - - - - - - -
-
-
-
- + + + + Database password + + Used for direct Postgres connections + + + + + +
+

Reset database password

+

+ The database password isn’t viewable after creation. Resetting it will break any + existing connections. +

+
+ +
+
+
+
) } diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog.tsx new file mode 100644 index 0000000000000..1bde44e8d9d59 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog.tsx @@ -0,0 +1,202 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' +import type { ChangeEvent, ComponentProps, ReactNode } from 'react' +import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { PasswordStrengthBar } from '@/components/ui/PasswordStrengthBar' +import { useDatabasePasswordResetMutation } from '@/data/database/database-password-reset-mutation' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useIsProjectActive, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { DEFAULT_MINIMUM_PASSWORD_STRENGTH } from '@/lib/constants' +import { passwordStrength, PasswordStrengthScore } from '@/lib/password-strength' +import { generateStrongPassword } from '@/lib/project' + +export type ResetDbPasswordDialogProps = { + disabled?: boolean + onPasswordReset?: (password: string) => void + triggerClassName?: string + triggerIcon?: ReactNode + triggerLabel?: string + triggerType?: ComponentProps['type'] +} + +export const ResetDbPasswordDialog = ({ + disabled = false, + onPasswordReset, + triggerClassName, + triggerIcon, + triggerLabel = 'Reset password', + triggerType = 'default', +}: ResetDbPasswordDialogProps) => { + const { ref } = useParams() + const isProjectActive = useIsProjectActive() + const { data: project } = useSelectedProjectQuery() + + const { can: canResetDbPassword } = useAsyncCheckPermissions( + PermissionAction.UPDATE, + 'projects', + { + resource: { + project_id: project?.id, + }, + } + ) + + const [showResetDbPass, setShowResetDbPass] = useState(false) + + const [password, setPassword] = useState('') + const [passwordStrengthMessage, setPasswordStrengthMessage] = useState('') + const [passwordStrengthWarning, setPasswordStrengthWarning] = useState('') + const [passwordStrengthScore, setPasswordStrengthScore] = useState(0) + const latestPasswordStrengthValueRef = useRef('') + const passwordStrengthResultValueRef = useRef('') + + const { mutate: resetDatabasePassword, isPending: isUpdatingPassword } = + useDatabasePasswordResetMutation({ + onSuccess: async (_data, variables) => { + toast.success('Successfully updated database password') + onPasswordReset?.(variables.password) + setShowResetDbPass(false) + }, + }) + + useEffect(() => { + if (showResetDbPass) { + setPassword('') + setPasswordStrengthMessage('') + setPasswordStrengthWarning('') + setPasswordStrengthScore(0) + latestPasswordStrengthValueRef.current = '' + passwordStrengthResultValueRef.current = '' + } + }, [showResetDbPass]) + + async function checkPasswordStrength(value: string) { + latestPasswordStrengthValueRef.current = value + const { message, warning, strength } = await passwordStrength(value) + + if (latestPasswordStrengthValueRef.current !== value) return + + passwordStrengthResultValueRef.current = value + setPasswordStrengthScore(strength) + setPasswordStrengthWarning(warning) + setPasswordStrengthMessage(message) + } + + const onDbPassChange = (e: ChangeEvent) => { + const value = e.target.value + setPassword(value) + if (value == '') { + latestPasswordStrengthValueRef.current = value + passwordStrengthResultValueRef.current = value + setPasswordStrengthScore(-1) + setPasswordStrengthMessage('') + setPasswordStrengthWarning('') + } else checkPasswordStrength(value) + } + + const confirmResetDbPass = async () => { + if (!ref) return console.error('Project ref is required') + + if ( + passwordStrengthResultValueRef.current === password && + passwordStrengthScore >= DEFAULT_MINIMUM_PASSWORD_STRENGTH + ) { + resetDatabasePassword({ ref, password }) + } + } + + function generatePassword() { + const password = generateStrongPassword() + setPassword(password) + checkPasswordStrength(password) + } + + return ( + setShowResetDbPass(open)}> + + + {triggerLabel} + + + + + Reset database password + + + + + } + > + 0} + aria-invalid={!!passwordStrengthWarning} + type="password" + placeholder="Type in a strong password" + value={password} + autoComplete="off" + onChange={onDbPassChange} + /> + + + + + + + + + ) +} From e92f13f11a87b609e7d8702b23a94e0307d987ba Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 12 Jun 2026 23:42:11 +1000 Subject: [PATCH 3/6] adjust project settings integrations layout (#46868) image Updates project settings integration page to more aligned with our updated layout guidance (as defined in layout.mdx in design system). ## Summary by CodeRabbit * **Style** * Refined the integrations settings page layout and visual design across AWS PrivateLink, GitHub, and Vercel integration sections for improved consistency. --- .../AWSPrivateLink/AWSPrivateLinkSection.tsx | 124 +++++----- .../GithubIntegration/GithubSection.tsx | 73 +++--- .../Integrations/IntegrationsSettings.tsx | 69 ++++-- .../VercelIntegration/VercelSection.tsx | 223 +++++++++--------- .../project/[ref]/settings/integrations.tsx | 33 ++- 5 files changed, 271 insertions(+), 251 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx index a503bcd1713b9..540879a0d1afc 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx @@ -2,16 +2,18 @@ import { useState } from 'react' import { toast } from 'sonner' import { Button, Card, CardContent, cn } from 'ui' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' -import { IntegrationImageHandler } from '../IntegrationsSettings' +import { IntegrationSectionIcon } from '../IntegrationsSettings' import { AWSPrivateLinkAccountItem } from './AWSPrivateLinkAccountItem' import { AWSPrivateLinkForm } from './AWSPrivateLinkForm' -import { - ScaffoldContainer, - ScaffoldSection, - ScaffoldSectionContent, - ScaffoldSectionDetail, -} from '@/components/layouts/Scaffold' import { ResourceList } from '@/components/ui/Resource/ResourceList' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useAWSAccountDeleteMutation } from '@/data/aws-accounts/aws-account-delete-mutation' @@ -63,64 +65,60 @@ export const AWSPrivateLinkSection = () => { return ( <> - - - -

Connect to your Supabase project from your AWS VPC using AWS PrivateLink.

- -
- -
-
-
-

- How does the AWS PrivateLink integration work? -

-

- Connecting to AWS PrivateLink allows you to create a private connection between - your AWS VPC and your Supabase project. -

-
- {promptPlanUpgrade && ( - - )} -
-
-
-

AWS Accounts

- -
- {(accounts?.length ?? 0) > 0 ? ( - - {accounts?.map((account) => ( - onEditAccount(account)} - onDelete={() => onDeleteAccount(account)} - /> - ))} - - ) : ( - - -

No accounts connected

-
-
- )} + + +
+ + + AWS PrivateLink + + Connect to your Supabase project from your AWS VPC using AWS PrivateLink. Create a + private connection between your AWS VPC and your Supabase project without traffic + traversing the public internet. + + +
+
+ +
+ {promptPlanUpgrade && ( + + )} +
+
+

AWS Accounts

+
+ {(accounts?.length ?? 0) > 0 ? ( + + {accounts?.map((account) => ( + onEditAccount(account)} + onDelete={() => onDeleteAccount(account)} + /> + ))} + + ) : ( + + +

No accounts connected

+
+
+ )}
- - - +
+
+
diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index 34240d1f58b2a..44ad75a9c30d4 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -1,16 +1,18 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import { useMemo } from 'react' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { IntegrationImageHandler } from '../IntegrationsSettings' +import { IntegrationSectionIcon } from '../IntegrationsSettings' import { GitHubIntegrationConnectionForm } from './GitHubIntegrationConnectionForm' -import { - ScaffoldContainer, - ScaffoldSection, - ScaffoldSectionContent, - ScaffoldSectionDetail, -} from '@/components/layouts/Scaffold' import NoPermission from '@/components/ui/NoPermission' import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -33,39 +35,30 @@ export const GitHubSection = () => { [connections, projectRef] ) - const GitHubTitle = `GitHub Integration` - return ( - - - -

Connect any of your GitHub repositories to a project.

- -
- - {isLoadingPermissions ? ( - - ) : !canReadGitHubConnection ? ( - - ) : ( -
-
-

- How does the GitHub integration work? -

-

- Connect to GitHub to apply changes to your database when you merge into your - production branch. If branching is enabled, each pull request gets its own preview - database. -

-
-
- -
-
- )} -
-
-
+ + +
+ + + GitHub Integration + + Connect any of your GitHub repositories to a project. Supabase applies database + changes when you merge into your production branch. If branching is enabled, each pull + request gets its own preview database. + + +
+
+ + {isLoadingPermissions ? ( + + ) : !canReadGitHubConnection ? ( + + ) : ( + + )} + +
) } diff --git a/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx b/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx index fb58bd8ffd2d1..5a0822d5b16f2 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/IntegrationsSettings.tsx @@ -1,10 +1,10 @@ +import { Card } from 'ui' import { Admonition } from 'ui-patterns' import { AWSPrivateLinkSection } from './AWSPrivateLink/AWSPrivateLinkSection' import { GitHubSection } from './GithubIntegration/GithubSection' import { VercelSection } from './VercelIntegration/VercelSection' import { SidePanelVercelProjectLinker } from '@/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker' -import { ScaffoldContainer, ScaffoldDivider } from '@/components/layouts/Scaffold' import { InlineLink } from '@/components/ui/InlineLink' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -13,13 +13,46 @@ import { BASE_PATH } from '@/lib/constants' export const IntegrationImageHandler = ({ title }: { title: 'vercel' | 'github' | 'aws' }) => { return ( {`${title} ) } +const INTEGRATION_ICONS: Record< + 'vercel' | 'github' | 'aws', + (className: string) => React.ReactNode +> = { + github: (className) => ( + + + + ), + vercel: (className) => ( + + + + ), + aws: (className) => ( + + + + + + ), +} + +export const IntegrationSectionIcon = ({ title }: { title: 'vercel' | 'github' | 'aws' }) => { + return ( +
+ + {INTEGRATION_ICONS[title]('h-5 w-5')} + +
+ ) +} + export const IntegrationSettings = () => { const { data: project } = useSelectedProjectQuery() const isBranch = project?.parent_project_ref !== undefined @@ -33,37 +66,29 @@ export const IntegrationSettings = () => { return ( <> {isBranch && ( - - - To adjust your project's integration settings, you may return to your{' '} - - main branch - - . - - + + To adjust your project's integration settings, you may return to your{' '} + + main branch + + . + )} {showVercelIntegration && ( <> - )} - {showAWSPrivateLink && ( - <> - - - - )} + {showAWSPrivateLink && } ) } diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index 90a7ed02f353c..3309a748d35cf 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -4,9 +4,17 @@ import Link from 'next/link' import { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { Button, cn } from 'ui' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { IntegrationImageHandler } from '../IntegrationsSettings' +import { IntegrationSectionIcon } from '../IntegrationsSettings' import VercelIntegrationConnectionForm from './VercelIntegrationConnectionForm' import { IntegrationConnectionItem } from '@/components/interfaces/Integrations/VercelGithub/IntegrationConnection' import { @@ -14,12 +22,6 @@ import { IntegrationConnectionHeader, IntegrationInstallation, } from '@/components/interfaces/Integrations/VercelGithub/IntegrationPanels' -import { - ScaffoldContainer, - ScaffoldSection, - ScaffoldSectionContent, - ScaffoldSectionDetail, -} from '@/components/layouts/Scaffold' import { InlineLink } from '@/components/ui/InlineLink' import NoPermission from '@/components/ui/NoPermission' import { useOrgIntegrationsQuery } from '@/data/integrations/integrations-query-org-only' @@ -119,8 +121,6 @@ export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) [deleteVercelConnection, org?.slug] ) - const VercelTitle = `Vercel Integration` - const integrationUrl = process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? 'https://vercel.com/integrations/supabase' @@ -141,116 +141,113 @@ export const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) )} ` return ( - - - -

Connect your Vercel teams to your Supabase organization.

- -
- - {isLoadingPermissions ? ( - - ) : !canReadVercelConnection ? ( - - ) : ( -
-
-

- How does the Vercel integration work? -

-

- Supabase will keep your environment variables up to date in each of the projects - you assign to a Supabase project. You can also link multiple Vercel projects to - the same Supabase project. -

-
-
- {vercelIntegration ? ( -
- - {connections.length > 0 ? ( - <> - -
    - {connections.map((connection) => ( -
    li]:pb-0' - )} - > - - {isProjectScoped ? ( -
    -
    - -
    -
    - ) : null} -
    - ))} -
- - ) : ( + + +
+ + + Vercel Integration + + Connect your Vercel teams to your Supabase organization. Supabase keeps environment + variables up to date in each assigned project. You can also link multiple Vercel + projects to the same Supabase project. + + +
+
+ + {isLoadingPermissions ? ( + + ) : !canReadVercelConnection ? ( + + ) : ( +
+
+ {vercelIntegration ? ( +
+ + {connections.length > 0 ? ( + <> +
    + {connections.map((connection) => ( +
    li]:pb-0' + )} + > + + {isProjectScoped ? ( +
    +
    + +
    +
    + ) : null} +
    + ))} +
+ + ) : ( + + )} + onAddVercelConnection(vercelIntegration.id)} + > + Add new project connection + +
+ ) : ( +
+
- ) : ( -
- -
- )} -
- {vercelProjectCount > 0 && vercelIntegration !== undefined && ( -

- Your Vercel connection can access {vercelProjectCount} Vercel projects. To change - which projects Supabase may use, open your organization’s{' '} - - Vercel integration settings - - . -

+ +
)}
- )} - - - + {vercelProjectCount > 0 && vercelIntegration !== undefined && ( +

+ Your Vercel connection can access {vercelProjectCount} Vercel projects. To change + which projects Supabase may use, open your organization’s{' '} + + Vercel integration settings + + . +

+ )} +
+ )} + + ) } diff --git a/apps/studio/pages/project/[ref]/settings/integrations.tsx b/apps/studio/pages/project/[ref]/settings/integrations.tsx index 1e0654c2f8b30..5d02828a69003 100644 --- a/apps/studio/pages/project/[ref]/settings/integrations.tsx +++ b/apps/studio/pages/project/[ref]/settings/integrations.tsx @@ -1,24 +1,31 @@ +import { PageContainer } from 'ui-patterns/PageContainer' +import { + PageHeader, + PageHeaderDescription, + PageHeaderMeta, + PageHeaderSummary, + PageHeaderTitle, +} from 'ui-patterns/PageHeader' + import { IntegrationSettings } from '@/components/interfaces/Settings/Integrations/IntegrationsSettings' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import SettingsLayout from '@/components/layouts/ProjectSettingsLayout/SettingsLayout' -import { - ScaffoldContainer, - ScaffoldDivider, - ScaffoldHeader, - ScaffoldTitle, -} from '@/components/layouts/Scaffold' import type { NextPageWithLayout } from '@/types' const ProjectSettingsIntegrations: NextPageWithLayout = () => { return ( <> - - - Integrations - - - - + + + + Integrations + Connect external services to your project + + + + + + ) } From 47dbbddc91c372ddbee02657cc3e8ae26bcbbd7f Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 12 Jun 2026 08:11:36 -0600 Subject: [PATCH 4/6] chore: fix secrets editor for functions to be text area/ support newlines (#46754) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Update to support text area for functions ## Summary by CodeRabbit * **New Features** * Secret inputs now accept and preserve multi-line values and auto-resize to fit content. * Secret values can be masked/unmasked via a show/hide toggle with tooltip; masking uses styled concealment. * Per-secret controls refined: clearer row layout, dedicated remove icon, and add/save controls moved to the card footer. * **Tests** * Added tests validating multi-line secret entry and that submitted payloads include embedded newlines. * Updated tests to assert masking/unmasking behavior via visual security styling. --------- Co-authored-by: kemal --- .../AddNewSecretForm.test.tsx | 69 +++++++++ .../EdgeFunctionSecrets/AddNewSecretForm.tsx | 138 +++++++++++------- .../EdgeFunctionSecrets/EditSecretSheet.tsx | 57 +++++--- .../Vault/Secrets/AddNewSecretModal.tsx | 52 ++++++- .../Vault/Secrets/EditSecretModal.tsx | 24 ++- .../__tests__/EditSecretModal.test.tsx | 5 +- 6 files changed, 268 insertions(+), 77 deletions(-) create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.test.tsx diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.test.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.test.tsx new file mode 100644 index 0000000000000..4ab435cb0e084 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/AddNewSecretForm.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { describe, expect, test } from 'vitest' + +import { AddNewSecretForm } from './AddNewSecretForm' +import type { ProjectSecret } from '@/data/secrets/secrets-query' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +const multilineValue = '-----BEGIN CERTIFICATE-----\nline2\nline3\n-----END CERTIFICATE-----' + +describe('AddNewSecretForm', () => { + 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." > - -
- } - /> +
+