From 46b31eb53a51982754812a4f71cd5c11f1c20f43 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 6 Jul 2026 17:38:03 +0800 Subject: [PATCH] [FE-3379] feat(studio): warn when db passwords need percent-encoding (#47564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users who set a database password with special characters (\`@\`, \`#\`, \`%\`, \`+\`, etc.) get no warning that it must be percent-encoded when used in a connection URL, which leads to confusing connection failures ([FE-3379](https://linear.app/supabase/issue/FE-3379)). Screenshot 2026-07-03 at 6 26 43 PM Screenshot 2026-07-03 at 6 27 14 PM Screenshot 2026-07-03 at 6 27 26 PM **Changed:** - `PasswordStrengthBar` now shows a warning-colored callout (with a docs link) whenever the entered password contains characters that need percent-encoding — this covers project creation, reset database password, restore-to-new-project, and the Vercel deploy-button flow - Replaced `DATABASE_PASSWORD_REGEX` (only caught `@`, `:`, `/`) with a `passwordNeedsPercentEncoding()` helper based on `encodeURIComponent`, so `#`, `%`, `+`, `?`, `&`, spaces etc. are caught too - Moved `SpecialSymbolsCallout` from `ProjectCreation/` to `components/ui/` since it's now shared **Added:** - Info admonition in the Connect sheet next to connection strings that still contain `[YOUR-PASSWORD]` (direct connection + `.env`-based file setups; hidden for psql and .NET where percent-encoding doesn't apply, and after a password reset since the substituted password is already encoded) ## To test - Project creation → type a password containing \`#\` or \`@\` → warning callout appears above the strength bar; disappears for alphanumeric passwords - Database Settings → Reset database password → same behaviour - Connect sheet → Direct connection → note shows under the connection string for URI/JDBC types, not for psql; after resetting the password from the sheet, the note disappears (password is substituted already encoded) - Connect sheet → Node.js/Python/Go/SQLAlchemy file setups show the note; .NET does not - \`pnpm vitest run lib/password-strength.test.ts\` passes ## Summary by CodeRabbit ## Summary by CodeRabbit * **New Features** * Added a dedicated password encoding note (with documentation link) on direct connection screens when the password is embedded in a URL. * Added an encoding hint to the password strength area when percent-encoding is required. * **Bug Fixes** * Removed regex-based “invalid password” callout and replaced it with safer percent-encoding detection logic. * **Tests** * Added test coverage for `passwordNeedsPercentEncoding`. * Removed obsolete Project Creation password regex tests. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../ConnectSheet/PasswordEncodingNote.tsx | 19 +++++++++++++++ .../steps/direct-connection/content.tsx | 2 ++ .../content/steps/direct-files/content.tsx | 8 +++++++ .../ProjectCreation/DatabasePasswordInput.tsx | 20 +++++----------- .../ProjectCreation.constants.ts | 2 -- .../ProjectCreation/ProjectCreation.test.ts | 11 --------- .../ProjectCreation/SpecialSymbolsCallout.tsx | 14 ----------- .../components/ui/PasswordStrengthBar.tsx | 4 +++- .../components/ui/SpecialSymbolsCallout.tsx | 12 ++++++++++ apps/studio/lib/constants/index.ts | 1 + apps/studio/lib/password-strength.test.ts | 23 ++++++++++++++++++- apps/studio/lib/password-strength.ts | 13 +++++++++++ 12 files changed, 86 insertions(+), 43 deletions(-) create mode 100644 apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx delete mode 100644 apps/studio/components/interfaces/ProjectCreation/ProjectCreation.test.ts delete mode 100644 apps/studio/components/interfaces/ProjectCreation/SpecialSymbolsCallout.tsx create mode 100644 apps/studio/components/ui/SpecialSymbolsCallout.tsx diff --git a/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx b/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx new file mode 100644 index 00000000000..7c8ea817b03 --- /dev/null +++ b/apps/studio/components/interfaces/ConnectSheet/PasswordEncodingNote.tsx @@ -0,0 +1,19 @@ +import { Admonition } from 'ui-patterns/admonition' + +import { InlineLink } from '@/components/ui/InlineLink' +import { SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL } from '@/lib/constants' + +export const PasswordEncodingNote = () => { + return ( + + If your database password contains special characters, you will need to{' '} + percent-encode them + in the connection string. + + } + /> + ) +} 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 65157cb03dc..64687afd842 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 @@ -26,6 +26,7 @@ import { PASSWORD_PLACEHOLDER, resolveConnectionString, } from '@/components/interfaces/ConnectSheet/ConnectionString.utils' +import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote' 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' @@ -237,6 +238,7 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) { {poolerBadge} )} + {connectionString.includes(PASSWORD_PLACEHOLDER) && }
+ {config.passwordInUrl && } {(config.postCommands ?? []).map((command) => (
diff --git a/apps/studio/components/interfaces/ProjectCreation/DatabasePasswordInput.tsx b/apps/studio/components/interfaces/ProjectCreation/DatabasePasswordInput.tsx index c5b974310d2..46deeeb3ee9 100644 --- a/apps/studio/components/interfaces/ProjectCreation/DatabasePasswordInput.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/DatabasePasswordInput.tsx @@ -3,9 +3,7 @@ import { FormControl, FormField } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { DATABASE_PASSWORD_REGEX } from './ProjectCreation.constants' import { CreateProjectForm } from './ProjectCreation.schema' -import { SpecialSymbolsCallout } from './SpecialSymbolsCallout' import Panel from '@/components/ui/Panel' import { PasswordStrengthBar } from '@/components/ui/PasswordStrengthBar' import { passwordStrength } from '@/lib/password-strength' @@ -49,23 +47,17 @@ export const DatabasePasswordInput = ({ form }: DatabasePasswordInputProps) => { control={form.control} name="dbPass" render={({ field }) => { - const isInvalidDatabasePassword = - field.value.length > 0 && !field.value.match(DATABASE_PASSWORD_REGEX) - return ( - {isInvalidDatabasePassword && } - - + } > diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts index 517d261d47a..2cdb36b8c86 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts @@ -255,6 +255,4 @@ export const COUNTRY_LAT_LON = { ZW: { lat: -20, lon: 30 }, } -export const DATABASE_PASSWORD_REGEX = /^[^@:\/]*$/ - export const sizes: DesiredInstanceSize[] = ['micro', 'small', 'medium'] diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.test.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.test.ts deleted file mode 100644 index 76babdb88d6..00000000000 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { expect, test } from 'vitest' - -import { DATABASE_PASSWORD_REGEX } from './ProjectCreation.constants' - -test('Regex test to surface if password contains @, : or /', () => { - expect(!'teststring'.match(DATABASE_PASSWORD_REGEX)).toEqual(false) - expect(!'test@string'.match(DATABASE_PASSWORD_REGEX)).toEqual(true) - expect(!'te:ststring'.match(DATABASE_PASSWORD_REGEX)).toEqual(true) - expect(!`tests/tring`.match(DATABASE_PASSWORD_REGEX)).toEqual(true) - expect(!'!#$%^&*()'.match(DATABASE_PASSWORD_REGEX)).toEqual(false) -}) diff --git a/apps/studio/components/interfaces/ProjectCreation/SpecialSymbolsCallout.tsx b/apps/studio/components/interfaces/ProjectCreation/SpecialSymbolsCallout.tsx deleted file mode 100644 index a2af77dac29..00000000000 --- a/apps/studio/components/interfaces/ProjectCreation/SpecialSymbolsCallout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { InlineLink } from '@/components/ui/InlineLink' -import { DOCS_URL } from '@/lib/constants' - -export const SpecialSymbolsCallout = () => { - return ( -

- Note: If using the Postgres connection string, you will need to{' '} - - percent-encode - {' '} - the password -

- ) -} diff --git a/apps/studio/components/ui/PasswordStrengthBar.tsx b/apps/studio/components/ui/PasswordStrengthBar.tsx index 3d5600adf1f..8c96203bdb8 100644 --- a/apps/studio/components/ui/PasswordStrengthBar.tsx +++ b/apps/studio/components/ui/PasswordStrengthBar.tsx @@ -1,6 +1,7 @@ import { InlineLinkClassName } from './InlineLink' +import { SpecialSymbolsCallout } from './SpecialSymbolsCallout' import { PASSWORD_STRENGTH_COLOR, PASSWORD_STRENGTH_PERCENTAGE } from '@/lib/constants' -import { PasswordStrengthScore } from '@/lib/password-strength' +import { passwordNeedsPercentEncoding, PasswordStrengthScore } from '@/lib/password-strength' interface Props { passwordStrengthScore: PasswordStrengthScore @@ -17,6 +18,7 @@ export const PasswordStrengthBar = ({ }: Props) => { return ( <> + {passwordNeedsPercentEncoding(password) && } {password && (
{ + return ( +

+ Note: If using the Postgres connection string, you will need to{' '} + percent-encode the + password +

+ ) +} diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 0ed4e00616c..2dd90f2652d 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -68,6 +68,7 @@ export const POSTHOG_URL = export const USAGE_APPROACHING_THRESHOLD = 0.75 export const DOCS_URL = process.env.NEXT_PUBLIC_DOCS_URL || 'https://supabase.com/docs' +export const SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL = `${DOCS_URL}/guides/database/postgres/roles#special-symbols-in-passwords` export const OPT_IN_TAGS = { AI_SQL: 'AI_SQL_GENERATOR_OPT_IN', diff --git a/apps/studio/lib/password-strength.test.ts b/apps/studio/lib/password-strength.test.ts index 0af4784f0b2..ada6a8a5977 100644 --- a/apps/studio/lib/password-strength.test.ts +++ b/apps/studio/lib/password-strength.test.ts @@ -1,6 +1,27 @@ import { describe, expect, it } from 'vitest' -import { passwordStrength } from './password-strength' +import { passwordNeedsPercentEncoding, passwordStrength } from './password-strength' + +describe('passwordNeedsPercentEncoding', () => { + it('returns false for passwords that are safe to use in a connection string', () => { + expect(passwordNeedsPercentEncoding('')).toBe(false) + expect(passwordNeedsPercentEncoding('teststring')).toBe(false) + expect(passwordNeedsPercentEncoding('Str0ngPassword123')).toBe(false) + expect(passwordNeedsPercentEncoding('with-safe_chars.~!')).toBe(false) + }) + + it('returns true for passwords with characters that need percent-encoding', () => { + expect(passwordNeedsPercentEncoding('test@string')).toBe(true) + expect(passwordNeedsPercentEncoding('te:ststring')).toBe(true) + expect(passwordNeedsPercentEncoding('tests/tring')).toBe(true) + expect(passwordNeedsPercentEncoding('test#string')).toBe(true) + expect(passwordNeedsPercentEncoding('test%string')).toBe(true) + expect(passwordNeedsPercentEncoding('test+string')).toBe(true) + expect(passwordNeedsPercentEncoding('test?string')).toBe(true) + expect(passwordNeedsPercentEncoding('test&string')).toBe(true) + expect(passwordNeedsPercentEncoding('test string')).toBe(true) + }) +}) describe('passwordStrength', () => { it('returns empty values for message, warning and strength for empty input', async () => { diff --git a/apps/studio/lib/password-strength.ts b/apps/studio/lib/password-strength.ts index 7bc72f622a6..6101ebfa70d 100644 --- a/apps/studio/lib/password-strength.ts +++ b/apps/studio/lib/password-strength.ts @@ -4,6 +4,19 @@ import { DEFAULT_MINIMUM_PASSWORD_STRENGTH, PASSWORD_STRENGTH } from '@/lib/cons // but we need to define it here because we don't to import zxcvbn everywhere export type PasswordStrengthScore = 0 | 1 | 2 | 3 | 4 +/** + * True when the password contains characters that must be percent-encoded + * before the password can be used in a connection string URL. + */ +export function passwordNeedsPercentEncoding(password: string) { + try { + return password !== encodeURIComponent(password) + } catch { + // encodeURIComponent throws on lone surrogates + return true + } +} + export async function passwordStrength(value: string) { // [Alaister]: Lazy load zxcvbn to avoid bundling it with the main app (it's pretty chunky) const zxcvbn = await import('zxcvbn').then((module) => module.default)