From 8d2232b98df48906ae8176600f38bf16c769c2f5 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Fri, 11 Sep 2026 14:22:00 +0200 Subject: [PATCH] Always send an admin password so a renamed admin user can't block site startup Changing a site's admin username without a stored password left the site unable to start: the admin API was asked to create the new user with no password and returned an error, killing the server process. The default now lives in @studio/common and is sent to the CLI rather than only shown in the settings field. Co-Authored-By: Claude Opus 5 --- apps/cli/lib/admin-credentials.ts | 6 ++++-- apps/cli/lib/native-php/site-setup.ts | 12 ++++++++---- apps/cli/lib/tests/admin-credentials.test.ts | 12 +++++++++++- apps/local/src/index.ts | 18 ++++++++++++++---- apps/studio/src/ipc-handlers.ts | 8 ++++++-- .../src/components/create-site-form/index.tsx | 10 +++++++--- .../site-overview-view/admin-section.tsx | 12 ++++++++---- .../components/site-settings-view/index.tsx | 13 +++++++++---- packages/common/lib/passwords.ts | 19 +++++++++++++++++++ 9 files changed, 86 insertions(+), 24 deletions(-) diff --git a/apps/cli/lib/admin-credentials.ts b/apps/cli/lib/admin-credentials.ts index cf3824a374..cbce9ba042 100644 --- a/apps/cli/lib/admin-credentials.ts +++ b/apps/cli/lib/admin-credentials.ts @@ -1,4 +1,4 @@ -import { decodePassword } from '@studio/common/lib/passwords'; +import { decodeAdminPassword } from '@studio/common/lib/passwords'; import { ServerConfig } from 'cli/lib/types/wordpress-server-ipc'; type AdminCredentialsConfig = Pick< @@ -28,9 +28,11 @@ export function shouldSetAdminCredentials( config: AdminCredentialsConfig ): boo export function getSetAdminCredentialsRequestBody( config: AdminCredentialsConfig ): SetAdminCredentialsRequestBody { + // The password is always sent: creating a user requires one, so omitting it fails the + // request outright when adminUsername names a user that does not exist yet. return { action: 'set_admin_password', - ...( config.adminPassword && { password: decodePassword( config.adminPassword ) } ), + password: decodeAdminPassword( config.adminPassword ), ...( config.adminUsername && { username: config.adminUsername } ), ...( config.adminEmail && { email: config.adminEmail } ), }; diff --git a/apps/cli/lib/native-php/site-setup.ts b/apps/cli/lib/native-php/site-setup.ts index 9ee3f7072f..929b7e09b7 100644 --- a/apps/cli/lib/native-php/site-setup.ts +++ b/apps/cli/lib/native-php/site-setup.ts @@ -2,7 +2,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { DEFAULT_LOCALE } from '@studio/common/lib/locale'; import { escapePhpSingleQuotedString } from '@studio/common/lib/mu-plugins'; -import { decodePassword } from '@studio/common/lib/passwords'; +import { + DEFAULT_ADMIN_EMAIL, + DEFAULT_ADMIN_USERNAME, + decodeAdminPassword, +} from '@studio/common/lib/passwords'; import { type NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; import { getWpCliPharPath } from 'cli/lib/dependency-management/paths'; import { ensurePhpBinaryAvailable } from '../dependency-management/php-binary'; @@ -167,9 +171,9 @@ export async function installWordPress( } const siteTitle = config.siteTitle ?? 'My WordPress Website'; - const username = config.adminUsername ?? 'admin'; - const password = config.adminPassword ? decodePassword( config.adminPassword ) : 'password'; - const email = config.adminEmail ?? 'admin@localhost.com'; + const username = config.adminUsername ?? DEFAULT_ADMIN_USERNAME; + const password = decodeAdminPassword( config.adminPassword ); + const email = config.adminEmail ?? DEFAULT_ADMIN_EMAIL; const siteUrl = config.absoluteUrl ?? `http://localhost:${ config.port }`; // WP-CLI defaults to en_US; Studio's DEFAULT_LOCALE of "en" is not a WP locale code. const locale = diff --git a/apps/cli/lib/tests/admin-credentials.test.ts b/apps/cli/lib/tests/admin-credentials.test.ts index ee608ef358..7fd5f77be6 100644 --- a/apps/cli/lib/tests/admin-credentials.test.ts +++ b/apps/cli/lib/tests/admin-credentials.test.ts @@ -1,4 +1,4 @@ -import { encodePassword } from '@studio/common/lib/passwords'; +import { DEFAULT_ADMIN_PASSWORD, encodePassword } from '@studio/common/lib/passwords'; import { getSetAdminCredentialsRequestBody, requestSetAdminCredentials, @@ -27,6 +27,16 @@ describe( 'admin credentials', () => { } ); } ); + it( 'falls back to the default password when the site has none stored', () => { + // A site configured with only a username must still send a password: creating the + // user fails without one, which previously left the site unable to start. + expect( getSetAdminCredentialsRequestBody( { adminUsername: 'admine' } ) ).toEqual( { + action: 'set_admin_password', + username: 'admine', + password: DEFAULT_ADMIN_PASSWORD, + } ); + } ); + it( 'serializes the admin API body as form data', () => { const params = toUrlSearchParams( { action: 'set_admin_password', diff --git a/apps/local/src/index.ts b/apps/local/src/index.ts index 9d313bf54f..69e4b31b96 100644 --- a/apps/local/src/index.ts +++ b/apps/local/src/index.ts @@ -55,7 +55,11 @@ import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { importIpcEventSchema } from '@studio/common/lib/import-export-events'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { getAuthenticationUrl, getSignUpUrl } from '@studio/common/lib/oauth'; -import { decodePassword } from '@studio/common/lib/passwords'; +import { + DEFAULT_ADMIN_USERNAME, + decodeAdminPassword, + decodePassword, +} from '@studio/common/lib/passwords'; import { getInstructionsLengthBucket, isTracksEventName, @@ -961,12 +965,18 @@ export async function startLocalServer( options: LocalServerOptions ): Promise< if ( ( updated.enableXdebug ?? false ) !== ( current.enableXdebug ?? false ) ) { options.xdebug = updated.enableXdebug ?? false; } - if ( ( updated.adminUsername ?? 'admin' ) !== ( current.adminUsername ?? 'admin' ) ) { + if ( + ( updated.adminUsername ?? DEFAULT_ADMIN_USERNAME ) !== + ( current.adminUsername ?? DEFAULT_ADMIN_USERNAME ) + ) { options.adminUsername = updated.adminUsername; } - if ( ( updated.adminPassword ?? '' ) !== ( current.adminPassword ?? '' ) ) { + if ( + decodeAdminPassword( updated.adminPassword ) !== + decodeAdminPassword( current.adminPassword ) + ) { // The CLI expects a plaintext password (it encodes before saving). - options.adminPassword = decodePassword( updated.adminPassword ?? '' ); + options.adminPassword = decodeAdminPassword( updated.adminPassword ); } if ( ( updated.adminEmail ?? '' ) !== ( current.adminEmail ?? '' ) ) { options.adminEmail = updated.adminEmail; diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 98f5d31f2c..29e060555e 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -76,7 +76,11 @@ import { isMultisite } from '@studio/common/lib/is-multisite'; import { checkMaintenanceFile } from '@studio/common/lib/maintenance-file'; import { getLocalMediaMimeType } from '@studio/common/lib/media-mime'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; -import { decodePassword, encodePassword } from '@studio/common/lib/passwords'; +import { + DEFAULT_ADMIN_PASSWORD, + decodePassword, + encodePassword, +} from '@studio/common/lib/passwords'; import { isTracksEventName } from '@studio/common/lib/record-tracks-event'; import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; import { @@ -717,7 +721,7 @@ export async function removeWordPressSkillFromAllSites( const DEBUG_LOG_MAX_LINES = 50; const PROCESS_MANAGER_HOME = nodePath.join( os.homedir(), '.studio', 'daemon' ); -const DEFAULT_ENCODED_PASSWORD = encodePassword( 'password' ); +const DEFAULT_ENCODED_PASSWORD = encodePassword( DEFAULT_ADMIN_PASSWORD ); function readWordPressDebugLog( sitePath: string ): string[] | undefined { const debugLogPath = nodePath.join( sitePath, DEBUG_LOG_RELATIVE_PATH ); diff --git a/apps/ui/src/components/create-site-form/index.tsx b/apps/ui/src/components/create-site-form/index.tsx index 1e25e20977..e4d9965da3 100644 --- a/apps/ui/src/components/create-site-form/index.tsx +++ b/apps/ui/src/components/create-site-form/index.tsx @@ -1,6 +1,10 @@ import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { generateCustomDomainFromSiteName } from '@studio/common/lib/domains'; -import { generatePassword } from '@studio/common/lib/passwords'; +import { + DEFAULT_ADMIN_EMAIL, + DEFAULT_ADMIN_USERNAME, + generatePassword, +} from '@studio/common/lib/passwords'; import { getLatestVersionLabel } from '@studio/common/lib/wordpress-versions'; import { RecommendedPHPVersion } from '@studio/common/types/php-versions'; import { BaseControl, CheckboxControl, TextControl } from '@wordpress/components'; @@ -113,9 +117,9 @@ function createDefaultFormData(): FormData { useCustomDomain: false, customDomain: '', enableHttps: false, - adminUsername: 'admin', + adminUsername: DEFAULT_ADMIN_USERNAME, adminPassword: generatePassword(), - adminEmail: 'admin@localhost.com', + adminEmail: DEFAULT_ADMIN_EMAIL, }; } diff --git a/apps/ui/src/components/site-overview-view/admin-section.tsx b/apps/ui/src/components/site-overview-view/admin-section.tsx index 01da26b931..193ffb7cc0 100644 --- a/apps/ui/src/components/site-overview-view/admin-section.tsx +++ b/apps/ui/src/components/site-overview-view/admin-section.tsx @@ -1,4 +1,8 @@ -import { decodePassword } from '@studio/common/lib/passwords'; +import { + DEFAULT_ADMIN_EMAIL, + DEFAULT_ADMIN_USERNAME, + decodeAdminPassword, +} from '@studio/common/lib/passwords'; import { __ } from '@wordpress/i18n'; import { CopyButton } from '@/components/copy-button'; import styles from './cards.module.css'; @@ -28,9 +32,9 @@ function CredentialRow( { } export function AdminSection( { site }: { site: SiteDetails } ) { - const username = site.adminUsername ?? 'admin'; - const password = site.adminPassword ? decodePassword( site.adminPassword ) : ''; - const email = site.adminEmail ?? 'admin@localhost.com'; + const username = site.adminUsername ?? DEFAULT_ADMIN_USERNAME; + const password = decodeAdminPassword( site.adminPassword ); + const email = site.adminEmail ?? DEFAULT_ADMIN_EMAIL; return ( diff --git a/apps/ui/src/components/site-settings-view/index.tsx b/apps/ui/src/components/site-settings-view/index.tsx index edae0770d0..b7ed80b140 100644 --- a/apps/ui/src/components/site-settings-view/index.tsx +++ b/apps/ui/src/components/site-settings-view/index.tsx @@ -1,6 +1,11 @@ import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { generateCustomDomainFromSiteName } from '@studio/common/lib/domains'; -import { decodePassword, encodePassword } from '@studio/common/lib/passwords'; +import { + DEFAULT_ADMIN_EMAIL, + DEFAULT_ADMIN_USERNAME, + decodeAdminPassword, + encodePassword, +} from '@studio/common/lib/passwords'; import { RecommendedPHPVersion } from '@studio/common/types/php-versions'; import { CheckboxControl } from '@wordpress/components'; import { DataForm, useFormValidity } from '@wordpress/dataviews'; @@ -79,9 +84,9 @@ function initialFormData( site: SiteDetails, installedWpVersion?: string ): Form useCustomDomain: Boolean( site.customDomain ), customDomain: site.customDomain ?? '', enableHttps: site.enableHttps ?? false, - adminUsername: site.adminUsername ?? 'admin', - adminPassword: decodePassword( site.adminPassword ?? '' ) || 'password', - adminEmail: site.adminEmail || 'admin@localhost.com', + adminUsername: site.adminUsername ?? DEFAULT_ADMIN_USERNAME, + adminPassword: decodeAdminPassword( site.adminPassword ), + adminEmail: site.adminEmail || DEFAULT_ADMIN_EMAIL, enableXdebug: site.enableXdebug ?? false, enableDebugLog: site.enableDebugLog ?? false, enableDebugDisplay: site.enableDebugDisplay ?? false, diff --git a/packages/common/lib/passwords.ts b/packages/common/lib/passwords.ts index 07d8a845b1..56ad8f0835 100644 --- a/packages/common/lib/passwords.ts +++ b/packages/common/lib/passwords.ts @@ -3,6 +3,25 @@ import { __, sprintf } from '@wordpress/i18n'; export { generatePassword }; +/** + * Defaults for a site's admin credentials, shared by both front ends and the CLI so an + * unset value resolves to the same thing everywhere. A site whose config predates these + * fields — or that only ever had one of them set — falls back to these on start. + */ +export const DEFAULT_ADMIN_USERNAME = 'admin'; +export const DEFAULT_ADMIN_PASSWORD = 'password'; +export const DEFAULT_ADMIN_EMAIL = 'admin@localhost.com'; + +/** + * Resolves a site's stored (encoded) admin password to plain text, falling back to the + * default when nothing is stored. Callers that send credentials to WordPress must use + * this rather than skipping the field: omitting the password fails the admin API when + * the username names a user that does not exist yet. + */ +export function decodeAdminPassword( encodedPassword?: string ): string { + return encodedPassword ? decodePassword( encodedPassword ) : DEFAULT_ADMIN_PASSWORD; +} + /** * Generates a random, Base64-encoded password. *