diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 7a66cd3360..386951805f 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -9,6 +9,7 @@ import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION, } from '@studio/common/constants'; +import { extractFormValuesFromBlueprint } from '@studio/common/lib/blueprint-settings'; import { filterUnsupportedBlueprintFeatures, validateBlueprintData, @@ -23,7 +24,12 @@ import { } from '@studio/common/lib/fs-utils'; import { DEFAULT_LOCALE } from '@studio/common/lib/locale'; import { isOnline } from '@studio/common/lib/network-utils'; -import { createPassword } from '@studio/common/lib/passwords'; +import { + createPassword, + encodePassword, + validateAdminEmail, + validateAdminUsername, +} from '@studio/common/lib/passwords'; import { portFinder } from '@studio/common/lib/port-finder'; import { hasDefaultDbBlock, @@ -77,6 +83,9 @@ type CreateCommandOptions = { contents: unknown; uri: string; }; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; noStart: boolean; skipBrowser: boolean; skipLogDetails: boolean; @@ -101,6 +110,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 ); @@ -119,6 +129,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 > @@ -193,7 +212,26 @@ 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; + if ( adminUsername ) { + const usernameError = validateAdminUsername( adminUsername ); + if ( usernameError ) { + throw new LoggerError( usernameError ); + } + } + const adminEmail = options.adminEmail?.trim() || 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(); const setupSteps: StepDefinition[] = []; @@ -266,7 +304,9 @@ export async function runCommand( id: siteId, name: siteName, path: sitePath, + adminUsername, adminPassword, + adminEmail, port, phpVersion: options.phpVersion, running: false, @@ -488,6 +528,20 @@ 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). 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' ), @@ -604,6 +658,9 @@ export const registerCommand = ( yargs: StudioArgv ) => { phpVersion, customDomain, enableHttps, + adminUsername: argv.adminUsername, + adminPassword: argv.adminPassword, + adminEmail: argv.adminEmail, noStart: ! argv.start, skipBrowser: !! argv.skipBrowser, skipLogDetails: !! argv.skipLogDetails, diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index 9cdd21adbb..d8e153f1b8 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -2,6 +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 { SITE_EVENTS } from '@studio/common/lib/site-events'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; import { @@ -44,12 +49,27 @@ export interface SetCommandOptions { php?: string; wp?: string; xdebug?: boolean; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; debugLog?: boolean; debugDisplay?: boolean; } export async function runCommand( sitePath: string, options: SetCommandOptions ): Promise< void > { - const { name, domain, https, php, wp, xdebug, debugLog, debugDisplay } = options; + const { + name, + domain, + https, + php, + wp, + xdebug, + adminUsername, + adminPassword, + debugLog, + debugDisplay, + } = options; + let { adminEmail } = options; if ( name === undefined && @@ -58,12 +78,15 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) php === undefined && wp === undefined && xdebug === undefined && + adminUsername === undefined && + adminPassword === undefined && + adminEmail === undefined && debugLog === undefined && debugDisplay === undefined ) { throw new LoggerError( __( - 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --debug-log, --debug-display) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' ) ); } @@ -72,6 +95,28 @@ 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.' ) ); + } + + if ( adminEmail !== undefined ) { + if ( ! adminEmail.trim() ) { + adminEmail = undefined; + } else { + const emailError = validateAdminEmail( adminEmail ); + if ( emailError ) { + throw new LoggerError( emailError ); + } + } + } + try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); let site = await getSiteByFolder( sitePath ); @@ -118,6 +163,11 @@ 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 adminEmailChanged = adminEmail !== undefined && adminEmail !== ( site.adminEmail ?? '' ); + const credentialsChanged = adminUsernameChanged || adminPasswordChanged || adminEmailChanged; const debugLogChanged = debugLog !== undefined && debugLog !== site.enableDebugLog; const debugDisplayChanged = debugDisplay !== undefined && debugDisplay !== site.enableDebugDisplay; @@ -129,6 +179,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) phpChanged || wpChanged || xdebugChanged || + credentialsChanged || debugLogChanged || debugDisplayChanged; if ( ! hasChanges ) { @@ -143,6 +194,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) phpChanged, wpChanged, xdebugChanged, + credentialsChanged, debugLogChanged, debugDisplayChanged, } ); @@ -171,6 +223,15 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) if ( xdebugChanged ) { foundSite.enableXdebug = xdebug; } + if ( adminUsernameChanged ) { + foundSite.adminUsername = adminUsername!; + } + if ( adminPasswordChanged ) { + foundSite.adminPassword = encodePassword( adminPassword! ); + } + if ( adminEmailChanged ) { + foundSite.adminEmail = adminEmail!; + } if ( debugLogChanged ) { foundSite.enableDebugLog = debugLog; } @@ -312,6 +373,18 @@ export const registerCommand = ( yargs: StudioArgv ) => { type: 'boolean', description: __( 'Enable Xdebug' ), } ) + .option( 'admin-username', { + type: 'string', + description: __( 'Admin username' ), + } ) + .option( 'admin-password', { + type: 'string', + description: __( 'Admin password' ), + } ) + .option( 'admin-email', { + type: 'string', + description: __( 'Admin email' ), + } ) .option( 'debug-log', { type: 'boolean', description: __( 'Enable WP_DEBUG_LOG' ), @@ -330,6 +403,9 @@ export const registerCommand = ( yargs: StudioArgv ) => { php: argv.php, wp: argv.wp, xdebug: argv.xdebug, + adminUsername: argv.adminUsername, + adminPassword: argv.adminPassword, + adminEmail: argv.adminEmail, debugLog: argv.debugLog, debugDisplay: argv.debugDisplay, } ); diff --git a/apps/cli/commands/site/status.ts b/apps/cli/commands/site/status.ts index 0711e1671c..6ce10276eb 100644 --- a/apps/cli/commands/site/status.ts +++ b/apps/cli/commands/site/status.ts @@ -58,12 +58,17 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) { key: __( 'PHP version' ), jsonKey: 'phpVersion', value: site.phpVersion }, { key: __( 'WP version' ), jsonKey: 'wpVersion', value: wpVersion }, { key: __( 'Xdebug' ), jsonKey: 'xdebug', value: xdebugStatus }, - { key: __( 'Admin username' ), jsonKey: 'adminUsername', value: 'admin' }, + { + key: __( 'Admin username' ), + jsonKey: 'adminUsername', + value: site.adminUsername ?? 'admin', + }, { key: __( 'Admin password' ), jsonKey: 'adminPassword', value: site.adminPassword ? decodePassword( site.adminPassword ) : undefined, }, + { key: __( 'Admin email' ), jsonKey: 'adminEmail', value: site.adminEmail }, ].filter( ( { value, hidden } ) => value && ! hidden ); if ( format === 'table' ) { diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index 6516e05820..da89c7edbe 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -1,6 +1,7 @@ import { StreamedPHPResponse } from '@php-wasm/universal'; import { getDomainNameValidationError } from '@studio/common/lib/domains'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; +import { encodePassword } from '@studio/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, --debug-log, --debug-display) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' ); } ); @@ -423,6 +424,113 @@ 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( '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 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 () => { + 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/apps/cli/lib/site-utils.ts b/apps/cli/lib/site-utils.ts index a6fcd8e42c..a50b1e6891 100644 --- a/apps/cli/lib/site-utils.ts +++ b/apps/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/apps/cli/lib/types/wordpress-server-ipc.ts b/apps/cli/lib/types/wordpress-server-ipc.ts index 7f4506db54..c8255e1939 100644 --- a/apps/cli/lib/types/wordpress-server-ipc.ts +++ b/apps/cli/lib/types/wordpress-server-ipc.ts @@ -8,7 +8,9 @@ const serverConfig = z.object( { phpVersion: z.string().optional(), wpVersion: z.string().optional(), 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/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index d289cbc05b..5e1c8f6204 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -82,10 +82,18 @@ export async function startWordPressServer( serverConfig.absoluteUrl = `${ protocol }://${ site.customDomain }`; } + if ( site.adminUsername ) { + serverConfig.adminUsername = site.adminUsername; + } + if ( site.adminPassword ) { serverConfig.adminPassword = site.adminPassword; } + if ( site.adminEmail ) { + serverConfig.adminEmail = site.adminEmail; + } + if ( site.isWpAutoUpdating !== undefined ) { serverConfig.isWpAutoUpdating = site.isWpAutoUpdating; } @@ -363,10 +371,18 @@ export async function runBlueprint( serverConfig.absoluteUrl = `${ protocol }://${ site.customDomain }`; } + if ( site.adminUsername ) { + serverConfig.adminUsername = site.adminUsername; + } + if ( site.adminPassword ) { serverConfig.adminPassword = site.adminPassword; } + if ( site.adminEmail ) { + serverConfig.adminEmail = site.adminEmail; + } + if ( site.isWpAutoUpdating !== undefined ) { serverConfig.isWpAutoUpdating = site.isWpAutoUpdating; } diff --git a/apps/cli/wordpress-server-child.ts b/apps/cli/wordpress-server-child.ts index 710c3778ae..f53a0b148d 100644 --- a/apps/cli/wordpress-server-child.ts +++ b/apps/cli/wordpress-server-child.ts @@ -91,13 +91,22 @@ 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, + adminEmail?: string +): Promise< void > { await server.playground.request( { url: '/?studio-admin-api', method: 'POST', body: { action: 'set_admin_password', - password: escapePhpString( decodePassword( adminPassword ) ), + ...( adminPassword && { + password: escapePhpString( decodePassword( adminPassword ) ), + } ), + ...( adminUsername && { username: escapePhpString( adminUsername ) } ), + ...( adminEmail && { email: escapePhpString( adminEmail ) } ), }, } ); } @@ -283,8 +292,13 @@ const startServer = wrapWithStartingPromise( lastCliArgs = sanitizeRunCLIArgs( args ); server = await runCLI( args ); - if ( config.adminPassword ) { - await setAdminPassword( server, config.adminPassword ); + if ( config.adminPassword || config.adminUsername || config.adminEmail ) { + await setAdminCredentials( + server, + config.adminPassword, + config.adminUsername, + config.adminEmail + ); } } catch ( error ) { server = null; diff --git a/apps/studio/src/components/content-tab-settings.tsx b/apps/studio/src/components/content-tab-settings.tsx index 7efd46f345..ed10e57dbe 100644 --- a/apps/studio/src/components/content-tab-settings.tsx +++ b/apps/studio/src/components/content-tab-settings.tsx @@ -41,10 +41,11 @@ 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; + const email = selectedSite.adminEmail || 'admin@localhost.com'; const [ wpVersion, refreshWpVersion ] = useGetWpVersion( selectedSite ); const domain = selectedSite.customDomain ? `${ selectedSite.customDomain }` @@ -205,6 +206,15 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) ************ + + + { email } + + 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 } + 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', + '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/apps/studio/src/components/tests/password-control.test.tsx b/apps/studio/src/components/tests/password-control.test.tsx new file mode 100644 index 0000000000..da40ed5d37 --- /dev/null +++ b/apps/studio/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' ); + } ); +} ); diff --git a/apps/studio/src/hooks/tests/use-add-site.test.tsx b/apps/studio/src/hooks/tests/use-add-site.test.tsx index ffa99d2306..f4d7d4e1f2 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -192,7 +192,10 @@ describe( 'useAddSite', () => { undefined, // blueprint parameter '8.2', expect.any( Function ), - false + false, + undefined, // adminUsername + undefined, // adminPassword + undefined // adminEmail ); } ); diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 8ba7590196..b7f2c4f762 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -32,6 +32,9 @@ export interface CreateSiteFormValues { useCustomDomain: boolean; customDomain: string | null; enableHttps: boolean; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; } /** @@ -290,7 +293,10 @@ export function useAddSite() { } ); } }, - shouldSkipStart + shouldSkipStart, + formValues.adminUsername, + formValues.adminPassword, + formValues.adminEmail ); } catch ( e ) { Sentry.captureException( e ); diff --git a/apps/studio/src/hooks/use-site-details.tsx b/apps/studio/src/hooks/use-site-details.tsx index a61eadb467..d5b834e1d3 100644 --- a/apps/studio/src/hooks/use-site-details.tsx +++ b/apps/studio/src/hooks/use-site-details.tsx @@ -36,7 +36,10 @@ interface SiteDetailsContext { blueprint?: Blueprint, phpVersion?: string, callback?: ( site: SiteDetails ) => Promise< void >, - noStart?: boolean + noStart?: boolean, + adminUsername?: string, + adminPassword?: string, + adminEmail?: string ) => Promise< SiteDetails | void >; copySite: ( sourceSiteId: string ) => Promise< SiteDetails | void >; startServer: ( site: SiteDetails ) => Promise< void >; @@ -268,7 +271,10 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { blueprint?: Blueprint, phpVersion?: string, callback?: ( site: SiteDetails ) => Promise< void >, - noStart?: boolean + noStart?: boolean, + adminUsername?: string, + adminPassword?: string, + adminEmail?: string ) => { // Function to handle error messages and cleanup const showError = ( error?: unknown, hasBlueprint?: boolean ) => { @@ -342,6 +348,9 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { siteId: tempSiteId, phpVersion, blueprint, + adminUsername, + adminPassword, + adminEmail, noStart, } ); if ( ! newSite ) { diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 15f0da1eca..40ed06d967 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -32,6 +32,7 @@ import { generateNumberedName, generateSiteName } from '@studio/common/lib/gener import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; +import { decodePassword, encodePassword } from '@studio/common/lib/passwords'; import { portFinder } from '@studio/common/lib/port-finder'; import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; @@ -128,6 +129,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 +251,9 @@ export async function createSite( siteId?: string; phpVersion?: string; blueprint?: Blueprint; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; noStart?: boolean; } = {} ): Promise< SiteDetails > { @@ -260,6 +265,9 @@ export async function createSite( siteId: providedSiteId, blueprint, phpVersion, + adminUsername, + adminPassword, + adminEmail, noStart = false, } = config; @@ -279,6 +287,9 @@ export async function createSite( enableHttps, siteId, blueprint: blueprint?.blueprint, + adminUsername, + adminPassword, + adminEmail, noStart, }, { wpVersion, blueprint: blueprint?.blueprint } @@ -378,6 +389,22 @@ export async function updateSite( options.xdebug = updatedSite.enableXdebug ?? false; } + if ( ( updatedSite.adminUsername ?? 'admin' ) !== ( currentSite.adminUsername ?? 'admin' ) ) { + options.adminUsername = updatedSite.adminUsername; + } + + if ( + ( 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 ?? DEFAULT_ENCODED_PASSWORD ); + } + + if ( ( updatedSite.adminEmail ?? '' ) !== ( currentSite.adminEmail ?? '' ) ) { + options.adminEmail = updatedSite.adminEmail; + } + if ( updatedSite.enableDebugLog !== currentSite.enableDebugLog ) { options.debugLog = updatedSite.enableDebugLog ?? false; } @@ -618,7 +645,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/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts index 054731a574..e07e54d5e5 100644 --- a/apps/studio/src/ipc-types.d.ts +++ b/apps/studio/src/ipc-types.d.ts @@ -16,7 +16,9 @@ interface StoppedSiteDetails { isWpAutoUpdating?: boolean; customDomain?: string; enableHttps?: boolean; + adminUsername?: string; adminPassword?: string; + adminEmail?: string; tlsKey?: string; tlsCert?: string; themeDetails?: { 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 05d98b5806..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 @@ -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'; @@ -13,6 +18,7 @@ import { FormEvent, useState, useEffect, useCallback, useMemo, useRef, RefObject 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'; @@ -46,6 +52,8 @@ 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 */ @@ -160,6 +168,7 @@ export const CreateSiteForm = ( { blueprintPreferredVersions, blueprintSuggestedDomain, blueprintSuggestedHttps, + blueprintCredentials, onSubmit, onValidityChange, formRef, @@ -180,6 +189,13 @@ 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 ?? 'admin' + ); + const [ adminPassword, setAdminPassword ] = useState( + () => blueprintCredentials?.adminPassword ?? generatePassword() + ); + const [ adminEmail, setAdminEmail ] = useState( '' ); const [ pathError, setPathError ] = useState( '' ); const [ doesPathContainWordPress, setDoesPathContainWordPress ] = useState( false ); @@ -190,6 +206,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( () => { @@ -215,6 +232,21 @@ export const CreateSiteForm = ( { } }, [ defaultValues.phpVersion, defaultValues.wpVersion ] ); + // 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 ); + setAdvancedSettingsVisible( true ); + } + if ( blueprintCredentials?.adminPassword !== undefined ) { + setAdminPassword( blueprintCredentials.adminPassword ); + setAdvancedSettingsVisible( true ); + } + }, [ blueprintCredentials?.adminUsername, blueprintCredentials?.adminPassword ] ); + useEffect( () => { if ( hasUserInteracted.current || ! blueprintSuggestedDomain ) { return; @@ -255,7 +287,12 @@ export const CreateSiteForm = ( { return; } - const hasErrors = !! pathError || ( useCustomDomain && !! customDomainError ); + const hasErrors = + !! pathError || + ( useCustomDomain && !! customDomainError ) || + ! adminUsername.trim() || + ! adminPassword.trim() || + !! adminEmailError; const isValid = ! hasErrors; // Only notify if validity has actually changed @@ -264,7 +301,7 @@ export const CreateSiteForm = ( { onValidityChange( isValid ); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ pathError, customDomainError, useCustomDomain ] ); + }, [ pathError, customDomainError, useCustomDomain, adminUsername, adminPassword, adminEmail ] ); const handleSiteNameChange = useCallback( async ( name: string ) => { @@ -338,8 +375,22 @@ export const CreateSiteForm = ( { useCustomDomain, customDomain, enableHttps, + adminUsername: adminUsername || undefined, + adminPassword: adminPassword || undefined, + adminEmail: adminEmail || undefined, } ), - [ siteName, sitePath, phpVersion, wpVersion, useCustomDomain, customDomain, enableHttps ] + [ + siteName, + sitePath, + phpVersion, + wpVersion, + useCustomDomain, + customDomain, + enableHttps, + adminUsername, + adminPassword, + adminEmail, + ] ); const handleFormSubmit = useCallback( @@ -351,7 +402,16 @@ export const CreateSiteForm = ( { ); const shouldShowCustomDomainError = useCustomDomain && customDomainError; - const errorCount = [ pathError, shouldShowCustomDomainError ].filter( Boolean ).length; + 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 = () => { setAdvancedSettingsVisible( ! isAdvancedSettingsVisible ); @@ -490,6 +550,69 @@ export const CreateSiteForm = ( { /> +
+ { __( 'Admin credentials' ) } +
+
+ + { + hasUserEditedCredentials.current = true; + setAdminUsername( value ); + } } + className={ adminUsernameError ? '[&_input]:!border-red-500' : '' } + /> +
+ +
+ + { + hasUserEditedCredentials.current = true; + setAdminPassword( value ); + } } + className={ adminPasswordError ? '[&_input]:!border-red-500' : '' } + /> +
+
+ { ( adminUsernameError || adminPasswordError ) && ( + + { adminUsernameError || adminPasswordError } + + ) } +
+ +
+ + { + hasUserEditedCredentials.current = true; + setAdminEmail( value ); + } } + placeholder="admin@localhost.com" + className={ adminEmailError ? '[&_input]:!border-red-500' : '' } + /> + { adminEmailError ? ( + { adminEmailError } + ) : ( + + { __( 'Defaults to admin@localhost.com if not provided.' ) } + + ) } +
+ { showBlueprintVersionWarning && ( { __( 'Version differs from Blueprint recommendation' ) } diff --git a/apps/studio/src/modules/add-site/components/create-site.tsx b/apps/studio/src/modules/add-site/components/create-site.tsx index f36dc43d59..e6b281c5eb 100644 --- a/apps/studio/src/modules/add-site/components/create-site.tsx +++ b/apps/studio/src/modules/add-site/components/create-site.tsx @@ -22,6 +22,7 @@ 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/apps/studio/src/modules/add-site/index.tsx b/apps/studio/src/modules/add-site/index.tsx index 4bf6888026..db8416c33b 100644 --- a/apps/studio/src/modules/add-site/index.tsx +++ b/apps/studio/src/modules/add-site/index.tsx @@ -76,6 +76,7 @@ interface NavigationContentProps { setBlueprintSuggestedDomain?: ( domain: string | undefined ) => void; blueprintSuggestedHttps?: boolean; setBlueprintSuggestedHttps?: ( https: boolean | undefined ) => void; + blueprintCredentials?: { adminUsername?: string; adminPassword?: string }; blueprintSuggestedSiteName?: string; setBlueprintSuggestedSiteName?: ( name: string | undefined ) => void; selectedRemoteSite?: SyncSite; @@ -110,6 +111,7 @@ function NavigationContent( props: NavigationContentProps ) { setBlueprintSuggestedDomain, blueprintSuggestedHttps, setBlueprintSuggestedHttps, + blueprintCredentials, blueprintSuggestedSiteName, setBlueprintSuggestedSiteName, selectedRemoteSite, @@ -323,6 +325,7 @@ function NavigationContent( props: NavigationContentProps ) { onSubmit: onFormSubmit, onValidityChange, formRef, + blueprintCredentials: blueprintCredentials ?? undefined, }; return ( @@ -563,6 +566,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 ( { undefined, // blueprint parameter '8.3', expect.any( Function ), - false + false, + 'admin', + expect.any( String ), + undefined // adminEmail ); } ); } ); @@ -447,7 +450,10 @@ describe( 'AddSite', () => { undefined, // blueprint parameter '8.3', expect.any( Function ), - false + false, + 'admin', + expect.any( String ), + undefined // adminEmail ); } ); } ); diff --git a/apps/studio/src/modules/cli/lib/cli-site-creator.ts b/apps/studio/src/modules/cli/lib/cli-site-creator.ts index 4292691397..e661f50aef 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-creator.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-creator.ts @@ -35,6 +35,9 @@ export interface CreateSiteOptions { enableHttps?: boolean; siteId?: string; blueprint?: Blueprint; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; noStart?: boolean; } @@ -129,6 +132,18 @@ function buildCliArgs( options: CreateSiteOptions ): string[] { args.push( '--https' ); } + if ( options.adminUsername ) { + args.push( '--admin-username', options.adminUsername ); + } + + if ( options.adminPassword ) { + 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/apps/studio/src/modules/cli/lib/cli-site-editor.ts b/apps/studio/src/modules/cli/lib/cli-site-editor.ts index e4e6578e7e..1b978ed654 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-editor.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-editor.ts @@ -17,6 +17,9 @@ export interface EditSiteOptions { php?: string; wp?: string; xdebug?: boolean; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; debugLog?: boolean; debugDisplay?: boolean; } @@ -81,6 +84,18 @@ 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 ); + } + + if ( options.adminEmail !== undefined ) { + args.push( '--admin-email', options.adminEmail ); + } + if ( options.debugLog !== undefined ) { args.push( options.debugLog ? '--debug-log' : '--no-debug-log' ); } 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 8baf095a10..67a4e01267 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, TabPanel } from '@wordpress/components'; @@ -14,6 +20,7 @@ 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'; @@ -43,6 +50,11 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = selectedSite?.enableDebugDisplay ?? false ); const [ xdebugEnabledSite, setXdebugEnabledSite ] = useState< SiteDetails | null >( null ); + const [ adminUsername, setAdminUsername ] = useState( selectedSite?.adminUsername ?? 'admin' ); + const [ adminPassword, setAdminPassword ] = useState( + () => decodePassword( selectedSite?.adminPassword ?? '' ) || 'password' + ); + const [ adminEmail, setAdminEmail ] = useState( selectedSite?.adminEmail ?? '' ); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); const closeModal = useCallback( () => { @@ -97,6 +109,11 @@ 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 adminEmailError = adminEmail.trim() ? validateAdminEmail( adminEmail ) : ''; + const isUsernameChanged = + ! adminUsernameError && adminUsername !== ( selectedSite?.adminUsername ?? 'admin' ); const isFormUnchanged = !! selectedSite && selectedSite.name === siteName && @@ -106,10 +123,18 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = usedCustomDomain === customDomain && !! selectedSite.enableHttps === ( !! usedCustomDomain && enableHttps ) && !! selectedSite.enableXdebug === enableXdebug && + ( selectedSite.adminUsername ?? 'admin' ) === adminUsername && + ( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ) === adminPassword && + ( selectedSite.adminEmail ?? '' ) === adminEmail && !! selectedSite.enableDebugLog === enableDebugLog && !! selectedSite.enableDebugDisplay === enableDebugDisplay; const hasValidationErrors = - ! selectedSite || ! siteName.trim() || ( useCustomDomain && !! customDomainError ); + ! selectedSite || + ! siteName.trim() || + ( useCustomDomain && !! customDomainError ) || + !! adminUsernameError || + !! adminPasswordError || + !! adminEmailError; const resetFormState = useCallback( () => { if ( ! selectedSite ) { @@ -124,6 +149,9 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = setErrorUpdatingWpVersion( null ); setEnableHttps( selectedSite.enableHttps ?? false ); setEnableXdebug( selectedSite.enableXdebug ?? false ); + setAdminUsername( selectedSite.adminUsername ?? 'admin' ); + setAdminPassword( decodePassword( selectedSite.adminPassword ?? '' ) || 'password' ); + setAdminEmail( selectedSite.adminEmail ?? '' ); setEnableDebugLog( selectedSite.enableDebugLog ?? false ); setEnableDebugDisplay( selectedSite.enableDebugDisplay ?? false ); }, [ selectedSite, getEffectiveWpVersion ] ); @@ -147,6 +175,10 @@ 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' ) || + adminEmail !== ( selectedSite.adminEmail ?? '' ); const needsRestart = selectedSite.running && @@ -156,6 +188,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = phpChanged: hasPhpVersionChanged, wpChanged: hasWpVersionChanged, xdebugChanged: hasXdebugChanged, + credentialsChanged: hasCredentialsChanged, debugLogChanged: hasDebugLogChanged, debugDisplayChanged: hasDebugDisplayChanged, } ); @@ -177,6 +210,10 @@ 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 ), + adminEmail: adminEmail || undefined, enableDebugLog, enableDebugDisplay, }, @@ -352,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.' ) } + + ) } +
) } diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index 2531bb92be..6ca0ca6985 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -186,7 +186,9 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { ( { id, path, + adminUsername, adminPassword, + adminEmail, port, phpVersion, isWpAutoUpdating, @@ -208,7 +210,9 @@ function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { id, name, path, + adminUsername, adminPassword, + adminEmail, port, phpVersion, isWpAutoUpdating, diff --git a/tools/common/lib/blueprint-settings.ts b/tools/common/lib/blueprint-settings.ts index 5080b7e8c5..f966aa4ff8 100644 --- a/tools/common/lib/blueprint-settings.ts +++ b/tools/common/lib/blueprint-settings.ts @@ -5,6 +5,8 @@ type BlueprintSiteSettings = Partial< Pick< StoppedSiteDetails, 'phpVersion' | 'customDomain' | 'enableHttps' > > & { wpVersion?: string; + adminUsername?: string; + adminPassword?: string; siteName?: string; }; @@ -37,6 +39,20 @@ export function extractFormValuesFromBlueprint( blueprintJson: Blueprint ): Blue } } + // 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; + } + } + const setSiteOptionsStep = blueprintJson.steps.find( ( step: { step?: string; options?: Record< string, unknown > } ) => step.step === 'setSiteOptions' && step.options?.blogname @@ -46,6 +62,23 @@ export function extractFormValuesFromBlueprint( blueprintJson: Blueprint ): Blue } } + // 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 { + username?: string; + password?: string; + }; + if ( typeof username === 'string' ) { + values.adminUsername = username; + } + if ( typeof password === 'string' ) { + values.adminPassword = password; + } + } + } + return values; } diff --git a/tools/common/lib/blueprint-validation.ts b/tools/common/lib/blueprint-validation.ts index 9d75d17242..21d7ba08c0 100644 --- a/tools/common/lib/blueprint-validation.ts +++ b/tools/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/tools/common/lib/mu-plugins.ts b/tools/common/lib/mu-plugins.ts index 8b6addab14..584e6a5052 100644 --- a/tools/common/lib/mu-plugins.ts +++ b/tools/common/lib/mu-plugins.ts @@ -409,25 +409,59 @@ 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 ) ) { + $username = 'admin'; } - $user = get_user_by( 'login', 'admin' ); + $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 ( $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 + $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( - 'user_login' => 'admin', + 'user_login' => $username, 'user_pass' => $_POST['password'], - 'user_email' => 'admin@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 ); $result = [ 'success' => true ]; break; @@ -470,7 +504,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/tools/common/lib/passwords.ts b/tools/common/lib/passwords.ts index 6825e460fc..547123fe54 100644 --- a/tools/common/lib/passwords.ts +++ b/tools/common/lib/passwords.ts @@ -1,4 +1,7 @@ import { generatePassword } from '@automattic/generate-password'; +import { __ } from '@wordpress/i18n'; + +export { generatePassword }; /** * Generates a random, Base64-encoded password. @@ -6,15 +9,60 @@ import { generatePassword } from '@automattic/generate-password'; * @returns The Base64-encoded password. */ export function createPassword(): string { - return btoa( generatePassword() ); + return encodePassword( generatePassword() ); +} + +/** + * 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 { + 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 ); +} + +/** + * 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@]+\.[^\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. + */ +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/tools/common/lib/site-events.ts b/tools/common/lib/site-events.ts index 8a6b4b7abe..ec8f25ca02 100644 --- a/tools/common/lib/site-events.ts +++ b/tools/common/lib/site-events.ts @@ -18,7 +18,9 @@ export const siteDetailsSchema = z.object( { phpVersion: z.string(), customDomain: z.string().optional(), 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/tools/common/lib/site-needs-restart.ts b/tools/common/lib/site-needs-restart.ts index c22ab9cbe7..870ae81a7d 100644 --- a/tools/common/lib/site-needs-restart.ts +++ b/tools/common/lib/site-needs-restart.ts @@ -4,6 +4,7 @@ export interface SiteSettingChanges { phpChanged?: boolean; wpChanged?: boolean; xdebugChanged?: boolean; + credentialsChanged?: boolean; debugLogChanged?: boolean; debugDisplayChanged?: boolean; } @@ -15,6 +16,7 @@ export function siteNeedsRestart( changes: SiteSettingChanges ): boolean { phpChanged, wpChanged, xdebugChanged, + credentialsChanged, debugLogChanged, debugDisplayChanged, } = changes; @@ -25,6 +27,7 @@ export function siteNeedsRestart( changes: SiteSettingChanges ): boolean { phpChanged || wpChanged || xdebugChanged || + credentialsChanged || debugLogChanged || debugDisplayChanged ); diff --git a/tools/common/lib/tests/blueprint-settings.test.ts b/tools/common/lib/tests/blueprint-settings.test.ts index da171ffd7f..5e42b96e98 100644 --- a/tools/common/lib/tests/blueprint-settings.test.ts +++ b/tools/common/lib/tests/blueprint-settings.test.ts @@ -145,6 +145,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' ); + } ); + it( 'should extract blogname from setSiteOptions step', () => { const blueprint = { steps: [ { step: 'setSiteOptions', options: { blogname: 'My Blog' } } ], diff --git a/tools/common/lib/tests/blueprint-validation.test.ts b/tools/common/lib/tests/blueprint-validation.test.ts index 6b2a84abbf..c4d5cbe059 100644 --- a/tools/common/lib/tests/blueprint-validation.test.ts +++ b/tools/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,7 +78,6 @@ 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' ); } } ); diff --git a/tools/common/lib/tests/passwords.test.ts b/tools/common/lib/tests/passwords.test.ts index 2a0bd1da0a..fbc969c71e 100644 --- a/tools/common/lib/tests/passwords.test.ts +++ b/tools/common/lib/tests/passwords.test.ts @@ -1,5 +1,5 @@ // Removed: globals are now available via vitest/globals in tsconfig -import { createPassword, decodePassword } from '@studio/common/lib/passwords'; +import { createPassword, decodePassword, encodePassword } from '@studio/common/lib/passwords'; describe( 'createPassword', () => { it( 'should return a Base64-encoded string', () => { @@ -14,6 +14,32 @@ 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 ); + } ); + + 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', () => { it( 'should decode the password', () => { const mockPassword = 'test-password';