Skip to content
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 @@ -10,7 +10,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 @@ -41,6 +42,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 @@ -59,6 +69,7 @@
$newAppPassButton.removeProp( 'aria-disabled' ).removeClass( 'disabled' );
} ).done( function( response ) {
$newAppPassField.val( '' );
$newAppPassExpiresField.val( '' );
$newAppPassButton.prop( 'disabled', false );

$newAppPassForm.after( tmplNewAppPass( {
Expand All @@ -82,6 +93,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 @@ -1075,6 +1075,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 @@ -235,6 +265,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 @@ -848,6 +848,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 @@
$prepared->app_id = $request['app_id'];
}

$prepared->expires = null;
if ( isset( $request['expires'] ) && null !== $request['expires'] ) {

Check warning on line 587 in src/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php

View workflow job for this annotation

GitHub Actions / PHP static analysis / Run PHP static analysis

Strict comparison using !== between null and mixed will always evaluate to true.
$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 @@
'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 @@
'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 @@

$error = new WP_Error();

if ( ! empty( $item['expires'] ) && time() > $item['expires'] ) {

Check warning on line 466 in src/wp-includes/user.php

View workflow job for this annotation

GitHub Actions / PHP static analysis / Run PHP static analysis

Offset 'expires' does not exist on array{uuid: string, app_id: string, name: string, password: string, created: int, last_used: int|null, last_ip: string|null}.
$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