[FE-3379] feat(studio): warn when db passwords need percent-encoding (#47564)

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)).

<img width="700" height="200" alt="Screenshot 2026-07-03 at 6 26 43 PM"
src="https://github.com/user-attachments/assets/48608d65-8057-4abe-96fc-c0ede3550951"
/>
<img width="1002" height="395" alt="Screenshot 2026-07-03 at 6 27 14 PM"
src="https://github.com/user-attachments/assets/1366b985-7d80-4e7d-97f0-c79d5c84cefd"
/>
<img width="548" height="303" alt="Screenshot 2026-07-03 at 6 27 26 PM"
src="https://github.com/user-attachments/assets/b042101a-0e88-4730-adb8-1b490018f208"
/>

**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

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
Alaister YoungandAlaister Young authored and GitHub committed 2026-07-06 17:38:03 +08:00
1 parent 0a9a0eec92
commit 46b31eb53a
12 files changed
+86 -43

No files matched your search

@@ -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 (
<Admonition
type="default"
description={
<>
If your database password contains special characters, you will need to{' '}
<InlineLink href={SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL}>percent-encode</InlineLink> them
in the connection string.
</>
}
/>
)
}
@@ -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) {
<Badge>{poolerBadge}</Badge>
</div>
)}
{connectionString.includes(PASSWORD_PLACEHOLDER) && <PasswordEncodingNote />}
<div className="overflow-hidden rounded-lg border bg-surface-75">
<div data-connect-copy-value={redactedConnectionString}>
<CodeBlock
@@ -16,6 +16,7 @@ import {
PASSWORD_PLACEHOLDER,
resolveConnectionString,
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote'
const DOTNET_CONFIG_COMMAND =
'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION'
@@ -27,6 +28,8 @@ type DirectFilesConfig = {
code: string
}[]
connectionStringFile?: string
/** The password ends up inside a connection URL, so special characters must be percent-encoded */
passwordInUrl?: boolean
postCommands?: { label: string; command: string }[]
}
@@ -79,6 +82,7 @@ export default sql`,
envFile,
],
connectionStringFile: envFile.name,
passwordInUrl: true,
}
case 'golang':
@@ -115,6 +119,7 @@ func main() {
envFile,
],
connectionStringFile: envFile.name,
passwordInUrl: true,
}
case 'dotnet':
@@ -161,6 +166,7 @@ connection = psycopg2.connect(DATABASE_URL)`,
envFile,
],
connectionStringFile: envFile.name,
passwordInUrl: true,
}
case 'sqlalchemy':
@@ -213,6 +219,7 @@ except Exception as e:
},
],
connectionStringFile: '.env',
passwordInUrl: true,
}
default:
@@ -242,6 +249,7 @@ except Exception as e:
return (
<div className="flex flex-col gap-3">
<MultipleCodeBlock files={config.files} value={activeFile} onValueChange={setActiveFile} />
{config.passwordInUrl && <PasswordEncodingNote />}
<ConnectionParameters parameters={buildConnectionParameters(connectionParams)} />
{(config.postCommands ?? []).map((command) => (
<div key={command.command} className="flex flex-col gap-2">
@@ -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 (
<FormItemLayout
label="Database password"
layout="horizontal"
description={
<>
{isInvalidDatabasePassword && <SpecialSymbolsCallout />}
<PasswordStrengthBar
passwordStrengthScore={form.getValues('dbPassStrength')}
password={field.value}
passwordStrengthMessage={form.getValues('dbPassStrengthMessage')}
generateStrongPassword={generatePassword}
/>
</>
<PasswordStrengthBar
passwordStrengthScore={form.getValues('dbPassStrength')}
password={field.value}
passwordStrengthMessage={form.getValues('dbPassStrengthMessage')}
generateStrongPassword={generatePassword}
/>
}
>
<FormControl>
@@ -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']
@@ -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)
})
@@ -1,14 +0,0 @@
import { InlineLink } from '@/components/ui/InlineLink'
import { DOCS_URL } from '@/lib/constants'
export const SpecialSymbolsCallout = () => {
return (
<p className="mb-2">
Note: If using the Postgres connection string, you will need to{' '}
<InlineLink href={`${DOCS_URL}/guides/database/postgres/roles#special-symbols-in-passwords`}>
percent-encode
</InlineLink>{' '}
the password
</p>
)
}
@@ -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) && <SpecialSymbolsCallout />}
{password && (
<div
aria-valuemax={100}
@@ -0,0 +1,12 @@
import { InlineLink } from '@/components/ui/InlineLink'
import { SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL } from '@/lib/constants'
export const SpecialSymbolsCallout = () => {
return (
<p className="mb-2 text-warning">
Note: If using the Postgres connection string, you will need to{' '}
<InlineLink href={SPECIAL_SYMBOLS_IN_PASSWORDS_DOCS_URL}>percent-encode</InlineLink> the
password
</p>
)
}
+1
View File
@@ -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',
+22 -1
View File
@@ -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 () => {
+13
View File
@@ -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)