diff --git a/src/js/_enqueues/admin/application-passwords.js b/src/js/_enqueues/admin/application-passwords.js index c79cdb8b4037f..e59402c3e3071 100644 --- a/src/js/_enqueues/admin/application-passwords.js +++ b/src/js/_enqueues/admin/application-passwords.js @@ -5,7 +5,8 @@ ( function( $ ) { var $appPassSection = $( '#application-passwords-section' ), $newAppPassForm = $appPassSection.find( '.create-application-password' ), - $newAppPassField = $newAppPassForm.find( '.input' ), + $newAppPassField = $newAppPassForm.find( '#new_application_password_name' ), + $newAppPassExpiresField = $newAppPassForm.find( '#new_application_password_expires' ), $newAppPassButton = $newAppPassForm.find( '.button' ), $appPassTwrapper = $appPassSection.find( '.application-passwords-list-table-wrapper' ), $appPassTbody = $appPassSection.find( 'tbody' ), @@ -36,6 +37,15 @@ name: name }; + var expires = $newAppPassExpiresField.val(); + if ( expires ) { + var expiresDate = new Date( expires ); + + if ( ! isNaN( expiresDate.getTime() ) ) { + request.expires = expiresDate.toISOString(); + } + } + /** * Filters the request data used to create a new Application Password. * @@ -54,6 +64,7 @@ $newAppPassButton.removeProp( 'aria-disabled' ).removeClass( 'disabled' ); } ).done( function( response ) { $newAppPassField.val( '' ); + $newAppPassExpiresField.val( '' ); $newAppPassButton.prop( 'disabled', false ); $newAppPassForm.after( tmplNewAppPass( { @@ -77,6 +88,85 @@ */ wp.hooks.doAction( 'wp_application_passwords_created_password', response, request ); } ).fail( handleErrorResponse ); + }); + + /** + * Handles the inline editing of an application password expiration date. + * * @since 7.1.0 + */ + $appPassTbody.on( 'click', '.edit-expires', function( e ) { + e.preventDefault(); + + var $button = $( this ), + $tr = $button.closest( 'tr' ), + uuid = $tr.data( 'uuid' ), + currentExpires = $tr.data( 'expires' ), + $td = $button.closest( 'td' ); + + if ( $td.find( '.edit-expires-form' ).length ) { + return; + } + + var $form = $( '
' ); + var $input = $( '' ); + + if ( currentExpires ) { + $input.val( currentExpires.split( 'T' )[0] ); + } + + var $buttonContainer = $( '
' ); + var $saveBtn = $( '' ); + var $cancelBtn = $( '' ); + + $buttonContainer.append( $saveBtn ).append( $cancelBtn ); + $form.append( $input ).append( $buttonContainer ); + + $td.append( $form ); + $button.hide(); + $input.trigger( 'focus' ); + + // Close form on Escape key. + $input.on( 'keydown', function( e ) { + if ( 27 === e.which ) { + $cancelBtn.trigger( 'click' ); + } + if ( 13 === e.which ) { + e.preventDefault(); + $saveBtn.trigger( 'click' ); + } + }); + + $cancelBtn.on( 'click', function() { + $form.remove(); + $button.show().trigger( 'focus' ); + } ); + + $saveBtn.on( 'click', function() { + var newExpires = $input.val(); + var expiresDate = newExpires ? new Date( newExpires ) : null; + var requestData = { + expires: ( expiresDate && ! isNaN( expiresDate.getTime() ) ) ? expiresDate.toISOString() : null + }; + + clearNotices(); + $saveBtn.prop( 'disabled', true ); + $cancelBtn.prop( 'disabled', true ); + + wp.apiRequest( { + path: '/wp/v2/users/' + userId + '/application-passwords/' + uuid + '?_locale=user', + method: 'PUT', + data: JSON.stringify( requestData ), + contentType: 'application/json' + } ).always( function() { + $saveBtn.prop( 'disabled', false ); + $cancelBtn.prop( 'disabled', false ); + } ).done( function( response ) { + var $newRow = $( tmplAppPassRow( response ) ); + $tr.replaceWith( $newRow ); + $newRow.find( '.edit-expires' ).trigger( 'focus' ); + addNotice( wp.i18n.__( 'Application password expiration updated.' ), 'success' ); + } ).fail( handleErrorResponse ); + } ); } ); $appPassTbody.on( 'click', '.delete', function( e ) { diff --git a/src/wp-admin/css/forms.css b/src/wp-admin/css/forms.css index b48825a1ef5a3..187215c9c0cfb 100644 --- a/src/wp-admin/css/forms.css +++ b/src/wp-admin/css/forms.css @@ -1077,6 +1077,28 @@ table.form-table td .updated p { max-width: 20em; } +.edit-expires-form { + margin-top: 8px; + width: 100%; +} + +.edit-expires-input { + display: block; + width: 100%; + max-width: 150px; + margin-bottom: 5px; +} + +.edit-expires-button-group { + display: flex; + gap: 4px; + justify-content: flex-start; +} + +.column-expires { + vertical-align: top; +} + .auth-app-card.card { max-width: 768px; } diff --git a/src/wp-admin/includes/class-wp-application-passwords-list-table.php b/src/wp-admin/includes/class-wp-application-passwords-list-table.php index 9a60853016fc5..a15495e881009 100644 --- a/src/wp-admin/includes/class-wp-application-passwords-list-table.php +++ b/src/wp-admin/includes/class-wp-application-passwords-list-table.php @@ -29,6 +29,7 @@ public function get_columns() { 'created' => __( 'Created' ), 'last_used' => __( 'Last Used' ), 'last_ip' => __( 'Last IP' ), + 'expires' => __( 'Expires' ), 'revoke' => __( 'Revoke' ), ); } @@ -101,6 +102,35 @@ public function column_last_ip( $item ) { } } + /** + * Handles the expires column output. + * + * @since 7.1.0 + * + * @param array $item The current application password item. + */ + public function column_expires( $item ) { + if ( empty( $item['expires'] ) ) { + echo '—'; + } else { + $date = date_i18n( __( 'F j, Y' ), $item['expires'] ); + if ( time() > $item['expires'] ) { + printf( + '%s', + /* translators: %s: Expiration date for the Application Password. */ + sprintf( __( 'Expired on %s' ), $date ) + ); + } else { + echo $date; + } + } + printf( + '
', + esc_attr__( 'Edit Application Password Expiration Date' ), + esc_html__( 'Edit Expiry' ) + ); + } + /** * Handles the revoke column output. * @@ -230,6 +260,25 @@ public function print_js_template_row() { case 'last_ip': echo "{{ data.last_ip || '—' }}"; break; + case 'expires': + ?> + <# if ( data.expires ) { #> + <# var isExpired = new Date().getTime() > new Date( data.expires ).getTime(); #> + <# var formattedDate = wp.date.dateI18n( , data.expires ); #> + <# if ( isExpired ) { #> + + <# } else { #> + {{ formattedDate }} + <# } #> + <# } else { #> + — + <# } #> +
+ %2$s', diff --git a/src/wp-admin/user-edit.php b/src/wp-admin/user-edit.php index c25380a93ee91..84fe33f698013 100644 --- a/src/wp-admin/user-edit.php +++ b/src/wp-admin/user-edit.php @@ -837,6 +837,12 @@

+
+ + +

+
+ time(), 'last_used' => null, 'last_ip' => null, + 'expires' => isset( $args['expires'] ) ? (int) $args['expires'] : null, ); $passwords = static::get_user_application_passwords( $user_id ); @@ -282,6 +283,14 @@ public static function update_application_password( $user_id, $uuid, $update = a $save = false; + if ( array_key_exists( 'expires', $update ) ) { + $expires = null === $update['expires'] ? null : (int) $update['expires']; + if ( ! array_key_exists( 'expires', $item ) || $item['expires'] !== $expires ) { + $item['expires'] = $expires; + $save = true; + } + } + if ( ! empty( $update['name'] ) && $item['name'] !== $update['name'] ) { $item['name'] = $update['name']; $save = true; diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php index 767917d6f6fd0..bf08d4aa52a29 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php @@ -583,6 +583,11 @@ protected function prepare_item_for_database( $request ) { $prepared->app_id = $request['app_id']; } + $prepared->expires = null; + if ( isset( $request['expires'] ) && null !== $request['expires'] ) { + $prepared->expires = rest_parse_date( $request['expires'] ); + } + /** * Filters an application password before it is inserted via the REST API. * @@ -619,6 +624,7 @@ public function prepare_item_for_response( $item, $request ) { 'created' => gmdate( 'Y-m-d\TH:i:s', $item['created'] ), 'last_used' => $item['last_used'] ? gmdate( 'Y-m-d\TH:i:s', $item['last_used'] ) : null, 'last_ip' => $item['last_ip'] ? $item['last_ip'] : null, + 'expires' => ! empty( $item['expires'] ) ? gmdate( 'Y-m-d\TH:i:s', $item['expires'] ) : null, ); if ( isset( $item['new_password'] ) ) { @@ -849,6 +855,12 @@ public function get_item_schema() { 'context' => array( 'view', 'edit' ), 'readonly' => true, ), + 'expires' => array( + 'description' => __( 'The GMT date the application password expires.' ), + 'type' => array( 'string', 'null' ), + 'format' => 'date-time', + 'context' => array( 'view', 'edit' ), + ), ), ); diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 9c635f63d288a..ec9347dd6eaa1 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -463,6 +463,13 @@ function wp_authenticate_application_password( $error = new WP_Error(); + if ( ! empty( $item['expires'] ) && time() > $item['expires'] ) { + $error->add( + 'application_password_expired', + __( 'The provided application password has expired.' ) + ); + } + /** * Fires when an application password has been successfully checked as valid. * diff --git a/tests/e2e/specs/profile/applications-passwords.test.js b/tests/e2e/specs/profile/applications-passwords.test.js index f6c5cf7a781b7..27ee00feb8afc 100644 --- a/tests/e2e/specs/profile/applications-passwords.test.js +++ b/tests/e2e/specs/profile/applications-passwords.test.js @@ -40,6 +40,56 @@ test.describe( 'Manage applications passwords', () => { ); } ); + test('should correctly create a new application password with expiration', async ( { + page, + applicationPasswords + } ) => { + const expiresDate = new Date(); + expiresDate.setDate( expiresDate.getDate() + 7 ); + const expiresString = expiresDate.toISOString().split( 'T' )[ 0 ]; + + await applicationPasswords.create( TEST_APPLICATION_NAME, expiresString ); + + const [ app ] = await applicationPasswords.get(); + expect( app['name'] ).toBe( TEST_APPLICATION_NAME ); + expect( app['expires'] ).not.toBeNull(); + expect( app['expires'].startsWith( expiresString ) ).toBe( true ); + + const successMessage = page.getByRole( 'alert' ); + await expect( successMessage ).toHaveClass( /notice-success/ ); + } ); + + test('should correctly update an application password expiration date', async ( { + page, + applicationPasswords + } ) => { + await applicationPasswords.create(); + + const [ app ] = await applicationPasswords.get(); + expect( app['expires'] ).toBeNull(); + + const editButton = page.getByRole( 'button', { name: 'Edit Expiration Date' } ); + await expect( editButton ).toBeVisible(); + await editButton.click(); + + const expiresInput = page.locator( '.edit-expires-input' ); + await expect( expiresInput ).toBeVisible(); + + const expiresDate = new Date(); + expiresDate.setDate( expiresDate.getDate() + 10 ); + const expiresString = expiresDate.toISOString().split( 'T' )[ 0 ]; + await expiresInput.fill( expiresString ); + + const saveButton = page.getByRole( 'button', { name: 'Save' } ); + await saveButton.click(); + + await expect( page.getByRole( 'alert' ) ).toContainText( 'Application password expiration updated.' ); + + const [ updatedApp ] = await applicationPasswords.get(); + expect( updatedApp['expires'] ).not.toBeNull(); + expect( updatedApp['expires'].startsWith( expiresString ) ).toBe( true ); + } ); + test( 'should correctly revoke a single application password', async ( { page, applicationPasswords @@ -94,13 +144,19 @@ class ApplicationPasswords { this.admin = admin; } - async create(applicationName = TEST_APPLICATION_NAME) { + async create(applicationName = TEST_APPLICATION_NAME, expires = null) { await this.admin.visitAdminPage( '/profile.php' ); const newPasswordField = this.page.getByRole( 'textbox', { name: 'New Application Password Name' } ); await expect( newPasswordField ).toBeVisible(); await newPasswordField.fill( applicationName ); + if ( expires ) { + const newPasswordExpiresField = this.page.getByLabel( 'Expires on' ); + await expect( newPasswordExpiresField ).toBeVisible(); + await newPasswordExpiresField.fill( expires ); + } + await this.page.getByRole( 'button', { name: 'Add Application Password' } ).click(); await expect( this.page.getByRole( 'alert' ) ).toBeVisible(); } diff --git a/tests/phpunit/tests/auth.php b/tests/phpunit/tests/auth.php index a290d11e118e6..1fdb846c707ac 100644 --- a/tests/phpunit/tests/auth.php +++ b/tests/phpunit/tests/auth.php @@ -1767,6 +1767,26 @@ public function test_authenticate_application_password_chunked() { $this->assertSame( self::$user_id, $user->ID ); } + /** + * @ticket 53995 + */ + public function test_authenticate_application_password_expired() { + add_filter( 'application_password_is_api_request', '__return_true' ); + add_filter( 'wp_is_application_passwords_available', '__return_true' ); + + list( $password ) = WP_Application_Passwords::create_new_application_password( + self::$user_id, + array( + 'name' => 'phpunit', + 'expires' => time() - DAY_IN_SECONDS, + ) + ); + + $error = wp_authenticate_application_password( null, self::$_user->user_login, $password ); + $this->assertWPError( $error ); + $this->assertSame( 'application_password_expired', $error->get_error_code() ); + } + /** * @ticket 51939 */ diff --git a/tests/phpunit/tests/rest-api/application-passwords.php b/tests/phpunit/tests/rest-api/application-passwords.php index 65e3bf222d85f..a831407075bb5 100644 --- a/tests/phpunit/tests/rest-api/application-passwords.php +++ b/tests/phpunit/tests/rest-api/application-passwords.php @@ -95,7 +95,7 @@ public function test_create_new_application_password( array $args, array $names $this->assertNotEmpty( $new_password ); $this->assertSame( - array( 'uuid', 'app_id', 'name', 'password', 'created', 'last_used', 'last_ip' ), + array( 'uuid', 'app_id', 'name', 'password', 'created', 'last_used', 'last_ip', 'expires' ), array_keys( $new_item ) ); $this->assertSame( $args['name'], $new_item['name'] ); @@ -106,10 +106,16 @@ public function data_create_new_application_password() { 'should create new password when no passwords exists' => array( 'args' => array( 'name' => 'test3' ), ), - 'should create new password when name is unique' => array( + 'should create new password when name is unique' => array( 'args' => array( 'name' => 'test3' ), 'names' => array( 'test1', 'test2' ), ), + 'should create new password with expiration' => array( + 'args' => array( + 'name' => 'test_expire', + 'expires' => time() + DAY_IN_SECONDS, + ), + ), ); } @@ -154,7 +160,7 @@ public function test_update_application_password( array $update, array $existing // Check updated only given values. $updated_item = WP_Application_Passwords::get_user_application_password( self::$user_id, $uuid ); foreach ( $updated_item as $key => $update_value ) { - $expected_value = $update[ $key ] ?? $original_item[ $key ]; + $expected_value = array_key_exists( $key, $update ) ? $update[ $key ] : $original_item[ $key ]; $this->assertSame( $expected_value, $update_value ); } } @@ -186,6 +192,17 @@ public function data_update_application_password() { 'update' => array( 'name' => 'Test Updated' ), 'existing' => array( 'name' => 'Test' ), ), + 'should update expires' => array( + 'update' => array( 'expires' => time() + DAY_IN_SECONDS ), + 'existing' => array( 'name' => 'Test' ), + ), + 'should clear expires' => array( + 'update' => array( 'expires' => null ), + 'existing' => array( + 'name' => 'Test', + 'expires' => time() + DAY_IN_SECONDS, + ), + ), ); } diff --git a/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php b/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php index 060a5c0912a94..287d649e2ba19 100644 --- a/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php +++ b/tests/phpunit/tests/rest-api/rest-application-passwords-controller.php @@ -906,6 +906,7 @@ protected function check_response( $response, $item, $password = false ) { $this->assertArrayHasKey( 'created', $response ); $this->assertArrayHasKey( 'last_used', $response ); $this->assertArrayHasKey( 'last_ip', $response ); + $this->assertArrayHasKey( 'expires', $response ); $this->assertSame( $item['uuid'], $response['uuid'] ); $this->assertSame( $item['app_id'], $response['app_id'] ); @@ -924,6 +925,12 @@ protected function check_response( $response, $item, $password = false ) { $this->assertNull( $response['last_ip'] ); } + if ( ! empty( $item['expires'] ) ) { + $this->assertSame( gmdate( 'Y-m-d\TH:i:s', $item['expires'] ), $response['expires'] ); + } else { + $this->assertNull( $response['expires'] ); + } + if ( $password ) { $this->assertArrayHasKey( 'password', $response ); } else { @@ -947,7 +954,8 @@ public function test_get_item_schema() { $this->assertArrayHasKey( 'created', $properties ); $this->assertArrayHasKey( 'last_used', $properties ); $this->assertArrayHasKey( 'last_ip', $properties ); - $this->assertCount( 7, $properties ); + $this->assertArrayHasKey( 'expires', $properties ); + $this->assertCount( 8, $properties ); } /** diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 003dc397ae305..6dc369c366f5c 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -10200,6 +10200,15 @@ mockedApiResponse.Schema = { "minLength": 1, "pattern": ".*\\S.*", "required": true + }, + "expires": { + "description": "The GMT date the application password expires.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false } } }, @@ -10295,6 +10304,15 @@ mockedApiResponse.Schema = { "minLength": 1, "pattern": ".*\\S.*", "required": false + }, + "expires": { + "description": "The GMT date the application password expires.", + "type": [ + "string", + "null" + ], + "format": "date-time", + "required": false } } },