diff --git a/.eslintrc.json b/.eslintrc.json index 96060c6b..c498c3d2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,7 @@ { "root": true, "extends": [ - "wikimedia/client-es5", + "wikimedia/client", "wikimedia/jquery", "wikimedia/mediawiki" ] diff --git a/Gruntfile.js b/Gruntfile.js index ea9a15ab..b431894c 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,6 +1,6 @@ /* eslint-env node */ module.exports = function ( grunt ) { - var conf = grunt.file.readJSON( 'extension.json' ); + const conf = grunt.file.readJSON( 'extension.json' ); grunt.loadNpmTasks( 'grunt-banana-checker' ); grunt.loadNpmTasks( 'grunt-eslint' ); diff --git a/extension.json b/extension.json index 590c6190..1c6435f3 100644 --- a/extension.json +++ b/extension.json @@ -101,16 +101,25 @@ } }, "ResourceModules": { - "ext.oath.totp.showqrcode.styles": { + "ext.oath.styles": { "class": "MediaWiki\\ResourceLoader\\CodexModule", "styles": [ - "totp/ext.oath.showqrcode.styles.less" + "totp/ext.oath.showqrcode.styles.less", + "recovery/ext.oauth.recovery.less" ], "codexStyleOnly": "true", "codexComponents": [ "CdxButton", "CdxIcon" ] + }, + "ext.oath": { + "packageFiles": [ + "recovery/ext.oath.recovery.copy.js" + ], + "messages": [ + "oathauth-recoverycodes-copy-success" + ] } }, "ResourceFileModulePaths": { diff --git a/i18n/en.json b/i18n/en.json index f81e232c..2d3610d0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -16,7 +16,9 @@ "oathauth-recoverycodes-important": "This step is important! Do not skip this step!", "oathauth-recoverycodes-neveragain": "These codes will never be shown again!", "oathauth-recoverytokens-createdat": "Recovery tokens created: $1", - "oathauth-recoverycodes-download": "Download recovery codes", + "oathauth-recoverycodes-download": "Download", + "oathauth-recoverycodes-copy": "Copy", + "oathauth-recoverycodes-copy-success": "Recovery codes were copied to your clipboard!", "oathauth-disable": "Disable two-factor authentication", "oathauth-validatedoath": "Validated two-factor credentials. Two-factor authentication will now be enforced.", "oathauth-noscratchforvalidation": "You cannot use a recovery code to confirm two-factor authentication. Recovery codes are for backup and incidental use only. Please use a code from your two-factor authentication application (such as Google Authenticator).", diff --git a/i18n/qqq.json b/i18n/qqq.json index b54779bf..8497dfa9 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -32,6 +32,8 @@ "oathauth-recoverycodes-neveragain": "Plain text, found on Special:OATH while enabling OATH.", "oathauth-recoverytokens-createdat": "Plain text, found on Special:OATH while enabling OATH.", "oathauth-recoverycodes-download": "Plain text, text of the download link on Special:OATH while enabling OATH.", + "oathauth-recoverycodes-copy": "Plain text, label of the button on Special:OATH to copy the recovery codes", + "oathauth-recoverycodes-copy-success": "Notification bubble presented on Special:OATH after copying recovery codes", "oathauth-disable": "Page title on Special:OATH while disabling OATH.\n\nSee [https://en.wikipedia.org/wiki/Two_factor_authentication two factor authentication]", "oathauth-validatedoath": "Plain text found on Special:OATH after a token has been validated.\n\nSee [https://en.wikipedia.org/wiki/Two_factor_authentication two factor authentication]", "oathauth-noscratchforvalidation": "Plain text found on Special:OATH if the user used the incorrect type of token while enabling OATH.\n\nSee [https://en.wikipedia.org/wiki/Two_factor_authentication two factor authentication]", diff --git a/modules/recovery/ext.oath.recovery.copy.js b/modules/recovery/ext.oath.recovery.copy.js new file mode 100644 index 00000000..08f1da3a --- /dev/null +++ b/modules/recovery/ext.oath.recovery.copy.js @@ -0,0 +1,40 @@ +/** + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +class CopyButton { + + static attach() { + // eslint-disable-next-line no-jquery/no-global-selector + $( '.mw-oathauth-recoverycodes-copy-button' ) + .addClass( 'clipboard-api-supported' ) + .on( 'click', ( e ) => { + e.preventDefault(); + // eslint-disable-next-line compat/compat + navigator.clipboard.writeText( mw.config.get( 'oathauth-recoverycodes' ) ).then( () => { + mw.notify( mw.msg( 'oathauth-recoverycodes-copy-success' ), { + type: 'success', + tag: 'recoverycodes' + } ); + } ); + } ); + } +} + +if ( navigator.clipboard && navigator.clipboard.writeText ) { + // navigator.clipboard() is not supported in Safari 11.1, iOS Safari 11.3-11.4 + $( CopyButton.attach ); +} diff --git a/modules/recovery/ext.oauth.recovery.less b/modules/recovery/ext.oauth.recovery.less new file mode 100644 index 00000000..4060b6c9 --- /dev/null +++ b/modules/recovery/ext.oauth.recovery.less @@ -0,0 +1,21 @@ +@import 'mediawiki.skin.variables.less'; + +.mw-oathauth-recoverycodes-download-icon { + .cdx-mixin-css-icon( @cdx-icon-download, @param-is-button-icon: true ); +} + +.mw-oathauth-recoverycodes-copy-button { + &.cdx-button { + margin-right: @spacing-horizontal-button; + margin-inline-end: @spacing-horizontal-button; + } + + .cdx-button__icon { + .cdx-mixin-css-icon( @cdx-icon-copy, @param-is-button-icon: true ); + } + + .client-nojs &, + &:not( .clipboard-api-supported ) { + display: none; + } +} diff --git a/modules/totp/ext.oath.showqrcode.styles.less b/modules/totp/ext.oath.showqrcode.styles.less index ef5ba958..62afabef 100644 --- a/modules/totp/ext.oath.showqrcode.styles.less +++ b/modules/totp/ext.oath.showqrcode.styles.less @@ -1,5 +1,3 @@ -@import 'mediawiki.skin.variables.less'; - kbd { font-family: monospace, monospace; white-space: nowrap; @@ -9,11 +7,3 @@ kbd { fieldset { page-break-inside: avoid; } - -.cdx-button.mw-oathauth-recoverycodes-download { - margin-top: 1em; -} - -.mw-oathauth-recoverycodes-download-icon { - .cdx-mixin-css-icon( @cdx-icon-download, @param-is-button-icon: true ); -} diff --git a/src/HTMLForm/TOTPEnableForm.php b/src/HTMLForm/TOTPEnableForm.php index ea6b65ac..b04a90ac 100644 --- a/src/HTMLForm/TOTPEnableForm.php +++ b/src/HTMLForm/TOTPEnableForm.php @@ -20,7 +20,9 @@ class TOTPEnableForm extends OATHAuthOOUIHTMLForm { * @return string */ public function getHTML( $submitResult ) { - $this->getOutput()->addModuleStyles( 'ext.oath.totp.showqrcode.styles' ); + $out = $this->getOutput(); + $out->addModuleStyles( 'ext.oath.styles' ); + $out->addModules( 'ext.oath' ); return parent::getHTML( $submitResult ); } @@ -69,6 +71,8 @@ class TOTPEnableForm extends OATHAuthOOUIHTMLForm { ->build(); $now = wfTimestampNow(); + $recoveryCodes = $this->getScratchTokensForDisplay( $key ); + $this->getOutput()->addJsConfigVars( 'oathauth-recoverycodes', $this->createTextList( $recoveryCodes ) ); // messages used: oathauth-step1, oathauth-step2, oathauth-step3, oathauth-step4 return [ @@ -115,9 +119,10 @@ class TOTPEnableForm extends OATHAuthOOUIHTMLForm { . $this->msg( 'word-separator' )->escaped() . $this->msg( 'parentheses' )->rawParams( wfTimestamp( TS_ISO_8601, $now ) )->escaped() ) . '
' . - $this->createResourceList( $this->getScratchTokensForDisplay( $key ) ) . '
' . + $this->createResourceList( $recoveryCodes ) . '
' . '' . $this->msg( 'oathauth-recoverycodes-neveragain' )->escaped() . '
' . - $this->createDownloadLink( $this->getScratchTokensForDisplay( $key ) ), + $this->createCopyButton() . + $this->createDownloadLink( $recoveryCodes ), 'raw' => true, 'section' => 'step3', ], @@ -146,6 +151,15 @@ class TOTPEnableForm extends OATHAuthOOUIHTMLForm { return Html::rawElement( 'ul', [], $resourceList ); } + /** + * @param array $items + * + * @return string + */ + private function createTextList( $items ) { + return "* " . implode( "\n* ", $items ); + } + private function createDownloadLink( array $scratchTokensForDisplay ): string { $icon = Html::element( 'span', [ 'class' => [ 'mw-oathauth-recoverycodes-download-icon', 'cdx-button__icon' ], @@ -167,6 +181,16 @@ class TOTPEnableForm extends OATHAuthOOUIHTMLForm { ); } + private function createCopyButton(): string { + return Html::rawElement( 'button', [ + 'class' => 'cdx-button mw-oathauth-recoverycodes-copy-button' + ], Html::element( 'span', [ + 'class' => 'cdx-button__icon', + 'aria-hidden' => 'true', + ] ) . $this->msg( 'oathauth-recoverycodes-copy' )->escaped() + ); + } + /** * Retrieve the current secret for display purposes *