Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion src/js/_enqueues/admin/application-passwords.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down Expand Up @@ -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.
*
Expand All @@ -54,6 +64,7 @@
$newAppPassButton.removeProp( 'aria-disabled' ).removeClass( 'disabled' );
} ).done( function( response ) {
$newAppPassField.val( '' );
$newAppPassExpiresField.val( '' );
$newAppPassButton.prop( 'disabled', false );

$newAppPassForm.after( tmplNewAppPass( {
Expand All @@ -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 = $( '<div class="edit-expires-form"></div>' );
var $input = $( '<input type="date" class="edit-expires-input" />' );

if ( currentExpires ) {
$input.val( currentExpires.split( 'T' )[0] );
}

var $buttonContainer = $( '<div class="edit-expires-button-group"></div>' );
var $saveBtn = $( '<button type="button" class="button button-small button-primary">' + wp.i18n.__( 'Save' ) + '</button>' );
var $cancelBtn = $( '<button type="button" class="button button-small">' + wp.i18n.__( 'Cancel' ) + '</button>' );

$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 ) {
Expand Down
22 changes: 22 additions & 0 deletions src/wp-admin/css/forms.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function get_columns() {
'created' => __( 'Created' ),
'last_used' => __( 'Last Used' ),
'last_ip' => __( 'Last IP' ),
'expires' => __( 'Expires' ),
'revoke' => __( 'Revoke' ),
);
}
Expand Down Expand Up @@ -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 '&mdash;';
} 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(
'<br><button type="button" class="button-link edit-expires" aria-label="%s">%s</button>',
esc_attr__( 'Edit Application Password Expiration Date' ),
esc_html__( 'Edit Expiry' )
);
}

/**
* Handles the revoke column output.
*
Expand Down Expand Up @@ -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( <?php echo wp_json_encode( __( 'F j, Y' ) ); ?>, data.expires ); #>
<# if ( isExpired ) { #>
<?php
/* translators: %s: Expiration date for the Application Password. */
printf( esc_html__( 'Expired on %s' ), '{{ formattedDate }}' );
?>
<# } else { #>
{{ formattedDate }}
<# } #>
<# } else { #>
<# } #>
<br><button type="button" class="button-link edit-expires" aria-label="<?php esc_attr_e( 'Edit Expiration Date' ); ?>"><?php esc_html_e( 'Edit Expiry' ); ?></button>
<?php
break;
case 'revoke':
printf(
'<button type="button" class="button delete" aria-label="%1$s">%2$s</button>',
Expand Down
6 changes: 6 additions & 0 deletions src/wp-admin/user-edit.php
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,12 @@
<p class="description" id="new_application_password_name_desc"><?php _e( 'Required to create an Application Password, but not to update the user.' ); ?></p>
</div>

<div class="form-field">
<label for="new_application_password_expires"><?php _e( 'Expires on' ); ?></label>
<input type="date" id="new_application_password_expires" name="new_application_password_expires" class="input ltr" />
<p class="description"><?php _e( 'Optional. Set an expiration date for this password.' ); ?></p>
</div>

<?php
/**
* Fires in the create Application Passwords form.
Expand Down
9 changes: 9 additions & 0 deletions src/wp-includes/class-wp-application-passwords.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public static function create_new_application_password( $user_id, $args = array(
'created' => time(),
'last_used' => null,
'last_ip' => null,
'expires' => isset( $args['expires'] ) ? (int) $args['expires'] : null,
);

$passwords = static::get_user_application_passwords( $user_id );
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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'] ) ) {
Expand Down Expand Up @@ -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' ),
),
),
);

Expand Down
7 changes: 7 additions & 0 deletions src/wp-includes/user.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
58 changes: 57 additions & 1 deletion tests/e2e/specs/profile/applications-passwords.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down
Loading
Loading