Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Delete WebAuthn key #195

Merged
merged 3 commits into from
May 31, 2023
Merged
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
6 changes: 4 additions & 2 deletions settings/rest-api.php
Original file line number Diff line number Diff line change
@@ -213,9 +213,11 @@ function register_user_fields(): void {
'get_callback' => function( $user ) {
$keys = WebAuthn_Credential_Store::get_user_keys( get_userdata( $user['id'] ) );

// Remove sensitive and unnecessary data.
array_walk( $keys, function( & $key ) {
$key = array_intersect_key( (array) $key, array_flip( [ 'id', 'name' ] ) );
$key->delete_nonce = wp_create_nonce( 'delete-key_' . $key->credential_id );

// Remove unnecessary data.
$key = array_intersect_key( (array) $key, array_flip( [ 'id', 'credential_id', 'name', 'delete_nonce' ] ) );
} );

return $keys;
2 changes: 1 addition & 1 deletion settings/src/block.json
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@
"textdomain": "wporg",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"viewScript": [ "file:./script.js", "zxcvbn-async", "two-factor-qr-code-generator" ],
"viewScript": [ "wp-util", "zxcvbn-async", "two-factor-qr-code-generator", "file:./script.js" ],
"style": [ "file:./style-index.css", "wp-components" ],
"render": "file:./render.php"
}
148 changes: 122 additions & 26 deletions settings/src/components/webauthn/list-keys.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,134 @@
/**
* WordPress dependencies
*/
import { Button } from '@wordpress/components';
import { Button, Modal, Notice, Spinner } from '@wordpress/components';
import { useCallback, useContext, useState } from '@wordpress/element';
import { Icon, cancelCircleFilled } from '@wordpress/icons';

const confirm = window.confirm;
/**
* Internal dependencies
*/
import { GlobalContext } from '../../script';
import { refreshRecord } from '../../utilities';

/**
* Global dependencies
*/
const ajax = wp.ajax;

/**
* Render the list of keys.
*/
export default function ListKeys() {
const {
user: { userRecord },
setGlobalNotice,
} = useContext( GlobalContext );
const keys = userRecord.record[ '2fa_webauthn_keys' ];

const [ modalKey, setModalKey ] = useState( null );
const [ modalError, setModalError ] = useState( null );
const [ deleting, setDeleting ] = useState( false );

/**
* After the user confirms their intent, POST an AJAX request to remove a key.
*/
const onConfirmDelete = useCallback( async () => {
setDeleting( true );

try {
await ajax.post( 'webauthn_delete_key', {
user_id: userRecord.record.id,
handle: modalKey.credential_id,
_ajax_nonce: modalKey.delete_nonce,
} );

setGlobalNotice( modalKey.name + ' has been deleted.' );
setModalKey( null );
refreshRecord( userRecord );
} catch ( error ) {
// The endpoint returns some errors as a string, but others as an object.
setModalError( error?.responseJSON?.data || error );
} finally {
setDeleting( false );
}
}, [ modalKey ] );

return (
<>
<ul>
{ keys.map( ( key ) => (
<li key={ key.id }>
{ key.name }

<Button
variant="link"
data-id={ key.id }
aria-label="Delete"
onClick={ () => setModalKey( key ) }
>
Delete
</Button>
</li>
) ) }
</ul>

{ modalKey && (
<ConfirmRemoveKey
keyToRemove={ modalKey }
error={ modalError }
deleting={ deleting }
onClose={ () => setModalKey( null ) }
onConfirm={ onConfirmDelete }
/>
) }
</>
);
}

/**
* Prompt the user to confirm they want to delete a key.
*
* @param props
* @param props.keys
* @param {Object} props
* @param {Object} props.keyToRemove
* @param {Function} props.onConfirm
* @param {Function} props.onClose
* @param {boolean} props.deleting
* @param {string} props.error
*/
export default function ListKeys( { keys } ) {
function ConfirmRemoveKey( { keyToRemove, onConfirm, onClose, deleting, error } ) {
return (
<ul>
{ keys.map( ( key ) => (
<li key={ key.id }>
{ key.name }

{ /* todo add onclick handler that pops up a <Modal> to confirm. maybe pass in from parent? */ }
<Button
variant="link"
data-id={ key.id }
aria-label="Delete"
onClick={ () =>
confirm(
'Modal H4 Remove Key? p Are you sure you want to remove the "" security key? Button Cancel Button Remove Key'
)
}
>
Delete
</Button>
</li>
) ) }
</ul>
<Modal
title={ `Remove ${ keyToRemove.name }?` }
className="wporg-2fa__confirm-delete-key"
onRequestClose={ onClose }
>
<p>
Are you sure you want to remove the <code>{ keyToRemove.name }</code> security key?
</p>

<div className="wporg-2fa__submit-actions">
<Button variant="primary" onClick={ onConfirm }>
Remove { keyToRemove.name }
</Button>

<Button variant="secondary" onClick={ onClose }>
Cancel
</Button>
</div>

{ deleting && (
<p>
<Spinner />
</p>
) }

{ error && (
<Notice status="error" isDismissible={ false }>
<Icon icon={ cancelCircleFilled } />
{ error }
</Notice>
) }
</Modal>
);
}
25 changes: 16 additions & 9 deletions settings/src/components/webauthn/webauthn.js
Original file line number Diff line number Diff line change
@@ -11,6 +11,9 @@ import { Icon, check } from '@wordpress/icons';
import { GlobalContext } from '../../script';
import ListKeys from './list-keys';

/**
* Global dependencies
*/
const confirm = window.confirm;

/**
@@ -20,7 +23,6 @@ export default function WebAuthn() {
const {
user: { userRecord },
} = useContext( GlobalContext );
const userKeys = userRecord.record[ '2fa_webauthn_keys' ];
const backupCodesEnabled =
userRecord.record[ '2fa_available_providers' ].includes( 'Two_Factor_Backup_Codes' );
const [ step, setStep ] = useState( 'manage' );
@@ -29,23 +31,27 @@ export default function WebAuthn() {
// and then replace userkeys with the value returned by the call
// probably also refreshRecord( userRecord );
const onRegisterSuccess = useCallback( () => {
userKeys.push( {
id: 1111,
const newKeys = userRecord.record[ '2fa_webauthn_keys' ].push( {
id: Math.random(),
name: 'New Key',
} );

userRecord.edit( { '2fa_webauthn_keys': newKeys } );
setStep( 'success' );

if ( ! backupCodesEnabled ) {
// todo redirect to backup codes
}
}, [ userKeys ] );
}, [ userRecord.record[ '2fa_webauthn_keys' ] ] );

return (
<>
{ 'manage' === step && <Manage setStep={ setStep } userKeys={ userKeys } /> }
{ 'manage' === step && <Manage setStep={ setStep } /> }

{ 'register' === step && (
<RegisterKey registerClickHandler={ () => setStep( 'waiting' ) } />
// convert to named func that makes api call to register key
// handle failure
) }

{ 'waiting' === step && (
@@ -62,9 +68,8 @@ export default function WebAuthn() {
*
* @param props
* @param props.setStep
* @param props.userKeys
*/
function Manage( { setStep, userKeys } ) {
function Manage( { setStep } ) {
return (
<>
<p>
@@ -75,7 +80,8 @@ function Manage( { setStep, userKeys } ) {
</p>

<h4>Security Keys</h4>
<ListKeys keys={ userKeys } />

<ListKeys />

<p className="wporg-2fa__submit-actions">
<Button variant="primary" onClick={ () => setStep( 'register' ) }>
@@ -106,7 +112,8 @@ function Manage( { setStep, userKeys } ) {
function RegisterKey( { registerClickHandler } ) {
return (
<form>
<TextControl label="Give the security key a name"></TextControl>
<TextControl label="Give the security key a name" />
{ /* todo add basic clientside validation */ }

<div className="wporg-2fa__submit-actions">
<Button variant="primary" onClick={ registerClickHandler }>
7 changes: 7 additions & 0 deletions settings/src/components/webauthn/webauthn.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
.wporg-2fa__webauthn,
.bbp-single-user .wporg-2fa__webauthn {
// todo all styles here, except modal (which isn't in this DOM tree)
}

.components-modal__frame.wporg-2fa__confirm-delete-key {
.components-notice.is-error {
margin-top: 18px;
}
}