Index: src/wp-admin/includes/ajax-actions.php
===================================================================
--- src/wp-admin/includes/ajax-actions.php	(revision 42978)
+++ src/wp-admin/includes/ajax-actions.php	(working copy)
@@ -4341,7 +4341,7 @@
 	/**
 	 * Filters the array of exporter callbacks.
 	 *
-	 * @since 4.9.5.
+	 * @since 4.9.6
 	 *
 	 * @param array $args {
 	 *     An array of callable exporters of personal data. Default empty array.
@@ -4429,7 +4429,7 @@
 	 *
 	 * Allows the export response to be consumed by destinations in addition to Ajax.
 	 *
-	 * @since 4.9.5
+	 * @since 4.9.6
 	 *
 	 * @param array  $response        The personal data for the given exporter and page.
 	 * @param int    $exporter_index  The index of the exporter that provided this data.
@@ -4443,3 +4443,193 @@
 
 	wp_send_json_success( $response );
 }
+
+/**
+ * Ajax handler for erasing personal data.
+ *
+ * @since 4.9.6
+ */
+function wp_ajax_wp_privacy_erase_personal_data() {
+	$request_id  = sanitize_text_field( $_POST['id'] );
+	check_ajax_referer( 'wp-privacy-erase-personal-data-' . $request_id, 'security' );
+
+	// Find the request CPT
+	$request = get_post( $request_id );
+	if ( 'user_remove_request' !== $request->post_type ) {
+		wp_send_json_error( __( 'Error: Invalid request ID.' ) );
+	}
+
+	$email_address = get_post_meta( $request_id, '_user_email', true );
+	if ( function_exists( 'mb_strtolower' ) ) {
+		$email_address = trim( mb_strtolower( $email_address ) );
+	} else {
+		$email_address = trim( strtolower( $email_address ) );
+	}
+
+	if ( ! is_email( $email_address ) ) {
+		wp_send_json_error( __( 'Error: Invalid email address in request.' ) );
+	}
+
+	$eraser_index = (int) $_POST['eraser'];
+	$page         = (int) $_POST['page'];
+
+	/**
+	 * Filters the array of personal data eraser callbacks.
+	 *
+	 * @since 4.9.6
+	 *
+	 * @param array $args {
+	 *     An array of callable erasers of personal data. Default empty array.
+	 *     [
+	 *         callback               string  Callable eraser that accepts an email address and
+	 *                                        a page and returns an array with the number of items
+	 *                                        removed, the number of items retained and any messages
+	 *                                        from the eraser, as well as if additional pages are
+	 *                                        available
+	 *         exporter_friendly_name string  Translated user facing friendly name for the eraser
+	 *     ]
+	 * }
+	 */
+	$erasers = apply_filters( 'wp_privacy_personal_data_erasers', array() );
+
+	// Do we have any registered erasers?
+	if ( 0 < count( $erasers ) ) {
+		if ( $eraser_index < 1 ) {
+			wp_send_json_error( __( 'Error: Eraser index cannot be less than one.' ) );
+		}
+
+		if ( $eraser_index > count( $erasers ) ) {
+			wp_send_json_error( __( 'Error: Eraser index is out of range.' ) );
+		}
+
+		if ( $page < 1 ) {
+			wp_send_json_error( __( 'Error: Page index cannot be less than one.' ) );
+		}
+
+		$index = $eraser_index - 1; // Convert to zero based for eraser index
+		$eraser = $erasers[ $index ];
+		if ( ! is_array( $eraser ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Expected an array describing the eraser at index %d.' ),
+					$eraser_index
+				)
+			);
+		}
+		if ( ! array_key_exists( 'callback', $eraser ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Eraser array at index %d does not include a callback.' ),
+					$eraser_index
+				)
+			);
+		}
+		if ( ! is_callable( $eraser['callback'] ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Eraser callback at index %d is not a valid callback.' ),
+					$eraser_index
+				)
+			);
+		}
+		if ( ! array_key_exists( 'eraser_friendly_name', $eraser ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Eraser array at index %d does not include a friendly name.' ),
+					$eraser_index
+				)
+			);
+		}
+
+		$callback = $erasers[ $index ]['callback'];
+		$eraser_friendly_name = $erasers[ $index ]['eraser_friendly_name'];
+
+		$response = call_user_func( $callback, $email_address, $page );
+		if ( is_wp_error( $response ) ) {
+			wp_send_json_error( $response );
+		}
+
+		if ( ! is_array( $response ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Did not receive array from %s eraser (index %d).' ),
+					$eraser_friendly_name,
+					$eraser_index
+				)
+			);
+		}
+		if ( ! array_key_exists( 'num_items_removed', $response ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Expected num_items_removed key in response array from %s eraser (index %d).' ),
+					$eraser_friendly_name,
+					$eraser_index
+				)
+			);
+		}
+		if ( ! array_key_exists( 'num_items_retained', $response ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Expected num_items_retained key in response array from %s eraser (index %d).' ),
+					$eraser_friendly_name,
+					$eraser_index
+				)
+			);
+		}
+		if ( ! array_key_exists( 'messages', $response ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Expected messages key in response array from %s eraser (index %d).' ),
+					$eraser_friendly_name,
+					$eraser_index
+				)
+			);
+		}
+		if ( ! is_array( $response['messages'] ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Expected messages key to reference an array in response array from %s eraser (index %d).' ),
+					$eraser_friendly_name,
+					$eraser_index
+				)
+			);
+		}
+		if ( ! array_key_exists( 'done', $response ) ) {
+			wp_send_json_error(
+				sprintf(
+					__( 'Error: Expected done flag in response array from %s eraser (index %d).' ),
+					$eraser_friendly_name,
+					$eraser_index
+				)
+			);
+		}
+	} else {
+		// No erasers, so we're done
+		$response = array(
+			'num_items_removed' => 0,
+			'num_items_retained' => 0,
+			'messages' => array(),
+			'done' => true,
+		);
+	}
+
+	/**
+	 * Filters a page of personal data eraser data.
+	 *
+	 * Allows the erasure response to be consumed by destinations in addition to Ajax.
+	 *
+	 * @since 4.9.6
+	 *
+	 * @param array  $response        The personal data for the given exporter and page.
+	 * @param int    $exporter_index  The index of the exporter that provided this data.
+	 * @param string $email_address   The email address associated with this personal data.
+	 * @param int    $page            The zero-based page for this response.
+	 * @param int    $request_id      The privacy request post ID associated with this request.
+	 */
+	$response = apply_filters( 'wp_privacy_personal_data_erasure_page', $response, $eraser_index, $email_address, $page, $request_id );
+	if ( is_wp_error( $response ) ) {
+		wp_send_json_error( $response );
+	}
+
+	wp_send_json_success( $response );
+}
Index: src/wp-admin/includes/user.php
===================================================================
--- src/wp-admin/includes/user.php	(revision 42978)
+++ src/wp-admin/includes/user.php	(working copy)
@@ -709,23 +709,23 @@
  * @access private
  */
 function _wp_personal_data_handle_actions() {
-	if ( isset( $_POST['export_personal_data_email_retry'] ) ) { // WPCS: input var ok.
+	if ( isset( $_POST['privacy_action_email_retry'] ) ) { // WPCS: input var ok.
 		check_admin_referer( 'bulk-privacy_requests' );
 
-		$request_id = absint( current( array_keys( (array) wp_unslash( $_POST['export_personal_data_email_retry'] ) ) ) ); // WPCS: input var ok, sanitization ok.
+		$request_id = absint( current( array_keys( (array) wp_unslash( $_POST['privacy_action_email_retry'] ) ) ) ); // WPCS: input var ok, sanitization ok.
 		$result     = _wp_privacy_resend_request( $request_id );
 
 		if ( is_wp_error( $result ) ) {
 			add_settings_error(
-				'export_personal_data_email_retry',
-				'export_personal_data_email_retry',
+				'privacy_action_email_retry',
+				'privacy_action_email_retry',
 				$result->get_error_message(),
 				'error'
 			);
 		} else {
 			add_settings_error(
-				'export_personal_data_email_retry',
-				'export_personal_data_email_retry',
+				'privacy_action_email_retry',
+				'privacy_action_email_retry',
 				__( 'Confirmation request re-resent successfully.' ),
 				'updated'
 			);
@@ -908,12 +908,17 @@
 
 	_wp_personal_data_handle_actions();
 
+	// "Borrow" xfn.js for now so we don't have to create new files.
+	wp_enqueue_script( 'xfn' );
+
 	$requests_table = new WP_Privacy_Data_Removal_Requests_Table( array(
 		'plural'   => 'privacy_requests',
 		'singular' => 'privacy_request',
 	) );
+
 	$requests_table->process_bulk_action();
 	$requests_table->prepare_items();
+
 	?>
 	<div class="wrap nosubsub">
 		<h1><?php esc_html_e( 'Remove Personal Data' ); ?></h1>
@@ -1358,8 +1363,18 @@
 	 * @return string
 	 */
 	public function column_email( $item ) {
+		$exporters       = apply_filters( 'wp_privacy_personal_data_exporters', array() );
+		$exporters_count = count( $exporters );
+		$request_id      = $item['request_id'];
+		$nonce           = wp_create_nonce( 'wp-privacy-export-personal-data-' . $request_id );
+
+		$download_data_markup = '<div class="download_personal_data" data-exporters-count="' . esc_attr( $exporters_count ) . '" data-request-id="' . esc_attr( $request_id ) . '" data-nonce="' . esc_attr( $nonce ) . '">' .
+			'<span class="download_personal_data_idle"><a href="#" >' . __( 'Download Personal Data' ) . '</a></span>' .
+			'<span style="display:none" class="download_personal_data_processing" >' . __( 'Downloading Data...' ) . '</span>' .
+			'<span style="display:none" class="download_personal_data_failed">' . __( 'Download Failed!' ) . ' <a href="#" >' . __( 'Retry' ) . '</a></span>';
+
 		$row_actions = array(
-			'download_data' => __( 'Download Personal Data' ),
+			'download_data' => $download_data_markup,
 		);
 
 		return sprintf( '%1$s %2$s', $item['email'], $this->row_actions( $row_actions ) );
@@ -1383,7 +1398,7 @@
 				// TODO Complete in follow on patch.
 				break;
 			case 'request-failed':
-				submit_button( __( 'Retry' ), 'secondary', 'export_personal_data_email_retry[' . $item['request_id'] . ']', false );
+				submit_button( __( 'Retry' ), 'secondary', 'privacy_action_email_retry[' . $item['request_id'] . ']', false );
 				break;
 			case 'request-completed':
 				echo '<a href="' . esc_url( wp_nonce_url( add_query_arg( array(
@@ -1428,15 +1443,24 @@
 	 * @return string
 	 */
 	public function column_email( $item ) {
-		$row_actions = array(
-			// TODO Complete in follow on patch.
-			'remove_data' => __( 'Remove Personal Data' ),
-		);
+		$row_actions = array();
 
-		// If we have a user ID, include a delete user action.
-		if ( ! empty( $item['user_id'] ) ) {
-			// TODO Complete in follow on patch.
-			$row_actions['delete_user'] = __( 'Delete User' );
+		// Allow the administrator to "force remove" the personal data even if confirmation has not yet been received
+		$status = get_post_status( $item['request_id'] );
+		if ( 'request-confirmed' !== $status ) {
+			$erasers       = apply_filters( 'wp_privacy_personal_data_erasers', array() );
+			$erasers_count = count( $erasers );
+			$request_id    = $item['request_id'];
+			$nonce         = wp_create_nonce( 'wp-privacy-erase-personal-data-' . $request_id );
+
+			$remove_data_markup = '<div class="remove_personal_data force_remove_personal_data" data-erasers-count="' . esc_attr( $erasers_count ) . '" data-request-id="' . esc_attr( $request_id ) . '" data-nonce="' . esc_attr( $nonce ) . '">' .
+				'<span class="remove_personal_data_idle"><a href="#" >' . __( 'Force Remove Personal Data' ) . '</a></span>' .
+				'<span style="display:none" class="remove_personal_data_processing" >' . __( 'Removing Data...' ) . '</span>' .
+				'<span style="display:none" class="remove_personal_data_failed">' . __( 'Force Remove Failed!' ) . ' <a href="#" >' . __( 'Retry' ) . '</a></span>';
+
+			$row_actions = array(
+				'remove_data' => $remove_data_markup,
+			);
 		}
 
 		return sprintf( '%1$s %2$s', $item['email'], $this->row_actions( $row_actions ) );
@@ -1450,6 +1474,35 @@
 	 * @param array $item Item being shown.
 	 */
 	public function column_next_steps( $item ) {
+		$status = get_post_status( $item['request_id'] );
+
+		switch ( $status ) {
+			case 'request-pending':
+				esc_html_e( 'Waiting for confirmation' );
+				break;
+			case 'request-confirmed':
+				$erasers       = apply_filters( 'wp_privacy_personal_data_erasers', array() );
+				$erasers_count = count( $erasers );
+				$request_id    = $item['request_id'];
+				$nonce         = wp_create_nonce( 'wp-privacy-erase-personal-data-' . $request_id );
+
+				$remove_data_markup = '<div class="remove_personal_data" data-force-erase="1" data-erasers-count="' . esc_attr( $erasers_count ) . '" data-request-id="' . esc_attr( $request_id ) . '" data-nonce="' . esc_attr( $nonce ) . '">' .
+					'<span class="remove_personal_data_idle"><a class="button" href="#" >' . __( 'Remove Personal Data' ) . '</a></span>' .
+					'<span style="display:none" class="remove_personal_data_processing button updating-message" >' . __( 'Removing Data...' ) . '</span>' .
+					'<span style="display:none" class="remove_personal_data_failed">' . __( 'Removing Data Failed!' ) . ' <a class="button" href="#" >' . __( 'Retry' ) . '</a></span>';
+
+				echo $remove_data_markup;
+				break;
+			case 'request-failed':
+				submit_button( __( 'Retry' ), 'secondary', 'privacy_action_email_retry[' . $item['request_id'] . ']', false );
+				break;
+			case 'request-completed':
+				echo '<a href="' . esc_url( wp_nonce_url( add_query_arg( array(
+					'action' => 'delete',
+					'request_id' => array( $item['request_id'] )
+				), admin_url( 'tools.php?page=remove_personal_data' ) ), 'bulk-privacy_requests' ) ) . '">' . esc_html__( 'Remove request' ) . '</a>';
+				break;
+		}
 	}
 
 }
Index: src/wp-admin/js/xfn.js
===================================================================
--- src/wp-admin/js/xfn.js	(revision 42978)
+++ src/wp-admin/js/xfn.js	(working copy)
@@ -20,3 +20,124 @@
 		$( '#link_rel' ).val( ( isMe ) ? 'me' : inputs.substr( 0,inputs.length - 1 ) );
 	});
 });
+
+// Privacy request action handling
+
+jQuery( document ).ready( function( $ ) {
+	var strings = window.privacyToolsL10n || {};
+
+	function set_action_state( $action, state ) {
+		$action.children().hide();
+		$action.children( '.' + state ).show();
+	}
+
+	function clearResultsAfterRow( $requestRow ) {
+		if ( $requestRow.next().hasClass( 'request-results' ) ) {
+			$requestRow.next().remove();
+		}
+	}
+
+	function appendResultsAfterRow( $requestRow, classes, summaryMessage, additionalMessages ) {
+		clearResultsAfterRow( $requestRow );
+		if ( additionalMessages.length ) {
+			// TODO - render additionalMessages after the summaryMessage
+		}
+
+		$requestRow.after( function() {
+			return '<tr class="request-results"><td colspan="5"><div class="notice inline notice-alt ' + classes + '"><p>' +
+				summaryMessage +
+				'</p></div></td></tr>';
+		} );
+	}
+
+	$( '.remove_personal_data a' ).click( function( event ) {
+		event.preventDefault();
+		event.stopPropagation();
+
+		var $this         = $( this );
+		var $action       = $this.parents( '.remove_personal_data' );
+		var $requestRow   = $this.parents( 'tr' );
+		var requestID     = $action.data( 'request-id' );
+		var nonce         = $action.data( 'nonce' );
+		var erasersCount  = $action.data( 'erasers-count' );
+
+		var removedCount  = 0;
+		var retainedCount = 0;
+		var messages      = [];
+
+		$action.blur();
+		clearResultsAfterRow( $requestRow );
+
+		function on_erasure_done_success() {
+			set_action_state( $action, 'remove_personal_data_idle' );
+			var summaryMessage = strings.noDataFound;
+			var classes = 'notice-success';
+			if ( 0 == removedCount ) {
+				if ( 0 == retainedCount ) {
+					summaryMessage = strings.noDataFound;
+				} else {
+					summaryMessage = strings.noneRemoved;
+					classes = 'notice-warning';
+				}
+			} else {
+				if ( 0 == retainedCount ) {
+					summaryMessage = strings.foundAndRemoved;
+				} else {
+					summaryMessage = strings.someNotRemoved;
+					classes = 'notice-warning';
+				}
+			}
+			appendResultsAfterRow( $requestRow, 'notice-success', summaryMessage, [] );
+		}
+
+		function on_erasure_failure( textStatus, error ) {
+			set_action_state( $action, 'remove_personal_data_failed' );
+			appendResultsAfterRow( $requestRow, 'notice-error', strings.anErrorOccurred, [] );
+		}
+
+		function do_next_erasure( eraserIndex, pageIndex ) {
+			$.ajax( {
+				url: ajaxurl,
+				data: {
+					action: 'wp-privacy-erase-personal-data',
+					eraser: eraserIndex,
+					id: requestID,
+					page: pageIndex,
+					security: nonce,
+				},
+				method: 'post'
+			} ).done( function( response ) {
+				if ( ! response.success ) {
+					on_erasure_failure( 'error', response.data );
+					return;
+				}
+				var responseData = response.data;
+				if ( responseData.num_items_removed ) {
+					removedCount += responseData.num_items_removed;
+				}
+				if ( responseData.num_items_retained ) {
+					retainedCount += responseData.num_items_removed;
+				}
+				if ( responseData.messages ) {
+					messages = messages.concat( responseData.messages );
+				}
+				if ( ! responseData.done ) {
+					setTimeout( do_next_erasure( eraserIndex, pageIndex + 1 ) );
+				} else {
+					if ( eraserIndex < erasersCount ) {
+						setTimeout( do_next_erasure( eraserIndex + 1, 1 ) );
+					} else {
+						on_erasure_done_success();
+					}
+				}
+			} ).fail( function( jqxhr, textStatus, error ) {
+				on_erasure_failure( textStatus, error );
+			} );
+		}
+
+		// And now, let's begin
+		set_action_state( $action, 'remove_personal_data_processing' );
+
+		do_next_erasure( 1, 1 );
+	} )
+} );
Index: src/wp-includes/script-loader.php
===================================================================
--- src/wp-includes/script-loader.php	(revision 42978)
+++ src/wp-includes/script-loader.php	(working copy)
@@ -709,6 +709,15 @@
 		);
 
 		$scripts->add( 'xfn', "/wp-admin/js/xfn$suffix.js", array( 'jquery' ), false, 1 );
+		did_action( 'init' ) && $scripts->localize(
+			'xfn', 'privacyToolsL10n', array(
+				'noDataFound'     => __( 'No personal data was found for this user.' ),
+				'foundAndRemoved' => __( 'All of the personal data found for this user was removed.' ),
+				'noneRemoved'     => __( 'Personal data was found for this user but was not removed.' ),
+				'someNotRemoved'  => __( 'Personal data was found for this user but some of the personal data found was not removed.' ),
+				'anErrorOccurred' => __( 'An error occurred while attempting to find and remove personal data.' ),
+			)
+		);
 
 		$scripts->add( 'postbox', "/wp-admin/js/postbox$suffix.js", array( 'jquery-ui-sortable' ), false, 1 );
 		did_action( 'init' ) && $scripts->localize(
