From becd91f440b6a354300f970998d587454352f3b0 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 3 Feb 2026 18:33:55 +0000 Subject: [PATCH 01/20] Add support for Blueprints login with UI fields Implements STU-829 by allowing users to set custom admin username/password either via Blueprints or through new fields in Advanced settings. Blueprint credentials are extracted and pre-filled in the form, with CLI args taking precedence. Co-Authored-By: Claude Haiku 4.5 --- cli/commands/site/create.ts | 33 +++++- cli/lib/types/wordpress-server-ipc.ts | 1 + cli/lib/wordpress-server-manager.ts | 8 ++ cli/wordpress-server-child.ts | 9 +- common/lib/blueprint-settings.ts | 32 ++++++ common/lib/blueprint-validation.ts | 5 - common/lib/mu-plugins.ts | 12 +- common/lib/passwords.ts | 10 ++ common/lib/site-events.ts | 1 + common/lib/tests/blueprint-settings.test.ts | 107 ++++++++++++++++++ common/lib/tests/blueprint-validation.test.ts | 24 +++- common/lib/tests/passwords.test.ts | 16 ++- src/components/password-control.tsx | 58 ++++++++++ src/hooks/tests/use-add-site.test.tsx | 4 +- src/hooks/use-add-site.ts | 6 +- src/hooks/use-site-details.tsx | 10 +- src/ipc-handlers.ts | 6 + .../add-site/components/create-site-form.tsx | 56 ++++++++- .../add-site/components/create-site.tsx | 3 + src/modules/add-site/index.tsx | 19 ++++ src/modules/cli/lib/cli-site-creator.ts | 10 ++ 21 files changed, 406 insertions(+), 24 deletions(-) create mode 100644 src/components/password-control.tsx diff --git a/cli/commands/site/create.ts b/cli/commands/site/create.ts index 09fcffc23b..96cf274f0d 100644 --- a/cli/commands/site/create.ts +++ b/cli/commands/site/create.ts @@ -10,6 +10,7 @@ import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION, } from 'common/constants'; +import { extractFormValuesFromBlueprint } from 'common/lib/blueprint-settings'; import { filterUnsupportedBlueprintFeatures, validateBlueprintData, @@ -24,7 +25,7 @@ import { } from 'common/lib/fs-utils'; import { DEFAULT_LOCALE } from 'common/lib/locale'; import { isOnline } from 'common/lib/network-utils'; -import { createPassword } from 'common/lib/passwords'; +import { createPassword, encodePassword } from 'common/lib/passwords'; import { portFinder } from 'common/lib/port-finder'; import { SITE_EVENTS } from 'common/lib/site-events'; import { sortSites } from 'common/lib/sort-sites'; @@ -69,6 +70,8 @@ type CreateCommandOptions = { contents: unknown; uri: string; }; + adminUsername?: string; + adminPassword?: string; noStart: boolean; skipBrowser: boolean; skipLogDetails: boolean; @@ -93,6 +96,7 @@ export async function runCommand( let blueprintUri: string | undefined; let blueprint: Blueprint | undefined; + let blueprintCredentials: { adminUsername?: string; adminPassword?: string } | null = null; if ( options.blueprint ) { const validation = await validateBlueprintData( options.blueprint.contents ); @@ -111,6 +115,15 @@ export async function runCommand( ); } + // Extract login credentials from blueprint before filtering + const formValues = extractFormValuesFromBlueprint( options.blueprint.contents as Blueprint ); + if ( formValues.adminUsername || formValues.adminPassword ) { + blueprintCredentials = { + adminUsername: formValues.adminUsername, + adminPassword: formValues.adminPassword, + }; + } + blueprintUri = options.blueprint.uri; blueprint = filterUnsupportedBlueprintFeatures( options.blueprint.contents as Record< string, unknown > @@ -185,7 +198,12 @@ export async function runCommand( const siteName = options.name || path.basename( sitePath ); const siteId = options.siteId || crypto.randomUUID(); - const adminPassword = createPassword(); + + // Determine admin credentials: CLI args > Blueprint > defaults + // External passwords need to be encoded; createPassword() already returns encoded + const adminUsername = options.adminUsername || blueprintCredentials?.adminUsername || undefined; + const externalPassword = options.adminPassword || blueprintCredentials?.adminPassword; + const adminPassword = externalPassword ? encodePassword( externalPassword ) : createPassword(); const setupSteps: StepDefinition[] = []; @@ -235,6 +253,7 @@ export async function runCommand( id: siteId, name: siteName, path: sitePath, + adminUsername, adminPassword, port, phpVersion: options.phpVersion, @@ -442,6 +461,14 @@ export const registerCommand = ( yargs: StudioArgv ) => { type: 'string', describe: __( 'Path or URL to Blueprint JSON file' ), } ) + .option( 'admin-username', { + type: 'string', + describe: __( 'Admin username (defaults to "admin")' ), + } ) + .option( 'admin-password', { + type: 'string', + describe: __( 'Admin password (auto-generated if not provided)' ), + } ) .option( 'start', { type: 'boolean', describe: __( 'Start the site after creation' ), @@ -466,6 +493,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { phpVersion: argv.php, customDomain: argv.domain, enableHttps: !! argv.https, + adminUsername: argv.adminUsername, + adminPassword: argv.adminPassword, noStart: ! argv.start, skipBrowser: !! argv.skipBrowser, skipLogDetails: !! argv.skipLogDetails, diff --git a/cli/lib/types/wordpress-server-ipc.ts b/cli/lib/types/wordpress-server-ipc.ts index d59ea74d57..ae4b76f7ca 100644 --- a/cli/lib/types/wordpress-server-ipc.ts +++ b/cli/lib/types/wordpress-server-ipc.ts @@ -8,6 +8,7 @@ const serverConfig = z.object( { phpVersion: z.string().optional(), wpVersion: z.string().optional(), absoluteUrl: z.string().optional(), + adminUsername: z.string().optional(), adminPassword: z.string().optional(), siteTitle: z.string().optional(), siteLanguage: z.string().optional(), diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index bbf89f1ce0..606848f2e1 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -92,6 +92,10 @@ export async function startWordPressServer( serverConfig.absoluteUrl = `${ protocol }://${ site.customDomain }`; } + if ( site.adminUsername ) { + serverConfig.adminUsername = site.adminUsername; + } + if ( site.adminPassword ) { serverConfig.adminPassword = site.adminPassword; } @@ -366,6 +370,10 @@ export async function runBlueprint( serverConfig.absoluteUrl = `${ protocol }://${ site.customDomain }`; } + if ( site.adminUsername ) { + serverConfig.adminUsername = site.adminUsername; + } + if ( site.adminPassword ) { serverConfig.adminPassword = site.adminPassword; } diff --git a/cli/wordpress-server-child.ts b/cli/wordpress-server-child.ts index d4c604363e..ce387b259b 100644 --- a/cli/wordpress-server-child.ts +++ b/cli/wordpress-server-child.ts @@ -92,13 +92,18 @@ function escapePhpString( str: string ): string { return str.replace( /\\/g, '\\\\' ).replace( /'/g, "\\'" ); } -async function setAdminPassword( server: RunCLIServer, adminPassword: string ): Promise< void > { +async function setAdminCredentials( + server: RunCLIServer, + adminPassword: string, + adminUsername?: string +): Promise< void > { await server.playground.request( { url: '/?studio-admin-api', method: 'POST', body: { action: 'set_admin_password', password: escapePhpString( decodePassword( adminPassword ) ), + ...( adminUsername && { username: adminUsername } ), }, } ); } @@ -287,7 +292,7 @@ const startServer = wrapWithStartingPromise( } if ( config.adminPassword ) { - await setAdminPassword( server, config.adminPassword ); + await setAdminCredentials( server, config.adminPassword, config.adminUsername ); } } catch ( error ) { server = null; diff --git a/common/lib/blueprint-settings.ts b/common/lib/blueprint-settings.ts index d0911e5425..4496ee22f4 100644 --- a/common/lib/blueprint-settings.ts +++ b/common/lib/blueprint-settings.ts @@ -4,6 +4,8 @@ type BlueprintSiteSettings = Partial< Pick< StoppedSiteDetails, 'phpVersion' | 'customDomain' | 'enableHttps' > > & { wpVersion?: string; + adminUsername?: string; + adminPassword?: string; }; /** @@ -34,6 +36,36 @@ export function extractFormValuesFromBlueprint( blueprintJson: Blueprint ): Blue // Invalid URL, skip } } + + // Extract login credentials from login step + const loginStep = blueprintJson.steps.find( + ( step: { step?: string } ) => step.step === 'login' + ); + if ( loginStep ) { + const { username, password } = loginStep as { username?: string; password?: string }; + if ( typeof username === 'string' ) { + values.adminUsername = username; + } + if ( typeof password === 'string' ) { + values.adminPassword = password; + } + } + } + + // Check top-level login property (shorthand syntax) + if ( blueprintJson.login !== undefined && blueprintJson.login !== true ) { + if ( typeof blueprintJson.login === 'object' && blueprintJson.login !== null ) { + const { username, password } = blueprintJson.login as { + username?: string; + password?: string; + }; + if ( typeof username === 'string' ) { + values.adminUsername = username; + } + if ( typeof password === 'string' ) { + values.adminPassword = password; + } + } } return values; diff --git a/common/lib/blueprint-validation.ts b/common/lib/blueprint-validation.ts index f84255d771..45394222c2 100644 --- a/common/lib/blueprint-validation.ts +++ b/common/lib/blueprint-validation.ts @@ -17,11 +17,6 @@ const UNSUPPORTED_BLUEPRINT_FEATURES: UnsupportedFeature[] = [ name: 'enableMultisite', reason: __( 'Multisite functionality is not currently supported in Studio.' ), }, - { - type: 'step', - name: 'login', - reason: __( 'Studio automatically creates and logs in the admin user during site creation.' ), - }, ]; /** diff --git a/common/lib/mu-plugins.ts b/common/lib/mu-plugins.ts index f697456ebb..23f724b164 100644 --- a/common/lib/mu-plugins.ts +++ b/common/lib/mu-plugins.ts @@ -416,18 +416,21 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { exit; } - $user = get_user_by( 'login', 'admin' ); + $username = ! empty( $_POST['username'] ) ? $_POST['username'] : 'admin'; + $user = get_user_by( 'login', $username ); if ( $user ) { wp_set_password( $_POST['password'], $user->ID ); } else { $user_data = array( - 'user_login' => 'admin', + 'user_login' => $username, 'user_pass' => $_POST['password'], - 'user_email' => 'admin@localhost.com', + 'user_email' => $username . '@localhost.com', 'role' => 'administrator', ); wp_insert_user( $user_data ); } + // Store the admin username in options for auto-login to use + update_option( 'studio_admin_username', $username ); $result = [ 'success' => true ]; break; @@ -470,7 +473,8 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { exit; } - $user = get_user_by( 'login', 'admin' ); + $username = get_option( 'studio_admin_username', 'admin' ); + $user = get_user_by( 'login', $username ); if ( ! $user ) { wp_die( 'Auto-login failed: admin user not found' ); } diff --git a/common/lib/passwords.ts b/common/lib/passwords.ts index 6825e460fc..25b5c45be6 100644 --- a/common/lib/passwords.ts +++ b/common/lib/passwords.ts @@ -9,6 +9,16 @@ export function createPassword(): string { return btoa( generatePassword() ); } +/** + * Encodes a plain-text password to Base64. + * + * @param password - The plain-text password to encode. + * @returns The Base64-encoded password. + */ +export function encodePassword( password: string ): string { + return btoa( password ); +} + /** * Decodes a Base64-encoded password. * diff --git a/common/lib/site-events.ts b/common/lib/site-events.ts index eab72fa9bc..2586c852f9 100644 --- a/common/lib/site-events.ts +++ b/common/lib/site-events.ts @@ -18,6 +18,7 @@ export const siteDetailsSchema = z.object( { phpVersion: z.string(), customDomain: z.string().optional(), enableHttps: z.boolean().optional(), + adminUsername: z.string().optional(), adminPassword: z.string().optional(), isWpAutoUpdating: z.boolean().optional(), autoStart: z.boolean().optional(), diff --git a/common/lib/tests/blueprint-settings.test.ts b/common/lib/tests/blueprint-settings.test.ts index b1e4d5aa47..896a29e101 100644 --- a/common/lib/tests/blueprint-settings.test.ts +++ b/common/lib/tests/blueprint-settings.test.ts @@ -143,6 +143,113 @@ describe( 'blueprint-settings', () => { expect( result.customDomain ).toBeUndefined(); } ); + + // Login credentials extraction tests + it( 'should not extract credentials when there is no login', () => { + const blueprint = { + steps: [ + { + step: 'installPlugin', + pluginData: { resource: 'wordpress.org/plugins', slug: 'akismet' }, + }, + ], + }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBeUndefined(); + expect( result.adminPassword ).toBeUndefined(); + } ); + + it( 'should not extract credentials for top-level login: true', () => { + const blueprint = { login: true }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBeUndefined(); + expect( result.adminPassword ).toBeUndefined(); + } ); + + it( 'should extract credentials from top-level login object', () => { + const blueprint = { + login: { username: 'customuser', password: 'custompass' }, + }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBe( 'customuser' ); + expect( result.adminPassword ).toBe( 'custompass' ); + } ); + + it( 'should extract username only from top-level login', () => { + const blueprint = { + login: { username: 'customuser' }, + }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBe( 'customuser' ); + expect( result.adminPassword ).toBeUndefined(); + } ); + + it( 'should extract password only from top-level login', () => { + const blueprint = { + login: { password: 'custompass' }, + }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBeUndefined(); + expect( result.adminPassword ).toBe( 'custompass' ); + } ); + + it( 'should extract credentials from login step in steps array', () => { + const blueprint = { + steps: [ + { + step: 'login', + username: 'stepuser', + password: 'steppass', + }, + ], + }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBe( 'stepuser' ); + expect( result.adminPassword ).toBe( 'steppass' ); + } ); + + it( 'should extract username only from login step', () => { + const blueprint = { + steps: [ + { + step: 'login', + username: 'stepuser', + }, + ], + }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBe( 'stepuser' ); + expect( result.adminPassword ).toBeUndefined(); + } ); + + it( 'should not extract credentials from login step without credentials', () => { + const blueprint = { + steps: [ + { + step: 'login', + }, + ], + }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBeUndefined(); + expect( result.adminPassword ).toBeUndefined(); + } ); + + it( 'should prefer top-level login credentials over steps array', () => { + const blueprint = { + login: { username: 'toplevel', password: 'toplevelpass' }, + steps: [ + { + step: 'login', + username: 'stepuser', + password: 'steppass', + }, + ], + }; + const result = extractFormValuesFromBlueprint( blueprint ); + expect( result.adminUsername ).toBe( 'toplevel' ); + expect( result.adminPassword ).toBe( 'toplevelpass' ); + } ); } ); describe( 'updateBlueprintWithFormValues', () => { diff --git a/common/lib/tests/blueprint-validation.test.ts b/common/lib/tests/blueprint-validation.test.ts index 6b2a84abbf..1102877b0e 100644 --- a/common/lib/tests/blueprint-validation.test.ts +++ b/common/lib/tests/blueprint-validation.test.ts @@ -66,10 +66,6 @@ describe( 'validateBlueprintData', () => { const blueprint = { landingPage: '/wp-admin/', steps: [ - { - step: 'login', - username: 'admin', - }, { step: 'enableMultisite', }, @@ -82,10 +78,28 @@ describe( 'validateBlueprintData', () => { if ( result.valid ) { expect( result.warnings.length ).toBeGreaterThan( 0 ); expect( result.warnings.map( ( w ) => w.feature ) ).toContain( 'landingPage' ); - expect( result.warnings.map( ( w ) => w.feature ) ).toContain( 'login' ); expect( result.warnings.map( ( w ) => w.feature ) ).toContain( 'enableMultisite' ); } } ); + + it( 'should accept a blueprint with login step (no warning)', async () => { + const blueprint = { + steps: [ + { + step: 'login', + username: 'admin', + }, + ], + }; + + const result = await validateBlueprintData( blueprint ); + + expect( result.valid ).toBe( true ); + if ( result.valid ) { + // login is now a supported feature, should not appear in warnings + expect( result.warnings.map( ( w ) => w.feature ) ).not.toContain( 'login' ); + } + } ); } ); describe( 'invalid blueprints', () => { diff --git a/common/lib/tests/passwords.test.ts b/common/lib/tests/passwords.test.ts index 0eabd17567..cd60bb274c 100644 --- a/common/lib/tests/passwords.test.ts +++ b/common/lib/tests/passwords.test.ts @@ -1,5 +1,5 @@ // Removed: globals are now available via vitest/globals in tsconfig -import { createPassword, decodePassword } from 'common/lib/passwords'; +import { createPassword, decodePassword, encodePassword } from 'common/lib/passwords'; describe( 'createPassword', () => { it( 'should return a Base64-encoded string', () => { @@ -14,6 +14,20 @@ describe( 'createPassword', () => { } ); } ); +describe( 'encodePassword', () => { + it( 'should encode the password to Base64', () => { + const plainPassword = 'test-password'; + const encoded = encodePassword( plainPassword ); + expect( encoded ).toBe( btoa( plainPassword ) ); + } ); + + it( 'should be reversible with decodePassword', () => { + const plainPassword = 'my-secret-pass!123'; + const encoded = encodePassword( plainPassword ); + expect( decodePassword( encoded ) ).toBe( plainPassword ); + } ); +} ); + describe( 'decodePassword', () => { it( 'should decode the password', () => { const mockPassword = 'test-password'; diff --git a/src/components/password-control.tsx b/src/components/password-control.tsx new file mode 100644 index 0000000000..8802830979 --- /dev/null +++ b/src/components/password-control.tsx @@ -0,0 +1,58 @@ +import { Icon } from '@wordpress/components'; +import { seen, unseen } from '@wordpress/icons'; +import { useState } from 'react'; +import { cx } from 'src/lib/cx'; + +interface PasswordControlProps { + id?: string; + value: string; + onChange: ( value: string ) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +const PasswordControl = ( { + id, + value, + onChange, + placeholder, + disabled, + className, +}: PasswordControlProps ) => { + const [ isVisible, setIsVisible ] = useState( false ); + + return ( +
+ onChange( e.target.value ) } + placeholder={ placeholder } + disabled={ disabled } + className={ cx( + 'w-full h-10 px-4 py-3 pr-10 rounded-sm border border-[#949494] outline-none', + 'focus:border-a8c-blue-50 focus:shadow-[0_0_0_0.5px_black] focus:shadow-a8c-blue-50', + 'transition-shadow transition-linear duration-100', + disabled && 'bg-a8c-gray-100 text-a8c-gray-600 border-a8c-gray-400 cursor-not-allowed' + ) } + /> + +
+ ); +}; + +export default PasswordControl; diff --git a/src/hooks/tests/use-add-site.test.tsx b/src/hooks/tests/use-add-site.test.tsx index ffa99d2306..0a83c6323b 100644 --- a/src/hooks/tests/use-add-site.test.tsx +++ b/src/hooks/tests/use-add-site.test.tsx @@ -192,7 +192,9 @@ describe( 'useAddSite', () => { undefined, // blueprint parameter '8.2', expect.any( Function ), - false + false, + undefined, // adminUsername + undefined // adminPassword ); } ); diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts index 92350b5f31..0c7180cea9 100644 --- a/src/hooks/use-add-site.ts +++ b/src/hooks/use-add-site.ts @@ -32,6 +32,8 @@ export interface CreateSiteFormValues { useCustomDomain: boolean; customDomain: string | null; enableHttps: boolean; + adminUsername?: string; + adminPassword?: string; } /** @@ -284,7 +286,9 @@ export function useAddSite() { } ); } }, - shouldSkipStart + shouldSkipStart, + formValues.adminUsername, + formValues.adminPassword ); } catch ( e ) { Sentry.captureException( e ); diff --git a/src/hooks/use-site-details.tsx b/src/hooks/use-site-details.tsx index 095cac91a7..354bca11ba 100644 --- a/src/hooks/use-site-details.tsx +++ b/src/hooks/use-site-details.tsx @@ -34,7 +34,9 @@ interface SiteDetailsContext { blueprint?: Blueprint, phpVersion?: string, callback?: ( site: SiteDetails ) => Promise< void >, - noStart?: boolean + noStart?: boolean, + adminUsername?: string, + adminPassword?: string ) => Promise< SiteDetails | void >; startServer: ( id: string ) => Promise< void >; stopServer: ( id: string ) => Promise< void >; @@ -261,7 +263,9 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { blueprint?: Blueprint, phpVersion?: string, callback?: ( site: SiteDetails ) => Promise< void >, - noStart?: boolean + noStart?: boolean, + adminUsername?: string, + adminPassword?: string ) => { // Function to handle error messages and cleanup const showError = ( error?: unknown, hasBlueprint?: boolean ) => { @@ -335,6 +339,8 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { siteId: tempSiteId, phpVersion, blueprint, + adminUsername, + adminPassword, noStart, } ); if ( ! newSite ) { diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index ae50b45644..94dd9ac942 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -246,6 +246,8 @@ export async function createSite( siteId?: string; phpVersion?: string; blueprint?: Blueprint; + adminUsername?: string; + adminPassword?: string; noStart?: boolean; } = {} ): Promise< SiteDetails > { @@ -257,6 +259,8 @@ export async function createSite( siteId: providedSiteId, blueprint, phpVersion, + adminUsername, + adminPassword, noStart = false, } = config; @@ -276,6 +280,8 @@ export async function createSite( enableHttps, siteId, blueprint: blueprint?.blueprint, + adminUsername, + adminPassword, noStart, }, { wpVersion, blueprint: blueprint?.blueprint } diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index afa7c5b89d..27eb993c1b 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -10,6 +10,7 @@ import { SupportedPHPVersions } from 'common/types/php-versions'; import Button from 'src/components/button'; import FolderIcon from 'src/components/folder-icon'; import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; +import PasswordControl from 'src/components/password-control'; import TextControlComponent from 'src/components/text-control'; import { WPVersionSelector } from 'src/components/wp-version-selector'; import { cx } from 'src/lib/cx'; @@ -43,6 +44,8 @@ export interface CreateSiteFormProps { blueprintSuggestedDomain?: string; /** Blueprint suggested HTTPS setting from defineSiteUrl step */ blueprintSuggestedHttps?: boolean; + /** Blueprint login credentials for pre-filling admin fields */ + blueprintCredentials?: { adminUsername?: string; adminPassword?: string }; /** Called when form is submitted */ onSubmit: ( values: CreateSiteFormValues ) => void; /** Called when form validity changes */ @@ -157,6 +160,7 @@ export const CreateSiteForm = ( { blueprintPreferredVersions, blueprintSuggestedDomain, blueprintSuggestedHttps, + blueprintCredentials, onSubmit, onValidityChange, formRef, @@ -177,6 +181,8 @@ export const CreateSiteForm = ( { const [ useCustomDomain, setUseCustomDomain ] = useState( false ); const [ customDomain, setCustomDomain ] = useState< string | null >( null ); const [ enableHttps, setEnableHttps ] = useState( false ); + const [ adminUsername, setAdminUsername ] = useState( blueprintCredentials?.adminUsername ?? '' ); + const [ adminPassword, setAdminPassword ] = useState( blueprintCredentials?.adminPassword ?? '' ); const [ pathError, setPathError ] = useState( '' ); const [ doesPathContainWordPress, setDoesPathContainWordPress ] = useState( false ); @@ -212,6 +218,16 @@ export const CreateSiteForm = ( { } }, [ defaultValues.phpVersion, defaultValues.wpVersion ] ); + // Sync admin credentials from Blueprint when they change + useEffect( () => { + if ( blueprintCredentials?.adminUsername !== undefined ) { + setAdminUsername( blueprintCredentials.adminUsername ); + } + if ( blueprintCredentials?.adminPassword !== undefined ) { + setAdminPassword( blueprintCredentials.adminPassword ); + } + }, [ blueprintCredentials?.adminUsername, blueprintCredentials?.adminPassword ] ); + useEffect( () => { if ( hasUserInteracted.current || ! blueprintSuggestedDomain ) { return; @@ -335,8 +351,20 @@ export const CreateSiteForm = ( { useCustomDomain, customDomain, enableHttps, + adminUsername: adminUsername || undefined, + adminPassword: adminPassword || undefined, } ), - [ siteName, sitePath, phpVersion, wpVersion, useCustomDomain, customDomain, enableHttps ] + [ + siteName, + sitePath, + phpVersion, + wpVersion, + useCustomDomain, + customDomain, + enableHttps, + adminUsername, + adminPassword, + ] ); const handleFormSubmit = useCallback( @@ -487,6 +515,32 @@ export const CreateSiteForm = ( { /> +
+
+ + +
+ +
+ + +
+
+ { showBlueprintVersionWarning && ( { __( 'Version differs from Blueprint recommendation' ) } diff --git a/src/modules/add-site/components/create-site.tsx b/src/modules/add-site/components/create-site.tsx index d35eab063c..2fa977629c 100644 --- a/src/modules/add-site/components/create-site.tsx +++ b/src/modules/add-site/components/create-site.tsx @@ -22,6 +22,7 @@ export interface CreateSiteProps { blueprintPreferredVersions?: BlueprintPreferredVersions; blueprintSuggestedDomain?: string; blueprintSuggestedHttps?: boolean; + blueprintCredentials?: { adminUsername?: string; adminPassword?: string }; originalDefaultVersions?: { phpVersion?: AllowedPHPVersion; wpVersion?: string; @@ -39,6 +40,7 @@ export default function CreateSite( { blueprintPreferredVersions, blueprintSuggestedDomain, blueprintSuggestedHttps, + blueprintCredentials, onSubmit, onValidityChange, formRef, @@ -59,6 +61,7 @@ export default function CreateSite( { blueprintPreferredVersions={ blueprintPreferredVersions } blueprintSuggestedDomain={ blueprintSuggestedDomain } blueprintSuggestedHttps={ blueprintSuggestedHttps } + blueprintCredentials={ blueprintCredentials } onSubmit={ onSubmit } onValidityChange={ onValidityChange } formRef={ formRef } diff --git a/src/modules/add-site/index.tsx b/src/modules/add-site/index.tsx index 2272b48ac3..708705ad09 100644 --- a/src/modules/add-site/index.tsx +++ b/src/modules/add-site/index.tsx @@ -72,6 +72,7 @@ interface NavigationContentProps { setBlueprintSuggestedDomain?: ( domain: string | undefined ) => void; blueprintSuggestedHttps?: boolean; setBlueprintSuggestedHttps?: ( https: boolean | undefined ) => void; + blueprintCredentials?: { adminUsername?: string; adminPassword?: string }; selectedRemoteSite?: SyncSite; setSelectedRemoteSite: ( site?: SyncSite ) => void; isDeeplinkFlow: boolean; @@ -103,6 +104,7 @@ function NavigationContent( props: NavigationContentProps ) { setBlueprintSuggestedDomain, blueprintSuggestedHttps, setBlueprintSuggestedHttps, + blueprintCredentials, selectedRemoteSite, setSelectedRemoteSite, isDeeplinkFlow, @@ -288,6 +290,7 @@ function NavigationContent( props: NavigationContentProps ) { onSubmit: onFormSubmit, onValidityChange, formRef, + blueprintCredentials: blueprintCredentials ?? undefined, }; return ( @@ -496,6 +499,21 @@ export function AddSiteModalContent( { // canSubmit is true if the form is initialized, has a name, and is valid (no errors) const canSubmit = formInitialized && defaultSiteName.trim().length > 0 && isFormValid; + // Extract login credentials from blueprint + const blueprintCredentials = useMemo( () => { + if ( ! selectedBlueprint?.blueprint ) { + return undefined; + } + const formValues = extractFormValuesFromBlueprint( selectedBlueprint.blueprint ); + if ( formValues.adminUsername || formValues.adminPassword ) { + return { + adminUsername: formValues.adminUsername, + adminPassword: formValues.adminPassword, + }; + } + return undefined; + }, [ selectedBlueprint ] ); + return ( Date: Wed, 4 Feb 2026 15:35:58 +0000 Subject: [PATCH 02/20] Add error handling for user creation and security note for CLI passwords --- cli/commands/site/create.ts | 4 +++- common/lib/mu-plugins.ts | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cli/commands/site/create.ts b/cli/commands/site/create.ts index 96cf274f0d..91d1e35620 100644 --- a/cli/commands/site/create.ts +++ b/cli/commands/site/create.ts @@ -467,7 +467,9 @@ export const registerCommand = ( yargs: StudioArgv ) => { } ) .option( 'admin-password', { type: 'string', - describe: __( 'Admin password (auto-generated if not provided)' ), + describe: __( + 'Admin password (auto-generated if not provided). Note: passwords in CLI arguments may be visible in process lists; consider using a Blueprint file for sensitive passwords.' + ), } ) .option( 'start', { type: 'boolean', diff --git a/common/lib/mu-plugins.ts b/common/lib/mu-plugins.ts index 23f724b164..3286c5ebdf 100644 --- a/common/lib/mu-plugins.ts +++ b/common/lib/mu-plugins.ts @@ -421,13 +421,24 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { if ( $user ) { wp_set_password( $_POST['password'], $user->ID ); } else { + // Sanitize email - fallback to admin@localhost.com if username produces invalid email + $email = sanitize_email( $username . '@localhost.com' ); + if ( empty( $email ) ) { + $email = 'admin@localhost.com'; + } $user_data = array( 'user_login' => $username, 'user_pass' => $_POST['password'], - 'user_email' => $username . '@localhost.com', + 'user_email' => $email, 'role' => 'administrator', ); - wp_insert_user( $user_data ); + $insert_result = wp_insert_user( $user_data ); + if ( is_wp_error( $insert_result ) ) { + status_header( 400 ); + header( 'Content-Type: application/json' ); + echo json_encode( [ 'error' => $insert_result->get_error_message() ] ); + exit; + } } // Store the admin username in options for auto-login to use update_option( 'studio_admin_username', $username ); From 33e613ec708b0d8a6d73c072d20ec88487ef7055 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 4 Feb 2026 18:27:47 +0000 Subject: [PATCH 03/20] Fix hardcoded admin username in CLI output and site settings UI --- cli/lib/site-utils.ts | 2 +- src/components/content-tab-settings.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/lib/site-utils.ts b/cli/lib/site-utils.ts index 6991d396ac..24b8378267 100644 --- a/cli/lib/site-utils.ts +++ b/cli/lib/site-utils.ts @@ -44,7 +44,7 @@ export async function openSiteInBrowser( site: SiteData ): Promise< void > { export function logSiteDetails( site: SiteData ): void { const siteUrl = getSiteUrl( site ); console.log( __( 'Site URL: ' ), siteUrl ); - console.log( __( 'Username: ' ), 'admin' ); + console.log( __( 'Username: ' ), site.adminUsername || 'admin' ); if ( site.adminPassword ) { console.log( __( 'Password: ' ), decodePassword( site.adminPassword ) ); } diff --git a/src/components/content-tab-settings.tsx b/src/components/content-tab-settings.tsx index 278200c6b2..0c77faf9dd 100644 --- a/src/components/content-tab-settings.tsx +++ b/src/components/content-tab-settings.tsx @@ -34,7 +34,7 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) const dispatch = useAppDispatch(); const { __ } = useI18n(); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); - const username = 'admin'; + const username = selectedSite.adminUsername || 'admin'; // Empty strings account for legacy sites lacking a stored password. const storedPassword = decodePassword( selectedSite.adminPassword ?? '' ); const password = storedPassword === '' ? 'password' : storedPassword; From cdd1dbd598b2fbfa18dfe1f1e0c73f2c24d444f6 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 4 Feb 2026 18:41:00 +0000 Subject: [PATCH 04/20] Add default values and validation for admin credentials in create site form --- common/lib/passwords.ts | 2 ++ src/ipc-types.d.ts | 1 + .../add-site/components/create-site-form.tsx | 36 +++++++++++++++---- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/common/lib/passwords.ts b/common/lib/passwords.ts index 25b5c45be6..ddfdcdcbb4 100644 --- a/common/lib/passwords.ts +++ b/common/lib/passwords.ts @@ -1,5 +1,7 @@ import { generatePassword } from '@automattic/generate-password'; +export { generatePassword }; + /** * Generates a random, Base64-encoded password. * diff --git a/src/ipc-types.d.ts b/src/ipc-types.d.ts index 6cbdf8272f..20b28e4e54 100644 --- a/src/ipc-types.d.ts +++ b/src/ipc-types.d.ts @@ -16,6 +16,7 @@ interface StoppedSiteDetails { isWpAutoUpdating?: boolean; customDomain?: string; enableHttps?: boolean; + adminUsername?: string; adminPassword?: string; tlsKey?: string; tlsCert?: string; diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index 27eb993c1b..4c68405085 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -6,6 +6,7 @@ import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useState, useEffect, useCallback, useMemo, useRef, RefObject } from 'react'; import { DEFAULT_WORDPRESS_VERSION } from 'common/constants'; import { generateCustomDomainFromSiteName, getDomainNameValidationError } from 'common/lib/domains'; +import { generatePassword } from 'common/lib/passwords'; import { SupportedPHPVersions } from 'common/types/php-versions'; import Button from 'src/components/button'; import FolderIcon from 'src/components/folder-icon'; @@ -181,8 +182,12 @@ export const CreateSiteForm = ( { const [ useCustomDomain, setUseCustomDomain ] = useState( false ); const [ customDomain, setCustomDomain ] = useState< string | null >( null ); const [ enableHttps, setEnableHttps ] = useState( false ); - const [ adminUsername, setAdminUsername ] = useState( blueprintCredentials?.adminUsername ?? '' ); - const [ adminPassword, setAdminPassword ] = useState( blueprintCredentials?.adminPassword ?? '' ); + const [ adminUsername, setAdminUsername ] = useState( + blueprintCredentials?.adminUsername ?? 'admin' + ); + const [ adminPassword, setAdminPassword ] = useState( + () => blueprintCredentials?.adminPassword ?? generatePassword() + ); const [ pathError, setPathError ] = useState( '' ); const [ doesPathContainWordPress, setDoesPathContainWordPress ] = useState( false ); @@ -268,7 +273,11 @@ export const CreateSiteForm = ( { return; } - const hasErrors = !! pathError || ( useCustomDomain && !! customDomainError ); + const hasErrors = + !! pathError || + ( useCustomDomain && !! customDomainError ) || + ! adminUsername.trim() || + ! adminPassword.trim(); const isValid = ! hasErrors; // Only notify if validity has actually changed @@ -277,7 +286,7 @@ export const CreateSiteForm = ( { onValidityChange( isValid ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ pathError, customDomainError, useCustomDomain ] ); + }, [ pathError, customDomainError, useCustomDomain, adminUsername, adminPassword ] ); const handleSiteNameChange = useCallback( async ( name: string ) => { @@ -376,7 +385,14 @@ export const CreateSiteForm = ( { ); const shouldShowCustomDomainError = useCustomDomain && customDomainError; - const errorCount = [ pathError, shouldShowCustomDomainError ].filter( Boolean ).length; + const adminUsernameError = ! adminUsername.trim() ? __( 'Admin username is required' ) : ''; + const adminPasswordError = ! adminPassword.trim() ? __( 'Admin password is required' ) : ''; + const errorCount = [ + pathError, + shouldShowCustomDomainError, + adminUsernameError, + adminPasswordError, + ].filter( Boolean ).length; const handleAdvancedSettingsClick = () => { setAdvancedSettingsVisible( ! isAdvancedSettingsVisible ); @@ -524,8 +540,11 @@ export const CreateSiteForm = ( { id="admin-username" value={ adminUsername } onChange={ setAdminUsername } - placeholder="admin" + className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } /> + { adminUsernameError && ( + { adminUsernameError } + ) }
@@ -536,8 +555,11 @@ export const CreateSiteForm = ( { id="admin-password" value={ adminPassword } onChange={ setAdminPassword } - placeholder={ __( 'Auto-generated' ) } + className={ adminPasswordError ? '[&_input]:!border-red-500' : '' } /> + { adminPasswordError && ( + { adminPasswordError } + ) }
From d7e07ff45d796528f18b9b6691fc4813209fbf6e Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 4 Feb 2026 19:33:40 +0000 Subject: [PATCH 05/20] Fix adminUsername not being persisted in toDiskFormat --- src/storage/user-data.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/storage/user-data.ts b/src/storage/user-data.ts index da35a653a2..8a4b960c36 100644 --- a/src/storage/user-data.ts +++ b/src/storage/user-data.ts @@ -156,6 +156,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { ( { id, path, + adminUsername, adminPassword, port, phpVersion, @@ -175,6 +176,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { id, name, path, + adminUsername, adminPassword, port, phpVersion, From 20ec434b6f6829e73972f122581ee85d1d76f431 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 4 Feb 2026 20:56:57 +0000 Subject: [PATCH 06/20] Fix password encoding, i18n, and credential editing guard --- common/lib/passwords.ts | 10 ++++++++-- src/components/password-control.tsx | 3 ++- .../add-site/components/create-site-form.tsx | 16 +++++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/common/lib/passwords.ts b/common/lib/passwords.ts index ddfdcdcbb4..37769debd3 100644 --- a/common/lib/passwords.ts +++ b/common/lib/passwords.ts @@ -13,20 +13,26 @@ export function createPassword(): string { /** * Encodes a plain-text password to Base64. + * Uses TextEncoder to properly handle Unicode characters. * * @param password - The plain-text password to encode. * @returns The Base64-encoded password. */ export function encodePassword( password: string ): string { - return btoa( password ); + const bytes = new TextEncoder().encode( password ); + const binary = String.fromCharCode( ...bytes ); + return btoa( binary ); } /** * Decodes a Base64-encoded password. + * Uses TextDecoder to properly handle Unicode characters. * * @param encodedPassword - The password to decode. * @returns The decoded password. */ export function decodePassword( encodedPassword: string ): string { - return atob( encodedPassword ).toString(); + const binary = atob( encodedPassword ); + const bytes = Uint8Array.from( binary, ( char ) => char.charCodeAt( 0 ) ); + return new TextDecoder().decode( bytes ); } diff --git a/src/components/password-control.tsx b/src/components/password-control.tsx index 8802830979..98f6da9705 100644 --- a/src/components/password-control.tsx +++ b/src/components/password-control.tsx @@ -1,4 +1,5 @@ import { Icon } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; import { seen, unseen } from '@wordpress/icons'; import { useState } from 'react'; import { cx } from 'src/lib/cx'; @@ -47,7 +48,7 @@ const PasswordControl = ( { 'hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-a8c-blue-50', disabled && 'cursor-not-allowed opacity-50' ) } - aria-label={ isVisible ? 'Hide password' : 'Show password' } + aria-label={ isVisible ? __( 'Hide password' ) : __( 'Show password' ) } > diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index 4c68405085..70d7f54bcd 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -198,6 +198,7 @@ export const CreateSiteForm = ( { // Prevent overwriting user input when defaultValues change asynchronously const hasUserInteracted = useRef( false ); + const hasUserEditedCredentials = useRef( false ); // Sync name/path only before user interaction (allows async loading) useEffect( () => { @@ -223,8 +224,11 @@ export const CreateSiteForm = ( { } }, [ defaultValues.phpVersion, defaultValues.wpVersion ] ); - // Sync admin credentials from Blueprint when they change + // Sync admin credentials from Blueprint when they change (only if user hasn't edited) useEffect( () => { + if ( hasUserEditedCredentials.current ) { + return; + } if ( blueprintCredentials?.adminUsername !== undefined ) { setAdminUsername( blueprintCredentials.adminUsername ); } @@ -539,7 +543,10 @@ export const CreateSiteForm = ( { { + hasUserEditedCredentials.current = true; + setAdminUsername( value ); + } } className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } /> { adminUsernameError && ( @@ -554,7 +561,10 @@ export const CreateSiteForm = ( { { + hasUserEditedCredentials.current = true; + setAdminPassword( value ); + } } className={ adminPasswordError ? '[&_input]:!border-red-500' : '' } /> { adminPasswordError && ( From 9e0cc1abba07153cc94a23023a4239c0ceb4b109 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 4 Feb 2026 21:06:53 +0000 Subject: [PATCH 07/20] Add username sanitization and PasswordControl tests --- common/lib/mu-plugins.ts | 2 +- .../tests/password-control.test.tsx | 73 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/components/tests/password-control.test.tsx diff --git a/common/lib/mu-plugins.ts b/common/lib/mu-plugins.ts index 3286c5ebdf..4b0d38303d 100644 --- a/common/lib/mu-plugins.ts +++ b/common/lib/mu-plugins.ts @@ -416,7 +416,7 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { exit; } - $username = ! empty( $_POST['username'] ) ? $_POST['username'] : 'admin'; + $username = ! empty( $_POST['username'] ) ? sanitize_user( $_POST['username'] ) : 'admin'; $user = get_user_by( 'login', $username ); if ( $user ) { wp_set_password( $_POST['password'], $user->ID ); diff --git a/src/components/tests/password-control.test.tsx b/src/components/tests/password-control.test.tsx new file mode 100644 index 0000000000..da40ed5d37 --- /dev/null +++ b/src/components/tests/password-control.test.tsx @@ -0,0 +1,73 @@ +// To run tests, execute `npm run test -- src/components/tests/password-control.test.tsx` from the root directory +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import PasswordControl from 'src/components/password-control'; + +describe( 'PasswordControl', () => { + test( 'renders password input with hidden text by default', () => { + render( {} } /> ); + const input = screen.getByDisplayValue( 'secret123' ); + expect( input ).toHaveAttribute( 'type', 'password' ); + } ); + + test( 'toggles password visibility when button is clicked', async () => { + const user = userEvent.setup(); + render( {} } /> ); + + const input = screen.getByDisplayValue( 'secret123' ); + const toggleButton = screen.getByRole( 'button', { name: 'Show password' } ); + + expect( input ).toHaveAttribute( 'type', 'password' ); + + await user.click( toggleButton ); + expect( input ).toHaveAttribute( 'type', 'text' ); + expect( screen.getByRole( 'button', { name: 'Hide password' } ) ).toBeVisible(); + + await user.click( toggleButton ); + expect( input ).toHaveAttribute( 'type', 'password' ); + } ); + + test( 'calls onChange when input value changes', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + render( ); + + const input = document.getElementById( 'test-password' ) as HTMLInputElement; + await user.type( input, 'newpassword' ); + + expect( handleChange ).toHaveBeenCalledTimes( 11 ); // One call per character + expect( handleChange ).toHaveBeenLastCalledWith( 'd' ); + } ); + + test( 'renders with custom id and placeholder', () => { + render( + {} } + placeholder="Enter password" + /> + ); + + const input = screen.getByPlaceholderText( 'Enter password' ); + expect( input ).toHaveAttribute( 'id', 'custom-password' ); + } ); + + test( 'disables input and button when disabled prop is true', () => { + render( {} } disabled /> ); + + const input = screen.getByDisplayValue( 'secret' ); + const toggleButton = screen.getByRole( 'button', { name: 'Show password' } ); + + expect( input ).toBeDisabled(); + expect( toggleButton ).toBeDisabled(); + } ); + + test( 'applies custom className', () => { + const { container } = render( + {} } className="custom-class" /> + ); + + expect( container.firstChild ).toHaveClass( 'custom-class' ); + } ); +} ); From fea69974841413cda43487e9b64e9e28275eee53 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 4 Feb 2026 21:21:38 +0000 Subject: [PATCH 08/20] Add sanitize_user fallback, username format validation, and Unicode tests --- common/lib/mu-plugins.ts | 4 ++++ common/lib/tests/passwords.test.ts | 12 ++++++++++++ src/modules/add-site/components/create-site-form.tsx | 12 +++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/common/lib/mu-plugins.ts b/common/lib/mu-plugins.ts index 4b0d38303d..c44bc7728e 100644 --- a/common/lib/mu-plugins.ts +++ b/common/lib/mu-plugins.ts @@ -417,6 +417,10 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { } $username = ! empty( $_POST['username'] ) ? sanitize_user( $_POST['username'] ) : 'admin'; + // Fallback to 'admin' if sanitize_user() strips all characters (e.g., !@#$%) + if ( empty( $username ) ) { + $username = 'admin'; + } $user = get_user_by( 'login', $username ); if ( $user ) { wp_set_password( $_POST['password'], $user->ID ); diff --git a/common/lib/tests/passwords.test.ts b/common/lib/tests/passwords.test.ts index cd60bb274c..9e95238cf3 100644 --- a/common/lib/tests/passwords.test.ts +++ b/common/lib/tests/passwords.test.ts @@ -26,6 +26,18 @@ describe( 'encodePassword', () => { const encoded = encodePassword( plainPassword ); expect( decodePassword( encoded ) ).toBe( plainPassword ); } ); + + it( 'should handle Unicode characters (Cyrillic, Chinese, emoji)', () => { + const unicodePassword = 'пароль密码🔐'; + const encoded = encodePassword( unicodePassword ); + expect( decodePassword( encoded ) ).toBe( unicodePassword ); + } ); + + it( 'should handle mixed ASCII and Unicode', () => { + const mixedPassword = 'admin123_пароль_密码'; + const encoded = encodePassword( mixedPassword ); + expect( decodePassword( encoded ) ).toBe( mixedPassword ); + } ); } ); describe( 'decodePassword', () => { diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index 70d7f54bcd..d2d536ce04 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -389,7 +389,17 @@ export const CreateSiteForm = ( { ); const shouldShowCustomDomainError = useCustomDomain && customDomainError; - const adminUsernameError = ! adminUsername.trim() ? __( 'Admin username is required' ) : ''; + const getAdminUsernameError = () => { + if ( ! adminUsername.trim() ) { + return __( 'Admin username is required' ); + } + // WordPress username format: alphanumeric, underscores, dots, @, and hyphens + if ( ! /^[a-zA-Z0-9_.@-]+$/.test( adminUsername ) ) { + return __( 'Username can only contain letters, numbers, and _.@- characters' ); + } + return ''; + }; + const adminUsernameError = getAdminUsernameError(); const adminPasswordError = ! adminPassword.trim() ? __( 'Admin password is required' ) : ''; const errorCount = [ pathError, From a8330fe63ac7c43d693c038aa3d04520c68068de Mon Sep 17 00:00:00 2001 From: bcotrim Date: Thu, 5 Feb 2026 11:51:25 +0000 Subject: [PATCH 09/20] Add credential editing to site settings UI and CLI set command --- cli/commands/site/create.ts | 8 +- cli/commands/site/set.ts | 53 ++++++++++++- cli/commands/site/tests/set.test.ts | 75 ++++++++++++++++++- cli/wordpress-server-child.ts | 2 +- common/lib/blueprint-settings.ts | 3 +- common/lib/mu-plugins.ts | 12 ++- common/lib/passwords.ts | 19 ++++- common/lib/site-needs-restart.ts | 13 +++- common/lib/tests/blueprint-validation.test.ts | 19 ----- src/components/password-control.tsx | 1 + src/ipc-handlers.ts | 14 ++++ .../add-site/components/create-site-form.tsx | 16 +--- src/modules/cli/lib/cli-site-editor.ts | 10 +++ .../site-settings/edit-site-details.tsx | 63 +++++++++++++++- 14 files changed, 260 insertions(+), 48 deletions(-) diff --git a/cli/commands/site/create.ts b/cli/commands/site/create.ts index 91d1e35620..8b6ddbbd2d 100644 --- a/cli/commands/site/create.ts +++ b/cli/commands/site/create.ts @@ -25,7 +25,7 @@ import { } from 'common/lib/fs-utils'; import { DEFAULT_LOCALE } from 'common/lib/locale'; import { isOnline } from 'common/lib/network-utils'; -import { createPassword, encodePassword } from 'common/lib/passwords'; +import { createPassword, encodePassword, validateAdminUsername } from 'common/lib/passwords'; import { portFinder } from 'common/lib/port-finder'; import { SITE_EVENTS } from 'common/lib/site-events'; import { sortSites } from 'common/lib/sort-sites'; @@ -202,6 +202,12 @@ export async function runCommand( // Determine admin credentials: CLI args > Blueprint > defaults // External passwords need to be encoded; createPassword() already returns encoded const adminUsername = options.adminUsername || blueprintCredentials?.adminUsername || undefined; + if ( adminUsername ) { + const usernameError = validateAdminUsername( adminUsername ); + if ( usernameError ) { + throw new LoggerError( usernameError ); + } + } const externalPassword = options.adminPassword || blueprintCredentials?.adminPassword; const adminPassword = externalPassword ? encodePassword( externalPassword ) : createPassword(); diff --git a/cli/commands/site/set.ts b/cli/commands/site/set.ts index 79fcd1a24d..229a7a9aff 100644 --- a/cli/commands/site/set.ts +++ b/cli/commands/site/set.ts @@ -3,6 +3,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from 'common/constants'; import { getDomainNameValidationError } from 'common/lib/domains'; import { arePathsEqual } from 'common/lib/fs-utils'; +import { encodePassword, validateAdminUsername } from 'common/lib/passwords'; import { SITE_EVENTS } from 'common/lib/site-events'; import { siteNeedsRestart } from 'common/lib/site-needs-restart'; import { @@ -44,10 +45,12 @@ export interface SetCommandOptions { php?: string; wp?: string; xdebug?: boolean; + adminUsername?: string; + adminPassword?: string; } export async function runCommand( sitePath: string, options: SetCommandOptions ): Promise< void > { - const { name, domain, https, php, wp, xdebug } = options; + const { name, domain, https, php, wp, xdebug, adminUsername, adminPassword } = options; if ( name === undefined && @@ -55,10 +58,14 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) https === undefined && php === undefined && wp === undefined && - xdebug === undefined + xdebug === undefined && + adminUsername === undefined && + adminPassword === undefined ) { throw new LoggerError( - __( 'At least one option (--name, --domain, --https, --php, --wp, --xdebug) is required.' ) + __( + 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password) is required.' + ) ); } @@ -66,6 +73,17 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) throw new LoggerError( __( 'Site name cannot be empty.' ) ); } + if ( adminUsername !== undefined ) { + const usernameError = validateAdminUsername( adminUsername ); + if ( usernameError ) { + throw new LoggerError( usernameError ); + } + } + + if ( adminPassword !== undefined && ! adminPassword.trim() ) { + throw new LoggerError( __( 'Admin password cannot be empty.' ) ); + } + try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); let site = await getSiteByFolder( sitePath ); @@ -112,9 +130,19 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) const phpChanged = php !== undefined && php !== site.phpVersion; const wpChanged = wp !== undefined; const xdebugChanged = xdebug !== undefined && xdebug !== site.enableXdebug; + const adminUsernameChanged = + adminUsername !== undefined && adminUsername !== ( site.adminUsername ?? 'admin' ); + const adminPasswordChanged = adminPassword !== undefined; + const credentialsChanged = adminUsernameChanged || adminPasswordChanged; const hasChanges = - nameChanged || domainChanged || httpsChanged || phpChanged || wpChanged || xdebugChanged; + nameChanged || + domainChanged || + httpsChanged || + phpChanged || + wpChanged || + xdebugChanged || + credentialsChanged; if ( ! hasChanges ) { throw new LoggerError( __( 'No changes to apply. The site already has the specified settings.' ) @@ -127,6 +155,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) phpChanged, wpChanged, xdebugChanged, + credentialsChanged, } ); const oldDomain = site.customDomain; @@ -153,6 +182,12 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) if ( xdebugChanged ) { foundSite.enableXdebug = xdebug; } + if ( adminUsernameChanged ) { + foundSite.adminUsername = adminUsername!; + } + if ( adminPasswordChanged ) { + foundSite.adminPassword = encodePassword( adminPassword! ); + } await saveAppdata( appdata ); site = foundSite; @@ -287,6 +322,14 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'xdebug', { type: 'boolean', description: __( 'Enable Xdebug' ), + } ) + .option( 'admin-username', { + type: 'string', + description: __( 'Admin username' ), + } ) + .option( 'admin-password', { + type: 'string', + description: __( 'Admin password' ), } ); }, handler: async ( argv ) => { @@ -298,6 +341,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { php: argv.php, wp: argv.wp, xdebug: argv.xdebug, + adminUsername: argv.adminUsername, + adminPassword: argv.adminPassword, } ); } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/cli/commands/site/tests/set.test.ts b/cli/commands/site/tests/set.test.ts index c764c87f33..a346b5e35d 100644 --- a/cli/commands/site/tests/set.test.ts +++ b/cli/commands/site/tests/set.test.ts @@ -1,6 +1,7 @@ import { StreamedPHPResponse } from '@php-wasm/universal'; import { getDomainNameValidationError } from 'common/lib/domains'; import { arePathsEqual } from 'common/lib/fs-utils'; +import { encodePassword } from 'common/lib/passwords'; import { vi } from 'vitest'; import { getSiteByFolder, @@ -98,7 +99,7 @@ describe( 'CLI: studio site set', () => { describe( 'Validation', () => { it( 'should throw when no options provided', async () => { await expect( runCommand( testSitePath, {} ) ).rejects.toThrow( - 'At least one option (--name, --domain, --https, --php, --wp, --xdebug) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password) is required.' ); } ); @@ -423,6 +424,78 @@ describe( 'CLI: studio site set', () => { } ); } ); + describe( 'Admin credential changes', () => { + it( 'should update admin username and restart running site', async () => { + vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); + + await runCommand( testSitePath, { adminUsername: 'newadmin' } ); + + const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); + expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); + expect( startWordPressServer ).toHaveBeenCalled(); + } ); + + it( 'should update admin password and restart running site', async () => { + vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); + + await runCommand( testSitePath, { adminPassword: 'newpass123' } ); + + const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].adminPassword ).toBe( encodePassword( 'newpass123' ) ); + expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); + expect( startWordPressServer ).toHaveBeenCalled(); + } ); + + it( 'should not restart stopped site when credentials change', async () => { + await runCommand( testSitePath, { adminUsername: 'newadmin' } ); + + const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + } ); + + it( 'should throw when admin username is empty', async () => { + await expect( runCommand( testSitePath, { adminUsername: ' ' } ) ).rejects.toThrow( + 'Admin username cannot be empty.' + ); + } ); + + it( 'should throw when admin username has invalid characters', async () => { + await expect( runCommand( testSitePath, { adminUsername: 'bad user!' } ) ).rejects.toThrow( + 'Username can only contain letters, numbers, and _.@- characters' + ); + } ); + + it( 'should throw when admin username exceeds 60 characters', async () => { + const longUsername = 'a'.repeat( 61 ); + await expect( runCommand( testSitePath, { adminUsername: longUsername } ) ).rejects.toThrow( + 'Username must be 60 characters or fewer.' + ); + } ); + + it( 'should throw when admin password is empty', async () => { + await expect( runCommand( testSitePath, { adminPassword: ' ' } ) ).rejects.toThrow( + 'Admin password cannot be empty.' + ); + } ); + + it( 'should throw when username has not changed', async () => { + await expect( runCommand( testSitePath, { adminUsername: 'admin' } ) ).rejects.toThrow( + 'No changes to apply.' + ); + } ); + + it( 'should update both credentials at once', async () => { + await runCommand( testSitePath, { adminUsername: 'newadmin', adminPassword: 'newpass' } ); + + const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); + expect( savedAppdata.sites[ 0 ].adminPassword ).toBe( encodePassword( 'newpass' ) ); + } ); + } ); + describe( 'Error handling', () => { it( 'should throw when site not found', async () => { vi.mocked( getSiteByFolder ).mockRejectedValue( new Error( 'Site not found' ) ); diff --git a/cli/wordpress-server-child.ts b/cli/wordpress-server-child.ts index ce387b259b..83aa1e2b7e 100644 --- a/cli/wordpress-server-child.ts +++ b/cli/wordpress-server-child.ts @@ -103,7 +103,7 @@ async function setAdminCredentials( body: { action: 'set_admin_password', password: escapePhpString( decodePassword( adminPassword ) ), - ...( adminUsername && { username: adminUsername } ), + ...( adminUsername && { username: escapePhpString( adminUsername ) } ), }, } ); } diff --git a/common/lib/blueprint-settings.ts b/common/lib/blueprint-settings.ts index 4496ee22f4..35169dbbea 100644 --- a/common/lib/blueprint-settings.ts +++ b/common/lib/blueprint-settings.ts @@ -52,7 +52,8 @@ export function extractFormValuesFromBlueprint( blueprintJson: Blueprint ): Blue } } - // Check top-level login property (shorthand syntax) + // Check top-level login property (shorthand syntax). + // login: true just enables auto-login with defaults, login: false disables it — neither has credentials to extract. if ( blueprintJson.login !== undefined && blueprintJson.login !== true ) { if ( typeof blueprintJson.login === 'object' && blueprintJson.login !== null ) { const { username, password } = blueprintJson.login as { diff --git a/common/lib/mu-plugins.ts b/common/lib/mu-plugins.ts index c44bc7728e..24a48a8534 100644 --- a/common/lib/mu-plugins.ts +++ b/common/lib/mu-plugins.ts @@ -421,15 +421,19 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { if ( empty( $username ) ) { $username = 'admin'; } + $user = get_user_by( 'login', $username ); if ( $user ) { wp_set_password( $_POST['password'], $user->ID ); } else { - // Sanitize email - fallback to admin@localhost.com if username produces invalid email - $email = sanitize_email( $username . '@localhost.com' ); - if ( empty( $email ) ) { - $email = 'admin@localhost.com'; + // Generate a unique email to avoid conflicts with existing users + $email = 'admin@localhost.com'; + $counter = 1; + while ( email_exists( $email ) ) { + $email = 'admin' . $counter . '@localhost.com'; + $counter++; } + $user_data = array( 'user_login' => $username, 'user_pass' => $_POST['password'], diff --git a/common/lib/passwords.ts b/common/lib/passwords.ts index 37769debd3..f9752391d5 100644 --- a/common/lib/passwords.ts +++ b/common/lib/passwords.ts @@ -1,4 +1,5 @@ import { generatePassword } from '@automattic/generate-password'; +import { __ } from '@wordpress/i18n'; export { generatePassword }; @@ -8,7 +9,7 @@ export { generatePassword }; * @returns The Base64-encoded password. */ export function createPassword(): string { - return btoa( generatePassword() ); + return encodePassword( generatePassword() ); } /** @@ -36,3 +37,19 @@ export function decodePassword( encodedPassword: string ): string { const bytes = Uint8Array.from( binary, ( char ) => char.charCodeAt( 0 ) ); return new TextDecoder().decode( bytes ); } + +/** + * Validates an admin username and returns an error message, or empty string if valid. + */ +export function validateAdminUsername( username: string ): string { + if ( ! username.trim() ) { + return __( 'Admin username cannot be empty.' ); + } + if ( ! /^[a-zA-Z0-9_.@-]+$/.test( username ) ) { + return __( 'Username can only contain letters, numbers, and _.@- characters' ); + } + if ( username.length > 60 ) { + return __( 'Username must be 60 characters or fewer.' ); + } + return ''; +} diff --git a/common/lib/site-needs-restart.ts b/common/lib/site-needs-restart.ts index 90a6592cb5..f455e790d4 100644 --- a/common/lib/site-needs-restart.ts +++ b/common/lib/site-needs-restart.ts @@ -4,10 +4,19 @@ export interface SiteSettingChanges { phpChanged?: boolean; wpChanged?: boolean; xdebugChanged?: boolean; + credentialsChanged?: boolean; } export function siteNeedsRestart( changes: SiteSettingChanges ): boolean { - const { domainChanged, httpsChanged, phpChanged, wpChanged, xdebugChanged } = changes; + const { domainChanged, httpsChanged, phpChanged, wpChanged, xdebugChanged, credentialsChanged } = + changes; - return !! ( domainChanged || httpsChanged || phpChanged || wpChanged || xdebugChanged ); + return !! ( + domainChanged || + httpsChanged || + phpChanged || + wpChanged || + xdebugChanged || + credentialsChanged + ); } diff --git a/common/lib/tests/blueprint-validation.test.ts b/common/lib/tests/blueprint-validation.test.ts index 1102877b0e..c4d5cbe059 100644 --- a/common/lib/tests/blueprint-validation.test.ts +++ b/common/lib/tests/blueprint-validation.test.ts @@ -81,25 +81,6 @@ describe( 'validateBlueprintData', () => { expect( result.warnings.map( ( w ) => w.feature ) ).toContain( 'enableMultisite' ); } } ); - - it( 'should accept a blueprint with login step (no warning)', async () => { - const blueprint = { - steps: [ - { - step: 'login', - username: 'admin', - }, - ], - }; - - const result = await validateBlueprintData( blueprint ); - - expect( result.valid ).toBe( true ); - if ( result.valid ) { - // login is now a supported feature, should not appear in warnings - expect( result.warnings.map( ( w ) => w.feature ) ).not.toContain( 'login' ); - } - } ); } ); describe( 'invalid blueprints', () => { diff --git a/src/components/password-control.tsx b/src/components/password-control.tsx index 98f6da9705..fd68c50ab4 100644 --- a/src/components/password-control.tsx +++ b/src/components/password-control.tsx @@ -32,6 +32,7 @@ const PasswordControl = ( { onChange={ ( e ) => onChange( e.target.value ) } placeholder={ placeholder } disabled={ disabled } + autoComplete="new-password" className={ cx( 'w-full h-10 px-4 py-3 pr-10 rounded-sm border border-[#949494] outline-none', 'focus:border-a8c-blue-50 focus:shadow-[0_0_0_0.5px_black] focus:shadow-a8c-blue-50', diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 94dd9ac942..7d56545fde 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -31,6 +31,7 @@ import { import { getWordPressVersion } from 'common/lib/get-wordpress-version'; import { isErrnoException } from 'common/lib/is-errno-exception'; import { getAuthenticationUrl } from 'common/lib/oauth'; +import { decodePassword, encodePassword } from 'common/lib/passwords'; import { isWordPressDevVersion } from 'common/lib/wordpress-version-utils'; import { Snapshot } from 'common/types/snapshot'; import { StatsGroup, StatsMetric } from 'common/types/stats'; @@ -381,6 +382,19 @@ export async function updateSite( options.xdebug = updatedSite.enableXdebug ?? false; } + if ( ( updatedSite.adminUsername ?? 'admin' ) !== ( currentSite.adminUsername ?? 'admin' ) ) { + options.adminUsername = updatedSite.adminUsername; + } + + const defaultEncodedPassword = encodePassword( 'password' ); + if ( + ( updatedSite.adminPassword ?? defaultEncodedPassword ) !== + ( currentSite.adminPassword ?? defaultEncodedPassword ) + ) { + // CLI set expects plain text password (it encodes before saving) + options.adminPassword = decodePassword( updatedSite.adminPassword ?? defaultEncodedPassword ); + } + const hasCliChanges = Object.keys( options ).length > 2; if ( hasCliChanges ) { diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index d2d536ce04..b2f1b2d3d6 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -6,7 +6,7 @@ import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useState, useEffect, useCallback, useMemo, useRef, RefObject } from 'react'; import { DEFAULT_WORDPRESS_VERSION } from 'common/constants'; import { generateCustomDomainFromSiteName, getDomainNameValidationError } from 'common/lib/domains'; -import { generatePassword } from 'common/lib/passwords'; +import { generatePassword, validateAdminUsername } from 'common/lib/passwords'; import { SupportedPHPVersions } from 'common/types/php-versions'; import Button from 'src/components/button'; import FolderIcon from 'src/components/folder-icon'; @@ -231,9 +231,11 @@ export const CreateSiteForm = ( { } if ( blueprintCredentials?.adminUsername !== undefined ) { setAdminUsername( blueprintCredentials.adminUsername ); + setAdvancedSettingsVisible( true ); } if ( blueprintCredentials?.adminPassword !== undefined ) { setAdminPassword( blueprintCredentials.adminPassword ); + setAdvancedSettingsVisible( true ); } }, [ blueprintCredentials?.adminUsername, blueprintCredentials?.adminPassword ] ); @@ -389,17 +391,7 @@ export const CreateSiteForm = ( { ); const shouldShowCustomDomainError = useCustomDomain && customDomainError; - const getAdminUsernameError = () => { - if ( ! adminUsername.trim() ) { - return __( 'Admin username is required' ); - } - // WordPress username format: alphanumeric, underscores, dots, @, and hyphens - if ( ! /^[a-zA-Z0-9_.@-]+$/.test( adminUsername ) ) { - return __( 'Username can only contain letters, numbers, and _.@- characters' ); - } - return ''; - }; - const adminUsernameError = getAdminUsernameError(); + const adminUsernameError = validateAdminUsername( adminUsername ); const adminPasswordError = ! adminPassword.trim() ? __( 'Admin password is required' ) : ''; const errorCount = [ pathError, diff --git a/src/modules/cli/lib/cli-site-editor.ts b/src/modules/cli/lib/cli-site-editor.ts index a027c51a31..96a895ad6a 100644 --- a/src/modules/cli/lib/cli-site-editor.ts +++ b/src/modules/cli/lib/cli-site-editor.ts @@ -17,6 +17,8 @@ export interface EditSiteOptions { php?: string; wp?: string; xdebug?: boolean; + adminUsername?: string; + adminPassword?: string; } export async function editSiteViaCli( options: EditSiteOptions ): Promise< void > { @@ -79,5 +81,13 @@ function buildCliArgs( options: EditSiteOptions ): string[] { args.push( options.xdebug ? '--xdebug' : '--no-xdebug' ); } + if ( options.adminUsername !== undefined ) { + args.push( '--admin-username', options.adminUsername ); + } + + if ( options.adminPassword !== undefined ) { + args.push( '--admin-password', options.adminPassword ); + } + return args; } diff --git a/src/modules/site-settings/edit-site-details.tsx b/src/modules/site-settings/edit-site-details.tsx index b0377d36da..227c458b0d 100644 --- a/src/modules/site-settings/edit-site-details.tsx +++ b/src/modules/site-settings/edit-site-details.tsx @@ -5,12 +5,14 @@ import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useCallback, useEffect, useState } from 'react'; import { DEFAULT_PHP_VERSION } from 'common/constants'; import { generateCustomDomainFromSiteName, getDomainNameValidationError } from 'common/lib/domains'; +import { decodePassword, encodePassword, validateAdminUsername } from 'common/lib/passwords'; import { siteNeedsRestart } from 'common/lib/site-needs-restart'; import { SupportedPHPVersions } from 'common/types/php-versions'; import Button from 'src/components/button'; import { ErrorInformation } from 'src/components/error-information'; import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; import Modal from 'src/components/modal'; +import PasswordControl from 'src/components/password-control'; import TextControlComponent from 'src/components/text-control'; import { Tooltip } from 'src/components/tooltip'; import { WPVersionSelector } from 'src/components/wp-version-selector'; @@ -36,6 +38,10 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = const [ needsRestart, setNeedsRestart ] = useState( false ); const [ enableXdebug, setEnableXdebug ] = useState( selectedSite?.enableXdebug ?? false ); const [ xdebugEnabledSite, setXdebugEnabledSite ] = useState< SiteDetails | null >( null ); + const [ adminUsername, setAdminUsername ] = useState( selectedSite?.adminUsername ?? 'admin' ); + const [ adminPassword, setAdminPassword ] = useState( + () => decodePassword( selectedSite?.adminPassword ?? '' ) || 'password' + ); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); const closeModal = useCallback( () => { @@ -90,6 +96,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = const generatedDomainName = generateCustomDomainFromSiteName( siteName ); const usedCustomDomain = ! useCustomDomain ? customDomain : undefined; + const adminUsernameError = validateAdminUsername( adminUsername ); + const adminPasswordError = ! adminPassword.trim() ? __( 'Admin password is required' ) : ''; const isFormUnchanged = !! selectedSite && selectedSite.name === siteName && @@ -98,9 +106,15 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = Boolean( selectedSite.customDomain ) === useCustomDomain && usedCustomDomain === customDomain && !! selectedSite.enableHttps === ( !! usedCustomDomain && enableHttps ) && - !! selectedSite.enableXdebug === enableXdebug; + !! selectedSite.enableXdebug === enableXdebug && + ( selectedSite.adminUsername ?? 'admin' ) === adminUsername && + ( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ) === adminPassword; const hasValidationErrors = - ! selectedSite || ! siteName.trim() || ( useCustomDomain && !! customDomainError ); + ! selectedSite || + ! siteName.trim() || + ( useCustomDomain && !! customDomainError ) || + !! adminUsernameError || + !! adminPasswordError; const resetFormState = useCallback( () => { if ( ! selectedSite ) { @@ -115,6 +129,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = setErrorUpdatingWpVersion( null ); setEnableHttps( selectedSite.enableHttps ?? false ); setEnableXdebug( selectedSite.enableXdebug ?? false ); + setAdminUsername( selectedSite.adminUsername ?? 'admin' ); + setAdminPassword( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ); }, [ selectedSite, getEffectiveWpVersion ] ); const onSiteEdit = async ( event: FormEvent ) => { @@ -133,6 +149,9 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = ( useCustomDomain && customDomain !== selectedSite.customDomain ); const hasHttpsChanged = useCustomDomain && enableHttps !== ( selectedSite.enableHttps ?? false ); + const hasCredentialsChanged = + adminUsername !== ( selectedSite.adminUsername ?? 'admin' ) || + adminPassword !== ( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ); const needsRestart = selectedSite.running && @@ -142,6 +161,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = phpChanged: hasPhpVersionChanged, wpChanged: hasWpVersionChanged, xdebugChanged: hasXdebugChanged, + credentialsChanged: hasCredentialsChanged, } ); setNeedsRestart( needsRestart ); @@ -161,6 +181,9 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = customDomain: usedCustomDomain, enableHttps: !! usedCustomDomain && enableHttps, enableXdebug, + adminUsername, + // Encode for IPC storage; IPC handler decodes back to plain text for the CLI set command + adminPassword: encodePassword( adminPassword ), }, hasWpVersionChanged ? selectedWpVersion : undefined ); @@ -363,6 +386,42 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = + +
+ { __( 'Admin credentials' ) } +
+
+ + + { adminUsernameError && ( + { adminUsernameError } + ) } +
+
+ + + { adminPasswordError && ( + { adminPasswordError } + ) } +
+
+
From df065093b6092d471c78331446eaaa31a2ee0cf6 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Thu, 5 Feb 2026 12:01:41 +0000 Subject: [PATCH 10/20] Fix add-site test assertions for credential args --- src/modules/add-site/tests/add-site.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/modules/add-site/tests/add-site.test.tsx b/src/modules/add-site/tests/add-site.test.tsx index 25f3b41661..b2f68a8fa7 100644 --- a/src/modules/add-site/tests/add-site.test.tsx +++ b/src/modules/add-site/tests/add-site.test.tsx @@ -229,7 +229,9 @@ describe( 'AddSite', () => { undefined, // blueprint parameter '8.3', expect.any( Function ), - false + false, + 'admin', + expect.any( String ) ); } ); } ); @@ -446,7 +448,9 @@ describe( 'AddSite', () => { undefined, // blueprint parameter '8.3', expect.any( Function ), - false + false, + 'admin', + expect.any( String ) ); } ); } ); From a60cc403498ae48b84806d647d459a0f511c3b16 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 6 Feb 2026 19:19:25 +0000 Subject: [PATCH 11/20] Fix hardcoded admin username in site status command --- cli/commands/site/status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/commands/site/status.ts b/cli/commands/site/status.ts index 17b4e31e09..fcf8109b2d 100644 --- a/cli/commands/site/status.ts +++ b/cli/commands/site/status.ts @@ -51,7 +51,7 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) { key: __( 'PHP version' ), value: site.phpVersion }, { key: __( 'WP version' ), value: wpVersion }, { key: __( 'Xdebug' ), value: xdebugStatus }, - { key: __( 'Admin username' ), value: 'admin' }, + { key: __( 'Admin username' ), value: site.adminUsername ?? 'admin' }, { key: __( 'Admin password' ), value: site.adminPassword ? decodePassword( site.adminPassword ) : undefined, From ab2fdc4d9aceb6aeda683becb8bb6f609a542886 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Sun, 8 Feb 2026 12:12:55 +0000 Subject: [PATCH 12/20] Add admin email field to site settings and CLI --- cli/commands/site/create.ts | 22 ++++++++++- cli/commands/site/set.ts | 29 ++++++++++++--- cli/commands/site/status.ts | 1 + cli/commands/site/tests/set.test.ts | 36 +++++++++++++++++- cli/lib/types/wordpress-server-ipc.ts | 1 + cli/lib/wordpress-server-manager.ts | 8 ++++ cli/wordpress-server-child.ts | 11 +++++- common/lib/mu-plugins.ts | 17 ++++++--- common/lib/passwords.ts | 13 +++++++ common/lib/site-events.ts | 1 + src/components/content-tab-settings.tsx | 10 +++++ src/hooks/tests/use-add-site.test.tsx | 3 +- src/hooks/use-add-site.ts | 4 +- src/hooks/use-site-details.tsx | 7 +++- src/ipc-handlers.ts | 15 ++++++-- src/ipc-types.d.ts | 1 + .../add-site/components/create-site-form.tsx | 26 ++++++++++++- src/modules/add-site/tests/add-site.test.tsx | 6 ++- src/modules/cli/lib/cli-site-creator.ts | 5 +++ src/modules/cli/lib/cli-site-editor.ts | 5 +++ .../site-settings/edit-site-details.tsx | 37 +++++++++++++++++-- src/storage/user-data.ts | 2 + 22 files changed, 231 insertions(+), 29 deletions(-) diff --git a/cli/commands/site/create.ts b/cli/commands/site/create.ts index 8b6ddbbd2d..16f7537655 100644 --- a/cli/commands/site/create.ts +++ b/cli/commands/site/create.ts @@ -25,7 +25,12 @@ import { } from 'common/lib/fs-utils'; import { DEFAULT_LOCALE } from 'common/lib/locale'; import { isOnline } from 'common/lib/network-utils'; -import { createPassword, encodePassword, validateAdminUsername } from 'common/lib/passwords'; +import { + createPassword, + encodePassword, + validateAdminEmail, + validateAdminUsername, +} from 'common/lib/passwords'; import { portFinder } from 'common/lib/port-finder'; import { SITE_EVENTS } from 'common/lib/site-events'; import { sortSites } from 'common/lib/sort-sites'; @@ -72,6 +77,7 @@ type CreateCommandOptions = { }; adminUsername?: string; adminPassword?: string; + adminEmail?: string; noStart: boolean; skipBrowser: boolean; skipLogDetails: boolean; @@ -208,6 +214,14 @@ export async function runCommand( throw new LoggerError( usernameError ); } } + const adminEmail = options.adminEmail ?? undefined; + if ( adminEmail ) { + const emailError = validateAdminEmail( adminEmail ); + if ( emailError ) { + throw new LoggerError( emailError ); + } + } + const externalPassword = options.adminPassword || blueprintCredentials?.adminPassword; const adminPassword = externalPassword ? encodePassword( externalPassword ) : createPassword(); @@ -261,6 +275,7 @@ export async function runCommand( path: sitePath, adminUsername, adminPassword, + adminEmail, port, phpVersion: options.phpVersion, running: false, @@ -477,6 +492,10 @@ export const registerCommand = ( yargs: StudioArgv ) => { 'Admin password (auto-generated if not provided). Note: passwords in CLI arguments may be visible in process lists; consider using a Blueprint file for sensitive passwords.' ), } ) + .option( 'admin-email', { + type: 'string', + describe: __( 'Admin email (defaults to "admin@localhost.com")' ), + } ) .option( 'start', { type: 'boolean', describe: __( 'Start the site after creation' ), @@ -503,6 +522,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { enableHttps: !! argv.https, adminUsername: argv.adminUsername, adminPassword: argv.adminPassword, + adminEmail: argv.adminEmail, noStart: ! argv.start, skipBrowser: !! argv.skipBrowser, skipLogDetails: !! argv.skipLogDetails, diff --git a/cli/commands/site/set.ts b/cli/commands/site/set.ts index 229a7a9aff..aa41cfe41c 100644 --- a/cli/commands/site/set.ts +++ b/cli/commands/site/set.ts @@ -3,7 +3,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from 'common/constants'; import { getDomainNameValidationError } from 'common/lib/domains'; import { arePathsEqual } from 'common/lib/fs-utils'; -import { encodePassword, validateAdminUsername } from 'common/lib/passwords'; +import { encodePassword, validateAdminEmail, validateAdminUsername } from 'common/lib/passwords'; import { SITE_EVENTS } from 'common/lib/site-events'; import { siteNeedsRestart } from 'common/lib/site-needs-restart'; import { @@ -47,10 +47,12 @@ export interface SetCommandOptions { xdebug?: boolean; adminUsername?: string; adminPassword?: string; + adminEmail?: string; } export async function runCommand( sitePath: string, options: SetCommandOptions ): Promise< void > { - const { name, domain, https, php, wp, xdebug, adminUsername, adminPassword } = options; + const { name, domain, https, php, wp, xdebug, adminUsername, adminPassword, adminEmail } = + options; if ( name === undefined && @@ -60,11 +62,12 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) wp === undefined && xdebug === undefined && adminUsername === undefined && - adminPassword === undefined + adminPassword === undefined && + adminEmail === undefined ) { throw new LoggerError( __( - 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password, --admin-email) is required.' ) ); } @@ -84,6 +87,13 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) throw new LoggerError( __( 'Admin password cannot be empty.' ) ); } + if ( adminEmail !== undefined ) { + const emailError = validateAdminEmail( adminEmail ); + if ( emailError ) { + throw new LoggerError( emailError ); + } + } + try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); let site = await getSiteByFolder( sitePath ); @@ -133,7 +143,8 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) const adminUsernameChanged = adminUsername !== undefined && adminUsername !== ( site.adminUsername ?? 'admin' ); const adminPasswordChanged = adminPassword !== undefined; - const credentialsChanged = adminUsernameChanged || adminPasswordChanged; + const adminEmailChanged = adminEmail !== undefined && adminEmail !== ( site.adminEmail ?? '' ); + const credentialsChanged = adminUsernameChanged || adminPasswordChanged || adminEmailChanged; const hasChanges = nameChanged || @@ -188,6 +199,9 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) if ( adminPasswordChanged ) { foundSite.adminPassword = encodePassword( adminPassword! ); } + if ( adminEmailChanged ) { + foundSite.adminEmail = adminEmail!; + } await saveAppdata( appdata ); site = foundSite; @@ -330,6 +344,10 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'admin-password', { type: 'string', description: __( 'Admin password' ), + } ) + .option( 'admin-email', { + type: 'string', + description: __( 'Admin email' ), } ); }, handler: async ( argv ) => { @@ -343,6 +361,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { xdebug: argv.xdebug, adminUsername: argv.adminUsername, adminPassword: argv.adminPassword, + adminEmail: argv.adminEmail, } ); } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/cli/commands/site/status.ts b/cli/commands/site/status.ts index fcf8109b2d..92e63ce92e 100644 --- a/cli/commands/site/status.ts +++ b/cli/commands/site/status.ts @@ -56,6 +56,7 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) key: __( 'Admin password' ), value: site.adminPassword ? decodePassword( site.adminPassword ) : undefined, }, + { key: __( 'Admin email' ), value: site.adminEmail }, ].filter( ( { value, hidden } ) => value && ! hidden ); if ( format === 'table' ) { diff --git a/cli/commands/site/tests/set.test.ts b/cli/commands/site/tests/set.test.ts index a346b5e35d..795df5005a 100644 --- a/cli/commands/site/tests/set.test.ts +++ b/cli/commands/site/tests/set.test.ts @@ -99,7 +99,7 @@ describe( 'CLI: studio site set', () => { describe( 'Validation', () => { it( 'should throw when no options provided', async () => { await expect( runCommand( testSitePath, {} ) ).rejects.toThrow( - 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password, --admin-email) is required.' ); } ); @@ -496,6 +496,40 @@ describe( 'CLI: studio site set', () => { } ); } ); + describe( 'Admin email changes', () => { + it( 'should update admin email and restart running site', async () => { + vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); + + await runCommand( testSitePath, { adminEmail: 'test@example.com' } ); + + const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].adminEmail ).toBe( 'test@example.com' ); + expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); + expect( startWordPressServer ).toHaveBeenCalled(); + } ); + + it( 'should not restart stopped site when email changes', async () => { + await runCommand( testSitePath, { adminEmail: 'test@example.com' } ); + + const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].adminEmail ).toBe( 'test@example.com' ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + } ); + + it( 'should throw when admin email is empty', async () => { + await expect( runCommand( testSitePath, { adminEmail: ' ' } ) ).rejects.toThrow( + 'Admin email cannot be empty.' + ); + } ); + + it( 'should throw when admin email is invalid', async () => { + await expect( runCommand( testSitePath, { adminEmail: 'notanemail' } ) ).rejects.toThrow( + 'Please enter a valid email address.' + ); + } ); + } ); + describe( 'Error handling', () => { it( 'should throw when site not found', async () => { vi.mocked( getSiteByFolder ).mockRejectedValue( new Error( 'Site not found' ) ); diff --git a/cli/lib/types/wordpress-server-ipc.ts b/cli/lib/types/wordpress-server-ipc.ts index ae4b76f7ca..3ed82b5b62 100644 --- a/cli/lib/types/wordpress-server-ipc.ts +++ b/cli/lib/types/wordpress-server-ipc.ts @@ -10,6 +10,7 @@ const serverConfig = z.object( { absoluteUrl: z.string().optional(), adminUsername: z.string().optional(), adminPassword: z.string().optional(), + adminEmail: z.string().optional(), siteTitle: z.string().optional(), siteLanguage: z.string().optional(), isWpAutoUpdating: z.boolean().optional(), diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index 606848f2e1..b143e8d95c 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -100,6 +100,10 @@ export async function startWordPressServer( serverConfig.adminPassword = site.adminPassword; } + if ( site.adminEmail ) { + serverConfig.adminEmail = site.adminEmail; + } + if ( site.isWpAutoUpdating !== undefined ) { serverConfig.isWpAutoUpdating = site.isWpAutoUpdating; } @@ -378,6 +382,10 @@ export async function runBlueprint( serverConfig.adminPassword = site.adminPassword; } + if ( site.adminEmail ) { + serverConfig.adminEmail = site.adminEmail; + } + if ( site.isWpAutoUpdating !== undefined ) { serverConfig.isWpAutoUpdating = site.isWpAutoUpdating; } diff --git a/cli/wordpress-server-child.ts b/cli/wordpress-server-child.ts index 83aa1e2b7e..8600adfd7a 100644 --- a/cli/wordpress-server-child.ts +++ b/cli/wordpress-server-child.ts @@ -95,7 +95,8 @@ function escapePhpString( str: string ): string { async function setAdminCredentials( server: RunCLIServer, adminPassword: string, - adminUsername?: string + adminUsername?: string, + adminEmail?: string ): Promise< void > { await server.playground.request( { url: '/?studio-admin-api', @@ -104,6 +105,7 @@ async function setAdminCredentials( action: 'set_admin_password', password: escapePhpString( decodePassword( adminPassword ) ), ...( adminUsername && { username: escapePhpString( adminUsername ) } ), + ...( adminEmail && { email: escapePhpString( adminEmail ) } ), }, } ); } @@ -292,7 +294,12 @@ const startServer = wrapWithStartingPromise( } if ( config.adminPassword ) { - await setAdminCredentials( server, config.adminPassword, config.adminUsername ); + await setAdminCredentials( + server, + config.adminPassword, + config.adminUsername, + config.adminEmail + ); } } catch ( error ) { server = null; diff --git a/common/lib/mu-plugins.ts b/common/lib/mu-plugins.ts index 24a48a8534..0df730af91 100644 --- a/common/lib/mu-plugins.ts +++ b/common/lib/mu-plugins.ts @@ -423,15 +423,22 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { } $user = get_user_by( 'login', $username ); + $provided_email = ! empty( $_POST['email'] ) ? sanitize_email( $_POST['email'] ) : ''; + if ( $user ) { wp_set_password( $_POST['password'], $user->ID ); + if ( $provided_email ) { + wp_update_user( array( 'ID' => $user->ID, 'user_email' => $provided_email ) ); + } } else { // Generate a unique email to avoid conflicts with existing users - $email = 'admin@localhost.com'; - $counter = 1; - while ( email_exists( $email ) ) { - $email = 'admin' . $counter . '@localhost.com'; - $counter++; + $email = $provided_email ? $provided_email : 'admin@localhost.com'; + if ( ! $provided_email ) { + $counter = 1; + while ( email_exists( $email ) && $counter < 100 ) { + $email = 'admin' . $counter . '@localhost.com'; + $counter++; + } } $user_data = array( diff --git a/common/lib/passwords.ts b/common/lib/passwords.ts index f9752391d5..1612142c42 100644 --- a/common/lib/passwords.ts +++ b/common/lib/passwords.ts @@ -38,6 +38,19 @@ export function decodePassword( encodedPassword: string ): string { return new TextDecoder().decode( bytes ); } +/** + * Validates an admin email and returns an error message, or empty string if valid. + */ +export function validateAdminEmail( email: string ): string { + if ( ! email.trim() ) { + return __( 'Admin email cannot be empty.' ); + } + if ( ! /^[^\s@]+@[^\s@]+$/.test( email ) ) { + return __( 'Please enter a valid email address.' ); + } + return ''; +} + /** * Validates an admin username and returns an error message, or empty string if valid. */ diff --git a/common/lib/site-events.ts b/common/lib/site-events.ts index 2586c852f9..7db5401b21 100644 --- a/common/lib/site-events.ts +++ b/common/lib/site-events.ts @@ -20,6 +20,7 @@ export const siteDetailsSchema = z.object( { enableHttps: z.boolean().optional(), adminUsername: z.string().optional(), adminPassword: z.string().optional(), + adminEmail: z.string().optional(), isWpAutoUpdating: z.boolean().optional(), autoStart: z.boolean().optional(), enableXdebug: z.boolean().optional(), diff --git a/src/components/content-tab-settings.tsx b/src/components/content-tab-settings.tsx index 0c77faf9dd..1edac43206 100644 --- a/src/components/content-tab-settings.tsx +++ b/src/components/content-tab-settings.tsx @@ -38,6 +38,7 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) // Empty strings account for legacy sites lacking a stored password. const storedPassword = decodePassword( selectedSite.adminPassword ?? '' ); const password = storedPassword === '' ? 'password' : storedPassword; + const email = selectedSite.adminEmail || 'admin@localhost.com'; const [ wpVersion, refreshWpVersion ] = useGetWpVersion( selectedSite ); const domain = selectedSite.customDomain ? `${ selectedSite.customDomain }` @@ -150,6 +151,15 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) ************ + + + { email } + + { expect.any( Function ), false, undefined, // adminUsername - undefined // adminPassword + undefined, // adminPassword + undefined // adminEmail ); } ); diff --git a/src/hooks/use-add-site.ts b/src/hooks/use-add-site.ts index 0c7180cea9..36da05c21a 100644 --- a/src/hooks/use-add-site.ts +++ b/src/hooks/use-add-site.ts @@ -34,6 +34,7 @@ export interface CreateSiteFormValues { enableHttps: boolean; adminUsername?: string; adminPassword?: string; + adminEmail?: string; } /** @@ -288,7 +289,8 @@ export function useAddSite() { }, shouldSkipStart, formValues.adminUsername, - formValues.adminPassword + formValues.adminPassword, + formValues.adminEmail ); } catch ( e ) { Sentry.captureException( e ); diff --git a/src/hooks/use-site-details.tsx b/src/hooks/use-site-details.tsx index 354bca11ba..f3ab1b50dc 100644 --- a/src/hooks/use-site-details.tsx +++ b/src/hooks/use-site-details.tsx @@ -36,7 +36,8 @@ interface SiteDetailsContext { callback?: ( site: SiteDetails ) => Promise< void >, noStart?: boolean, adminUsername?: string, - adminPassword?: string + adminPassword?: string, + adminEmail?: string ) => Promise< SiteDetails | void >; startServer: ( id: string ) => Promise< void >; stopServer: ( id: string ) => Promise< void >; @@ -265,7 +266,8 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { callback?: ( site: SiteDetails ) => Promise< void >, noStart?: boolean, adminUsername?: string, - adminPassword?: string + adminPassword?: string, + adminEmail?: string ) => { // Function to handle error messages and cleanup const showError = ( error?: unknown, hasBlueprint?: boolean ) => { @@ -341,6 +343,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { blueprint, adminUsername, adminPassword, + adminEmail, noStart, } ); if ( ! newSite ) { diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 7d56545fde..af9f893248 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -126,6 +126,7 @@ export { const DEBUG_LOG_MAX_LINES = 50; const PM2_HOME = nodePath.join( os.homedir(), '.studio', 'pm2' ); +const DEFAULT_ENCODED_PASSWORD = encodePassword( 'password' ); function readLastLines( filePath: string, maxLines: number ): string[] | undefined { try { @@ -249,6 +250,7 @@ export async function createSite( blueprint?: Blueprint; adminUsername?: string; adminPassword?: string; + adminEmail?: string; noStart?: boolean; } = {} ): Promise< SiteDetails > { @@ -262,6 +264,7 @@ export async function createSite( phpVersion, adminUsername, adminPassword, + adminEmail, noStart = false, } = config; @@ -283,6 +286,7 @@ export async function createSite( blueprint: blueprint?.blueprint, adminUsername, adminPassword, + adminEmail, noStart, }, { wpVersion, blueprint: blueprint?.blueprint } @@ -386,13 +390,16 @@ export async function updateSite( options.adminUsername = updatedSite.adminUsername; } - const defaultEncodedPassword = encodePassword( 'password' ); if ( - ( updatedSite.adminPassword ?? defaultEncodedPassword ) !== - ( currentSite.adminPassword ?? defaultEncodedPassword ) + ( updatedSite.adminPassword ?? DEFAULT_ENCODED_PASSWORD ) !== + ( currentSite.adminPassword ?? DEFAULT_ENCODED_PASSWORD ) ) { // CLI set expects plain text password (it encodes before saving) - options.adminPassword = decodePassword( updatedSite.adminPassword ?? defaultEncodedPassword ); + options.adminPassword = decodePassword( updatedSite.adminPassword ?? DEFAULT_ENCODED_PASSWORD ); + } + + if ( ( updatedSite.adminEmail ?? '' ) !== ( currentSite.adminEmail ?? '' ) ) { + options.adminEmail = updatedSite.adminEmail; } const hasCliChanges = Object.keys( options ).length > 2; diff --git a/src/ipc-types.d.ts b/src/ipc-types.d.ts index 20b28e4e54..7542f05ce7 100644 --- a/src/ipc-types.d.ts +++ b/src/ipc-types.d.ts @@ -18,6 +18,7 @@ interface StoppedSiteDetails { enableHttps?: boolean; adminUsername?: string; adminPassword?: string; + adminEmail?: string; tlsKey?: string; tlsCert?: string; themeDetails?: { diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index b2f1b2d3d6..fc73e02e31 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -6,7 +6,7 @@ import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useState, useEffect, useCallback, useMemo, useRef, RefObject } from 'react'; import { DEFAULT_WORDPRESS_VERSION } from 'common/constants'; import { generateCustomDomainFromSiteName, getDomainNameValidationError } from 'common/lib/domains'; -import { generatePassword, validateAdminUsername } from 'common/lib/passwords'; +import { generatePassword, validateAdminEmail, validateAdminUsername } from 'common/lib/passwords'; import { SupportedPHPVersions } from 'common/types/php-versions'; import Button from 'src/components/button'; import FolderIcon from 'src/components/folder-icon'; @@ -188,6 +188,7 @@ export const CreateSiteForm = ( { const [ adminPassword, setAdminPassword ] = useState( () => blueprintCredentials?.adminPassword ?? generatePassword() ); + const [ adminEmail, setAdminEmail ] = useState( '' ); const [ pathError, setPathError ] = useState( '' ); const [ doesPathContainWordPress, setDoesPathContainWordPress ] = useState( false ); @@ -368,6 +369,7 @@ export const CreateSiteForm = ( { enableHttps, adminUsername: adminUsername || undefined, adminPassword: adminPassword || undefined, + adminEmail: adminEmail || undefined, } ), [ siteName, @@ -379,6 +381,7 @@ export const CreateSiteForm = ( { enableHttps, adminUsername, adminPassword, + adminEmail, ] ); @@ -393,11 +396,13 @@ export const CreateSiteForm = ( { const shouldShowCustomDomainError = useCustomDomain && customDomainError; const adminUsernameError = validateAdminUsername( adminUsername ); const adminPasswordError = ! adminPassword.trim() ? __( 'Admin password is required' ) : ''; + const adminEmailError = adminEmail.trim() ? validateAdminEmail( adminEmail ) : ''; const errorCount = [ pathError, shouldShowCustomDomainError, adminUsernameError, adminPasswordError, + adminEmailError, ].filter( Boolean ).length; const handleAdvancedSettingsClick = () => { @@ -575,6 +580,25 @@ export const CreateSiteForm = ( {
+
+ + { + hasUserEditedCredentials.current = true; + setAdminEmail( value ); + } } + placeholder="admin@localhost.com" + className={ adminEmailError ? '[&_input]:!border-red-500' : '' } + /> + { adminEmailError && ( + { adminEmailError } + ) } +
+ { showBlueprintVersionWarning && ( { __( 'Version differs from Blueprint recommendation' ) } diff --git a/src/modules/add-site/tests/add-site.test.tsx b/src/modules/add-site/tests/add-site.test.tsx index b2f68a8fa7..cbb996b9b5 100644 --- a/src/modules/add-site/tests/add-site.test.tsx +++ b/src/modules/add-site/tests/add-site.test.tsx @@ -231,7 +231,8 @@ describe( 'AddSite', () => { expect.any( Function ), false, 'admin', - expect.any( String ) + expect.any( String ), + undefined // adminEmail ); } ); } ); @@ -450,7 +451,8 @@ describe( 'AddSite', () => { expect.any( Function ), false, 'admin', - expect.any( String ) + expect.any( String ), + undefined // adminEmail ); } ); } ); diff --git a/src/modules/cli/lib/cli-site-creator.ts b/src/modules/cli/lib/cli-site-creator.ts index 3fbb81d2d1..c64d852904 100644 --- a/src/modules/cli/lib/cli-site-creator.ts +++ b/src/modules/cli/lib/cli-site-creator.ts @@ -37,6 +37,7 @@ export interface CreateSiteOptions { blueprint?: Blueprint; adminUsername?: string; adminPassword?: string; + adminEmail?: string; noStart?: boolean; } @@ -139,6 +140,10 @@ function buildCliArgs( options: CreateSiteOptions ): string[] { args.push( '--admin-password', options.adminPassword ); } + if ( options.adminEmail ) { + args.push( '--admin-email', options.adminEmail ); + } + if ( options.noStart ) { args.push( '--no-start' ); } diff --git a/src/modules/cli/lib/cli-site-editor.ts b/src/modules/cli/lib/cli-site-editor.ts index 96a895ad6a..ff953c1bc3 100644 --- a/src/modules/cli/lib/cli-site-editor.ts +++ b/src/modules/cli/lib/cli-site-editor.ts @@ -19,6 +19,7 @@ export interface EditSiteOptions { xdebug?: boolean; adminUsername?: string; adminPassword?: string; + adminEmail?: string; } export async function editSiteViaCli( options: EditSiteOptions ): Promise< void > { @@ -89,5 +90,9 @@ function buildCliArgs( options: EditSiteOptions ): string[] { args.push( '--admin-password', options.adminPassword ); } + if ( options.adminEmail !== undefined ) { + args.push( '--admin-email', options.adminEmail ); + } + return args; } diff --git a/src/modules/site-settings/edit-site-details.tsx b/src/modules/site-settings/edit-site-details.tsx index 227c458b0d..9d9d924992 100644 --- a/src/modules/site-settings/edit-site-details.tsx +++ b/src/modules/site-settings/edit-site-details.tsx @@ -5,7 +5,12 @@ import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useCallback, useEffect, useState } from 'react'; import { DEFAULT_PHP_VERSION } from 'common/constants'; import { generateCustomDomainFromSiteName, getDomainNameValidationError } from 'common/lib/domains'; -import { decodePassword, encodePassword, validateAdminUsername } from 'common/lib/passwords'; +import { + decodePassword, + encodePassword, + validateAdminEmail, + validateAdminUsername, +} from 'common/lib/passwords'; import { siteNeedsRestart } from 'common/lib/site-needs-restart'; import { SupportedPHPVersions } from 'common/types/php-versions'; import Button from 'src/components/button'; @@ -42,6 +47,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = const [ adminPassword, setAdminPassword ] = useState( () => decodePassword( selectedSite?.adminPassword ?? '' ) || 'password' ); + const [ adminEmail, setAdminEmail ] = useState( selectedSite?.adminEmail ?? '' ); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); const closeModal = useCallback( () => { @@ -98,6 +104,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = const usedCustomDomain = ! useCustomDomain ? customDomain : undefined; const adminUsernameError = validateAdminUsername( adminUsername ); const adminPasswordError = ! adminPassword.trim() ? __( 'Admin password is required' ) : ''; + const adminEmailError = adminEmail.trim() ? validateAdminEmail( adminEmail ) : ''; const isFormUnchanged = !! selectedSite && selectedSite.name === siteName && @@ -108,13 +115,15 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = !! selectedSite.enableHttps === ( !! usedCustomDomain && enableHttps ) && !! selectedSite.enableXdebug === enableXdebug && ( selectedSite.adminUsername ?? 'admin' ) === adminUsername && - ( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ) === adminPassword; + ( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ) === adminPassword && + ( selectedSite.adminEmail ?? '' ) === adminEmail; const hasValidationErrors = ! selectedSite || ! siteName.trim() || ( useCustomDomain && !! customDomainError ) || !! adminUsernameError || - !! adminPasswordError; + !! adminPasswordError || + !! adminEmailError; const resetFormState = useCallback( () => { if ( ! selectedSite ) { @@ -131,6 +140,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = setEnableXdebug( selectedSite.enableXdebug ?? false ); setAdminUsername( selectedSite.adminUsername ?? 'admin' ); setAdminPassword( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ); + setAdminEmail( selectedSite.adminEmail ?? '' ); }, [ selectedSite, getEffectiveWpVersion ] ); const onSiteEdit = async ( event: FormEvent ) => { @@ -151,7 +161,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = useCustomDomain && enableHttps !== ( selectedSite.enableHttps ?? false ); const hasCredentialsChanged = adminUsername !== ( selectedSite.adminUsername ?? 'admin' ) || - adminPassword !== ( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ); + adminPassword !== ( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ) || + adminEmail !== ( selectedSite.adminEmail ?? '' ); const needsRestart = selectedSite.running && @@ -184,6 +195,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = adminUsername, // Encode for IPC storage; IPC handler decodes back to plain text for the CLI set command adminPassword: encodePassword( adminPassword ), + adminEmail: adminEmail || undefined, }, hasWpVersionChanged ? selectedWpVersion : undefined ); @@ -422,6 +434,23 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = + +
+ + + { adminEmailError && ( + { adminEmailError } + ) } +
diff --git a/src/storage/user-data.ts b/src/storage/user-data.ts index 8a4b960c36..811312178e 100644 --- a/src/storage/user-data.ts +++ b/src/storage/user-data.ts @@ -158,6 +158,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { path, adminUsername, adminPassword, + adminEmail, port, phpVersion, isWpAutoUpdating, @@ -178,6 +179,7 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { path, adminUsername, adminPassword, + adminEmail, port, phpVersion, isWpAutoUpdating, From c3f6846ea320fbd12f9d2180e81bd5796234ce62 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 11 Feb 2026 16:47:33 +0000 Subject: [PATCH 13/20] copilot feedback --- cli/commands/site/create.ts | 2 +- cli/commands/site/set.ts | 14 +++++++++----- cli/commands/site/tests/set.test.ts | 9 +++++---- common/lib/passwords.ts | 2 +- .../add-site/components/create-site-form.tsx | 5 +++-- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/cli/commands/site/create.ts b/cli/commands/site/create.ts index 16f7537655..2c314ed91e 100644 --- a/cli/commands/site/create.ts +++ b/cli/commands/site/create.ts @@ -214,7 +214,7 @@ export async function runCommand( throw new LoggerError( usernameError ); } } - const adminEmail = options.adminEmail ?? undefined; + const adminEmail = options.adminEmail?.trim() || undefined; if ( adminEmail ) { const emailError = validateAdminEmail( adminEmail ); if ( emailError ) { diff --git a/cli/commands/site/set.ts b/cli/commands/site/set.ts index aa41cfe41c..517b83145f 100644 --- a/cli/commands/site/set.ts +++ b/cli/commands/site/set.ts @@ -51,8 +51,8 @@ export interface SetCommandOptions { } export async function runCommand( sitePath: string, options: SetCommandOptions ): Promise< void > { - const { name, domain, https, php, wp, xdebug, adminUsername, adminPassword, adminEmail } = - options; + const { name, domain, https, php, wp, xdebug, adminUsername, adminPassword } = options; + let { adminEmail } = options; if ( name === undefined && @@ -88,9 +88,13 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) } if ( adminEmail !== undefined ) { - const emailError = validateAdminEmail( adminEmail ); - if ( emailError ) { - throw new LoggerError( emailError ); + if ( ! adminEmail.trim() ) { + adminEmail = undefined; + } else { + const emailError = validateAdminEmail( adminEmail ); + if ( emailError ) { + throw new LoggerError( emailError ); + } } } diff --git a/cli/commands/site/tests/set.test.ts b/cli/commands/site/tests/set.test.ts index 795df5005a..3c3d50ceef 100644 --- a/cli/commands/site/tests/set.test.ts +++ b/cli/commands/site/tests/set.test.ts @@ -517,10 +517,11 @@ describe( 'CLI: studio site set', () => { expect( startWordPressServer ).not.toHaveBeenCalled(); } ); - it( 'should throw when admin email is empty', async () => { - await expect( runCommand( testSitePath, { adminEmail: ' ' } ) ).rejects.toThrow( - 'Admin email cannot be empty.' - ); + it( 'should ignore whitespace-only admin email', async () => { + await runCommand( testSitePath, { adminEmail: ' ', name: 'New Name' } ); + const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].adminEmail ).toBeUndefined(); + expect( savedAppdata.sites[ 0 ].name ).toBe( 'New Name' ); } ); it( 'should throw when admin email is invalid', async () => { diff --git a/common/lib/passwords.ts b/common/lib/passwords.ts index 1612142c42..62e605afdf 100644 --- a/common/lib/passwords.ts +++ b/common/lib/passwords.ts @@ -45,7 +45,7 @@ export function validateAdminEmail( email: string ): string { if ( ! email.trim() ) { return __( 'Admin email cannot be empty.' ); } - if ( ! /^[^\s@]+@[^\s@]+$/.test( email ) ) { + if ( ! /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( email ) ) { return __( 'Please enter a valid email address.' ); } return ''; diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index fc73e02e31..4ac01ef874 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -284,7 +284,8 @@ export const CreateSiteForm = ( { !! pathError || ( useCustomDomain && !! customDomainError ) || ! adminUsername.trim() || - ! adminPassword.trim(); + ! adminPassword.trim() || + !! adminEmailError; const isValid = ! hasErrors; // Only notify if validity has actually changed @@ -293,7 +294,7 @@ export const CreateSiteForm = ( { onValidityChange( isValid ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ pathError, customDomainError, useCustomDomain, adminUsername, adminPassword ] ); + }, [ pathError, customDomainError, useCustomDomain, adminUsername, adminPassword, adminEmail ] ); const handleSiteNameChange = useCallback( async ( name: string ) => { From 5d641eb06da2eb2fc1ad9bf2702194d23bead4a9 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Thu, 12 Feb 2026 15:38:43 +0000 Subject: [PATCH 14/20] Add comment explaining username change creates new user --- common/lib/mu-plugins.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/lib/mu-plugins.ts b/common/lib/mu-plugins.ts index 0df730af91..c182f4c016 100644 --- a/common/lib/mu-plugins.ts +++ b/common/lib/mu-plugins.ts @@ -431,6 +431,8 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { wp_update_user( array( 'ID' => $user->ID, 'user_email' => $provided_email ) ); } } else { + // WordPress doesn't support renaming user_login, so we create a new admin user. + // The old user is left intact — this is intentional. // Generate a unique email to avoid conflicts with existing users $email = $provided_email ? $provided_email : 'admin@localhost.com'; if ( ! $provided_email ) { From aa79075fda4f53b63c014a9bdd8ffb8b3bfbaf53 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 13 Feb 2026 10:52:56 +0000 Subject: [PATCH 15/20] Fix prettier formatting in create-site-form --- .../add-site/components/create-site-form.tsx | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/modules/add-site/components/create-site-form.tsx b/src/modules/add-site/components/create-site-form.tsx index 0337e8cb9a..0cb51a0a97 100644 --- a/src/modules/add-site/components/create-site-form.tsx +++ b/src/modules/add-site/components/create-site-form.tsx @@ -544,45 +544,45 @@ export const CreateSiteForm = ( {
- { __( 'Admin credentials' ) } -
-
- - { - hasUserEditedCredentials.current = true; - setAdminUsername( value ); - } } - className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } - /> - { adminUsernameError && ( - { adminUsernameError } - ) } -
+ { __( 'Admin credentials' ) } +
+
+ + { + hasUserEditedCredentials.current = true; + setAdminUsername( value ); + } } + className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } + /> + { adminUsernameError && ( + { adminUsernameError } + ) } +
-
- - { - hasUserEditedCredentials.current = true; - setAdminPassword( value ); - } } - className={ adminPasswordError ? '[&_input]:!border-red-500' : '' } - /> - { adminPasswordError && ( - { adminPasswordError } - ) } +
+ + { + hasUserEditedCredentials.current = true; + setAdminPassword( value ); + } } + className={ adminPasswordError ? '[&_input]:!border-red-500' : '' } + /> + { adminPasswordError && ( + { adminPasswordError } + ) } +
-
diff --git a/apps/studio/src/modules/site-settings/edit-site-details.tsx b/apps/studio/src/modules/site-settings/edit-site-details.tsx index d04c703c6f..e9d5177b23 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -108,6 +108,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = const adminUsernameError = validateAdminUsername( adminUsername ); const adminPasswordError = ! adminPassword.trim() ? __( 'Admin password is required' ) : ''; const adminEmailError = adminEmail.trim() ? validateAdminEmail( adminEmail ) : ''; + const isUsernameChanged = + ! adminUsernameError && adminUsername !== ( selectedSite?.adminUsername ?? 'admin' ); const isFormUnchanged = !! selectedSite && selectedSite.name === siteName && @@ -436,6 +438,13 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = ) }
+ { isUsernameChanged && ( + + { __( + 'A new admin user will be created. WordPress does not support renaming usernames.' + ) } + + ) }
@@ -450,8 +459,12 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = placeholder="admin@localhost.com" className={ adminEmailError ? '[&_input]:!border-red-500' : '' } /> - { adminEmailError && ( + { adminEmailError ? ( { adminEmailError } + ) : ( + + { __( 'Defaults to admin@localhost.com if not provided.' ) } + ) }
diff --git a/tools/common/lib/passwords.ts b/tools/common/lib/passwords.ts index 62e605afdf..547123fe54 100644 --- a/tools/common/lib/passwords.ts +++ b/tools/common/lib/passwords.ts @@ -59,7 +59,7 @@ export function validateAdminUsername( username: string ): string { return __( 'Admin username cannot be empty.' ); } if ( ! /^[a-zA-Z0-9_.@-]+$/.test( username ) ) { - return __( 'Username can only contain letters, numbers, and _.@- characters' ); + return __( 'Username can only contain letters, numbers, and _.@- characters.' ); } if ( username.length > 60 ) { return __( 'Username must be 60 characters or fewer.' ); From ef8e1e2aab51e68bc3e5eb05e7f01f1a83e1c00e Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 20 Feb 2026 14:54:50 +0000 Subject: [PATCH 17/20] Fix import ordering and prettier formatting --- apps/cli/commands/site/set.ts | 6 +++++- .../modules/add-site/components/create-site-form.tsx | 10 +++++----- .../src/modules/site-settings/edit-site-details.tsx | 12 ++++++------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index 25a7e2200b..d252a5412f 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -2,7 +2,11 @@ import { SupportedPHPVersions } from '@php-wasm/universal'; import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; import { getDomainNameValidationError } from '@studio/common/lib/domains'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; -import { encodePassword, validateAdminEmail, validateAdminUsername } from '@studio/common/lib/passwords'; +import { + encodePassword, + validateAdminEmail, + validateAdminUsername, +} from '@studio/common/lib/passwords'; import { SITE_EVENTS } from '@studio/common/lib/site-events'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; import { diff --git a/apps/studio/src/modules/add-site/components/create-site-form.tsx b/apps/studio/src/modules/add-site/components/create-site-form.tsx index 47b61b4574..6d828b49eb 100644 --- a/apps/studio/src/modules/add-site/components/create-site-form.tsx +++ b/apps/studio/src/modules/add-site/components/create-site-form.tsx @@ -3,6 +3,11 @@ import { generateCustomDomainFromSiteName, getDomainNameValidationError, } from '@studio/common/lib/domains'; +import { + generatePassword, + validateAdminEmail, + validateAdminUsername, +} from '@studio/common/lib/passwords'; import { SupportedPHPVersions } from '@studio/common/types/php-versions'; import { Icon, SelectControl, Notice } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; @@ -10,11 +15,6 @@ import { __, sprintf, _n } from '@wordpress/i18n'; import { tip, cautionFilled, chevronRight, chevronDown, chevronLeft } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useState, useEffect, useCallback, useMemo, useRef, RefObject } from 'react'; -import { - generatePassword, - validateAdminEmail, - validateAdminUsername, -} from '@studio/common/lib/passwords'; import Button from 'src/components/button'; import FolderIcon from 'src/components/folder-icon'; import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; diff --git a/apps/studio/src/modules/site-settings/edit-site-details.tsx b/apps/studio/src/modules/site-settings/edit-site-details.tsx index e9d5177b23..6b00b66f7c 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -3,6 +3,12 @@ import { generateCustomDomainFromSiteName, getDomainNameValidationError, } from '@studio/common/lib/domains'; +import { + decodePassword, + encodePassword, + validateAdminEmail, + validateAdminUsername, +} from '@studio/common/lib/passwords'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; import { SupportedPHPVersions } from '@studio/common/types/php-versions'; import { SelectControl } from '@wordpress/components'; @@ -10,12 +16,6 @@ import { createInterpolateElement } from '@wordpress/element'; import { sprintf } from '@wordpress/i18n'; import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useCallback, useEffect, useState } from 'react'; -import { - decodePassword, - encodePassword, - validateAdminEmail, - validateAdminUsername, -} from '@studio/common/lib/passwords'; import Button from 'src/components/button'; import { ErrorInformation } from 'src/components/error-information'; import { LearnMoreLink, LearnHowLink } from 'src/components/learn-more'; From 1c57a40981b52c0fe3c735590f3e26636fd713cb Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 23 Feb 2026 15:52:49 +0000 Subject: [PATCH 18/20] Fix credential updates for legacy sites and copySite missing fields --- apps/cli/wordpress-server-child.ts | 8 +++++--- apps/studio/src/ipc-handlers.ts | 2 ++ tools/common/lib/mu-plugins.ts | 19 +++++++++++-------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 013a18c9ff..056753a975 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -93,7 +93,7 @@ function escapePhpString( str: string ): string { async function setAdminCredentials( server: RunCLIServer, - adminPassword: string, + adminPassword?: string, adminUsername?: string, adminEmail?: string ): Promise< void > { @@ -102,7 +102,9 @@ async function setAdminCredentials( method: 'POST', body: { action: 'set_admin_password', - password: escapePhpString( decodePassword( adminPassword ) ), + ...( adminPassword && { + password: escapePhpString( decodePassword( adminPassword ) ), + } ), ...( adminUsername && { username: escapePhpString( adminUsername ) } ), ...( adminEmail && { email: escapePhpString( adminEmail ) } ), }, @@ -282,7 +284,7 @@ const startServer = wrapWithStartingPromise( lastCliArgs = sanitizeRunCLIArgs( args ); server = await runCLI( args ); - if ( config.adminPassword ) { + if ( config.adminPassword || config.adminUsername || config.adminEmail ) { await setAdminCredentials( server, config.adminPassword, diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index fce180ca89..5a3121c1e2 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -636,7 +636,9 @@ export async function copySite( port, phpVersion: sourceSite.phpVersion, running: false, + adminUsername: sourceSite.adminUsername, adminPassword: sourceSite.adminPassword, + adminEmail: sourceSite.adminEmail, themeDetails: sourceSite.themeDetails, }; diff --git a/tools/common/lib/mu-plugins.ts b/tools/common/lib/mu-plugins.ts index c182f4c016..72ef1d0126 100644 --- a/tools/common/lib/mu-plugins.ts +++ b/tools/common/lib/mu-plugins.ts @@ -409,13 +409,7 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { switch ( $action ) { case 'set_admin_password': - if ( empty( $_POST['password'] ) ) { - status_header( 400 ); - header( 'Content-Type: application/json' ); - echo json_encode( [ 'error' => 'Password is required' ] ); - exit; - } - + $has_password = ! empty( $_POST['password'] ); $username = ! empty( $_POST['username'] ) ? sanitize_user( $_POST['username'] ) : 'admin'; // Fallback to 'admin' if sanitize_user() strips all characters (e.g., !@#$%) if ( empty( $username ) ) { @@ -426,11 +420,20 @@ function getStandardMuPlugins( options: MuPluginOptions ): MuPlugin[] { $provided_email = ! empty( $_POST['email'] ) ? sanitize_email( $_POST['email'] ) : ''; if ( $user ) { - wp_set_password( $_POST['password'], $user->ID ); + if ( $has_password ) { + wp_set_password( $_POST['password'], $user->ID ); + } if ( $provided_email ) { wp_update_user( array( 'ID' => $user->ID, 'user_email' => $provided_email ) ); } } else { + // Creating a new user requires a password + if ( ! $has_password ) { + status_header( 400 ); + header( 'Content-Type: application/json' ); + echo json_encode( [ 'error' => 'Password is required to create a new admin user' ] ); + exit; + } // WordPress doesn't support renaming user_login, so we create a new admin user. // The old user is left intact — this is intentional. // Generate a unique email to avoid conflicts with existing users From ec225a22a190d833875746ffbc61cbe85bdd77c0 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 27 Feb 2026 10:19:44 +0000 Subject: [PATCH 19/20] Show single credential validation error spanning full width --- .../modules/add-site/components/create-site-form.tsx | 11 +++++------ .../src/modules/site-settings/edit-site-details.tsx | 11 +++++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/studio/src/modules/add-site/components/create-site-form.tsx b/apps/studio/src/modules/add-site/components/create-site-form.tsx index b6dfc9ae56..af11ab9c04 100644 --- a/apps/studio/src/modules/add-site/components/create-site-form.tsx +++ b/apps/studio/src/modules/add-site/components/create-site-form.tsx @@ -566,9 +566,6 @@ export const CreateSiteForm = ( { } } className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } /> - { adminUsernameError && ( - { adminUsernameError } - ) }
@@ -584,11 +581,13 @@ export const CreateSiteForm = ( { } } className={ adminPasswordError ? '[&_input]:!border-red-500' : '' } /> - { adminPasswordError && ( - { adminPasswordError } - ) }
+ { ( adminUsernameError || adminPasswordError ) && ( + + { adminUsernameError || adminPasswordError } + + ) }
diff --git a/apps/studio/src/modules/site-settings/edit-site-details.tsx b/apps/studio/src/modules/site-settings/edit-site-details.tsx index 3328bf3a8a..b8d18c0f1e 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -470,9 +470,6 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = onChange={ setAdminUsername } className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } /> - { adminUsernameError && ( - { adminUsernameError } - ) }
+ { ( adminUsernameError || adminPasswordError ) && ( + + { adminUsernameError || adminPasswordError } + + ) } { isUsernameChanged && ( { __( From ad3b61bf35070372edbc7f225368510c77f15585 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 27 Feb 2026 10:43:15 +0000 Subject: [PATCH 20/20] Move admin credentials from debugging tab to general tab --- .../site-settings/edit-site-details.tsx | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/apps/studio/src/modules/site-settings/edit-site-details.tsx b/apps/studio/src/modules/site-settings/edit-site-details.tsx index b8d18c0f1e..67a4e01267 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -389,6 +389,69 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = ) } + +
+ { __( 'Admin credentials' ) } +
+
+ + +
+
+ + +
+
+ { ( adminUsernameError || adminPasswordError ) && ( + + { adminUsernameError || adminPasswordError } + + ) } + { isUsernameChanged && ( + + { __( + 'A new admin user will be created. WordPress does not support renaming usernames.' + ) } + + ) } +
+ +
+ + + { adminEmailError ? ( + { adminEmailError } + ) : ( + + { __( 'Defaults to admin@localhost.com if not provided.' ) } + + ) } +
) } @@ -456,69 +519,6 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = -
- { __( 'Admin credentials' ) } -
-
- - -
-
- - -
-
- { ( adminUsernameError || adminPasswordError ) && ( - - { adminUsernameError || adminPasswordError } - - ) } - { isUsernameChanged && ( - - { __( - 'A new admin user will be created. WordPress does not support renaming usernames.' - ) } - - ) } -
- -
- - - { adminEmailError ? ( - { adminEmailError } - ) : ( - - { __( 'Defaults to admin@localhost.com if not provided.' ) } - - ) } -
-