├── .nvmrc ├── .gitignore ├── settings ├── babel.config.json ├── jest.config.js ├── src │ ├── render.php │ ├── index.js │ ├── components │ │ ├── print-button.js │ │ ├── email-address.scss │ │ ├── screen-link.scss │ │ ├── screen-navigation.scss │ │ ├── webauthn │ │ │ ├── webauthn.scss │ │ │ ├── list-keys.js │ │ │ ├── register-key.js │ │ │ └── webauthn.js │ │ ├── global-notice.js │ │ ├── password.scss │ │ ├── svn-password.scss │ │ ├── success.scss │ │ ├── copy-to-clipboard-button.js │ │ ├── global-notice.scss │ │ ├── revalidate-modal.scss │ │ ├── download-button.js │ │ ├── auto-tabbing-input.scss │ │ ├── backup-codes.scss │ │ ├── success.js │ │ ├── screen-navigation.js │ │ ├── first-time │ │ │ ├── setup-progress-bar.js │ │ │ ├── first-time.scss │ │ │ ├── home.js │ │ │ ├── setup-progress-bar.scss │ │ │ ├── congratulations.js │ │ │ └── first-time.js │ │ ├── screen-link.js │ │ ├── auto-tabbing-input.js │ │ ├── numeric-control.js │ │ ├── account-status.scss │ │ ├── totp.scss │ │ ├── settings.js │ │ ├── revalidate-modal.js │ │ ├── svn-password.js │ │ ├── email-address.js │ │ ├── account-status.js │ │ ├── password.js │ │ ├── backup-codes.js │ │ └── totp.js │ ├── edit.js │ ├── tests │ │ ├── password │ │ │ ├── mocks.js │ │ │ └── password.test.js │ │ ├── utilities │ │ │ └── common.test.js │ │ └── hooks │ │ │ └── useUser.test.js │ ├── block.json │ ├── utilities │ │ ├── common.js │ │ └── webauthn.js │ ├── hooks │ │ └── useUser.js │ ├── style.scss │ └── script.js ├── jest.setup.js ├── .eslintrc.js ├── package.json └── settings.php ├── phpunit-watcher.yml.dist ├── .wp-env.json ├── .github └── workflows │ ├── lint.yml │ ├── test.yml │ └── build.yml ├── revalidation ├── style.css ├── README.md ├── index.php └── script.js ├── package.json ├── phpunit.xml.dist ├── composer.json ├── .wp-env └── mu-plugin.php ├── tests ├── bootstrap.php ├── settings │ └── test-rest-api.php └── test-wporg-two-factor.php ├── class-encrypted-totp-provider.php ├── README.md ├── stats.php ├── class-wporg-webauthn-provider.php ├── phpcs.xml └── wporg-two-factor.php /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | settings/build 2 | tests/.phpunit.result.cache 3 | tests/coverage 4 | vendor 5 | node_modules 6 | -------------------------------------------------------------------------------- /settings/babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", ["@babel/preset-react", {"runtime": "automatic"}] ] 3 | } 4 | -------------------------------------------------------------------------------- /phpunit-watcher.yml.dist: -------------------------------------------------------------------------------- 1 | watch: 2 | directories: 3 | - ./ 4 | exclude: 5 | - vendor 6 | fileMask: '*.php' 7 | ignoreDotFiles: true 8 | ignoreVCS: true 9 | ignoreVCSIgnored: true 10 | 11 | notifications: 12 | passingTests: false 13 | failingTests: false 14 | -------------------------------------------------------------------------------- /settings/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | const config = { 3 | testEnvironment: 'jsdom', 4 | moduleNameMapper: { 5 | '^uuid$': require.resolve( 'uuid' ), 6 | }, 7 | setupFilesAfterEnv: [ './jest.setup.js' ], 8 | }; 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /settings/src/render.php: -------------------------------------------------------------------------------- 1 |
3 | data-user-id="attributes['userId'] ); ?>" 4 | data-is-onboarding="attributes['isOnboarding'] ? 'true' : 'false' ); ?>" 5 | > 6 |
7 | Loading ... 8 |
9 |
10 | -------------------------------------------------------------------------------- /settings/src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { registerBlockType } from '@wordpress/blocks'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import Edit from './edit'; 10 | import metadata from './block.json'; 11 | import './style.scss'; 12 | 13 | registerBlockType( metadata.name, { 14 | edit: Edit, 15 | } ); 16 | -------------------------------------------------------------------------------- /settings/jest.setup.js: -------------------------------------------------------------------------------- 1 | /* global jest */ 2 | Object.defineProperty( window, 'matchMedia', { 3 | writable: true, 4 | value: jest.fn().mockImplementation( ( query ) => ( { 5 | matches: false, 6 | media: query, 7 | onchange: null, 8 | addListener: jest.fn(), 9 | removeListener: jest.fn(), 10 | addEventListener: jest.fn(), 11 | removeEventListener: jest.fn(), 12 | dispatchEvent: jest.fn(), 13 | } ) ), 14 | } ); 15 | -------------------------------------------------------------------------------- /settings/src/components/print-button.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useCallback } from '@wordpress/element'; 5 | import { Button } from '@wordpress/components'; 6 | 7 | export default function PrintButton() { 8 | const onClick = useCallback( () => { 9 | window.print(); 10 | }, [] ); 11 | 12 | return ( 13 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /settings/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: 'plugin:@wordpress/eslint-plugin/recommended', 4 | 5 | env: { 6 | browser: true, 7 | }, 8 | 9 | globals: { navigator: 'readonly' }, 10 | 11 | rules: { 12 | 'jsdoc/require-param-type': 0, 13 | 'prettier/prettier': [ 14 | 'error', 15 | { 16 | ...require( '@wordpress/prettier-config' ), 17 | printWidth: 100, 18 | }, 19 | ], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /settings/src/components/email-address.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__email { 2 | .components-notice.actions-on-right { 3 | margin-bottom: 16px; 4 | } 5 | 6 | /* Align the actions in a to the right */ 7 | .components-notice.actions-on-right > div { 8 | display: flex; 9 | margin-right: 0; 10 | 11 | &> p { 12 | flex-grow: 1; 13 | margin: initial; /* wporg-support adds top padding to

causing button alignment issues. */ 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /settings/src/components/screen-link.scss: -------------------------------------------------------------------------------- 1 | /* Overwrite bbPress styles with Gutenberg styles */ 2 | #bbpress-forums .wp-block-wporg-two-factor-settings a.components-button.is-secondary { 3 | box-shadow: inset 0 0 0 1px var(--wp-components-color-accent,var(--wp-admin-theme-color,#007cba)); 4 | text-decoration: none; 5 | 6 | &:not(:disabled):focus { 7 | box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus) var(--wp-components-color-accent,var(--wp-admin-theme-color,#007cba)); 8 | outline: 3px solid transparent; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /settings/src/edit.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { __ } from '@wordpress/i18n'; 5 | import { useBlockProps } from '@wordpress/block-editor'; 6 | import { Placeholder } from '@wordpress/components'; 7 | 8 | /** 9 | * Render in editor 10 | */ 11 | export default function Edit() { 12 | return ( 13 |

14 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /settings/src/components/screen-navigation.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__navigation { 2 | padding: 24px 0 16px !important; /* Override Gutenberg auto-generated. */ 3 | border-bottom: 0 !important; 4 | justify-content: center !important; 5 | position: relative; 6 | 7 | 8 | a { 9 | display: flex; 10 | align-items: center; 11 | position: absolute; 12 | left: 24px; 13 | font-weight: 600; 14 | gap: 2px; 15 | } 16 | 17 | svg { 18 | fill: var(--wp--preset--color--blueberry-1, #3858e9); 19 | } 20 | 21 | h3 { 22 | font-weight: 600; 23 | margin: unset; 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /settings/src/components/webauthn/webauthn.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__webauthn, 2 | .bbp-single-user .wporg-2fa__webauthn { 3 | .wporg-2fa__webauthn-keys-list { 4 | li { 5 | display: flex; 6 | padding: 16px 0; 7 | border-bottom: 1px solid $gray-300; 8 | 9 | &:last-of-type { 10 | border-bottom: none; 11 | } 12 | } 13 | 14 | .wporg-2fa__webauthn-key-name { 15 | flex: 1; 16 | font-size: 13px; 17 | } 18 | } 19 | } 20 | 21 | .components-modal__frame.wporg-2fa__confirm-delete-key { 22 | .components-notice.is-error { 23 | margin-top: 18px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /settings/src/components/global-notice.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { Flex, Snackbar } from '@wordpress/components'; 5 | import { check } from '@wordpress/icons'; 6 | 7 | export default function GlobalNotice( { notice, setNotice } ) { 8 | if ( ! notice ) { 9 | return; 10 | } 11 | 12 | return ( 13 | 14 | setNotice( '' ) } 19 | > 20 | { notice } 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /settings/src/components/password.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__password, 2 | .bbp-single-user .wporg-2fa__password { 3 | 4 | .wporg-2fa__password_container { 5 | justify-content: flex-start; 6 | align-items: flex-start; 7 | position: relative; 8 | width: fit-content; 9 | 10 | input { 11 | /* This includes room for the `(un)seen` icon, and also for the icon that 1password etc add to password fields. */ 12 | padding-right: 35px; 13 | } 14 | } 15 | 16 | .wporg-2fa__show-password { 17 | position: absolute; 18 | top: 24px; 19 | right: 1px; 20 | margin: 0; 21 | padding: 0 5px; 22 | height: 31px; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /settings/src/tests/password/mocks.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Adds the zxcvbn library to the global window object for testing purposes. 3 | * 4 | * @param {number} returnValue The score that is returned by the mocked zxcvbn library. 5 | */ 6 | export const mockPasswordEstimator = ( returnValue = 0 ) => { 7 | // Mock the zxcvbn library 8 | global.window = Object.create( window ); 9 | Object.defineProperty( window, 'zxcvbn', { 10 | value: () => { 11 | return { 12 | score: returnValue, // at less than or equal to 4 13 | }; 14 | }, 15 | writable: true, 16 | enumerable: true, 17 | configurable: true, 18 | } ); 19 | }; 20 | -------------------------------------------------------------------------------- /.wp-env.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "https://downloads.wordpress.org/plugin/gutenberg.latest-stable.zip", 4 | "https://downloads.wordpress.org/plugin/bbpress.latest-stable.zip", 5 | "WordPress/two-factor", 6 | "https://downloads.wordpress.org/plugin/two-factor-provider-webauthn.latest-stable.zip", 7 | "." 8 | ], 9 | "mappings": { 10 | "wp-content/themes/wporg-support": "WordPress/wordpress.org/wordpress.org/public_html/wp-content/themes/pub/wporg-support/", 11 | "wp-content/mu-plugins/pub/": "WordPress/wporg-mu-plugins#build", 12 | "wp-content/mu-plugins/mu-plugin.php": "./.wp-env/mu-plugin.php" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /settings/src/components/svn-password.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__svn-password { 2 | code { 3 | display: inline-block; 4 | padding: 0 3px; 5 | background: var(--wp--preset--color--light-grey-2, #f6f6f6); 6 | border-radius: 2px; 7 | } 8 | 9 | ul { 10 | padding-top: 16px; 11 | 12 | li:not(:last-child) { 13 | margin-bottom: 8px; 14 | 15 | .wporg-2fa__svn-password_generated { 16 | color: var(--wp--preset--color--charcoal-4, #656a71); 17 | } 18 | } 19 | } 20 | 21 | .wporg-2fa__svn-password_generated { 22 | padding-top: 4px; 23 | font-size: 12px; 24 | color: var(--wp--preset--color--charcoal-4, #656a71); 25 | } 26 | } -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis (Linting) 2 | 3 | # This workflow is triggered on pushes to trunk, and any PRs. 4 | on: 5 | push: 6 | branches: [trunk] 7 | pull_request: 8 | 9 | jobs: 10 | check: 11 | name: All 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Install NodeJS 19 | uses: actions/setup-node@v3 20 | with: 21 | cache: 'yarn' 22 | 23 | - name: Install NPM dependencies 24 | run: | 25 | yarn 26 | 27 | - name: Lint JavaScript 28 | run: | 29 | yarn workspaces run lint:js 30 | 31 | -------------------------------------------------------------------------------- /revalidation/style.css: -------------------------------------------------------------------------------- 1 | dialog.wporg-2fa-revalidate-modal { 2 | border-radius: 8px; 3 | max-width: 500px; 4 | } 5 | dialog.wporg-2fa-revalidate-modal > h1 { 6 | margin: unset; 7 | margin-bottom: 0.5em; 8 | text-align: center; 9 | } 10 | dialog.wporg-2fa-revalidate-modal > p { 11 | font-size: 14px; 12 | padding: 0 32px; 13 | } 14 | dialog.wporg-2fa-revalidate-modal > iframe { 15 | width: 100%; 16 | height: 330px; /* Room for errors. */ 17 | border: none; 18 | } 19 | /* Close button. */ 20 | dialog.wporg-2fa-revalidate-modal > button { 21 | position: absolute; 22 | top: 0; 23 | right: 0; 24 | background: none; 25 | border: none; 26 | padding: 15px; 27 | cursor: pointer; 28 | } -------------------------------------------------------------------------------- /settings/src/components/success.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__success { 2 | flex: 1; 3 | } 4 | 5 | @keyframes success { 6 | 0% { 7 | transform: scale(0); 8 | } 9 | 5% { 10 | transform: scale(0); 11 | } 12 | 10% { 13 | transform: scale(1.25); 14 | } 15 | 15% { 16 | transform: scale(1); 17 | } 18 | 80% { 19 | transform: scale(1); 20 | } 21 | 100% { 22 | transform: scale(1); 23 | } 24 | } 25 | 26 | .wporg-2fa__success-animation { 27 | transform: scale(0); 28 | background: #33F078; 29 | color: #fff; 30 | border-radius: 50%; 31 | display: flex; 32 | align-items: center; 33 | justify-content: center; 34 | width: 32px; 35 | height: 32px; 36 | animation: success 5s ease-in-out; 37 | margin-right: 8px; 38 | } 39 | -------------------------------------------------------------------------------- /settings/src/components/copy-to-clipboard-button.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useCallback, useState } from '@wordpress/element'; 5 | import { Button } from '@wordpress/components'; 6 | 7 | export default function CopyToClipboardButton( { contents, variant = 'secondary' } ) { 8 | const [ copied, setCopied ] = useState( false ); 9 | 10 | const onClick = useCallback( () => { 11 | navigator.clipboard.writeText( contents ).then( () => { 12 | setCopied( true ); 13 | setTimeout( () => setCopied( false ), 2000 ); 14 | } ); 15 | }, [ contents ] ); 16 | 17 | return ( 18 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /settings/src/components/global-notice.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__global-notice { 2 | position: absolute; 3 | top: -60px; 4 | right: 0; 5 | z-index: 2; 6 | 7 | &.components-snackbar, 8 | .components-snackbar__content { 9 | cursor: auto; 10 | } 11 | 12 | .components-snackbar__content { 13 | position: relative; 14 | } 15 | 16 | .components-snackbar .components-snackbar__icon, 17 | .components-snackbar__icon { 18 | position: absolute; 19 | width: 30px; 20 | top: -6px !important; 21 | left: -12px !important; 22 | /* todo for some reason this is getting overridden without !important by the core styles, even though the selector is more specific, 23 | and it's inline while the core styles are in a file */ 24 | 25 | fill: $alert-green; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /settings/src/components/revalidate-modal.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__revalidate-modal { 2 | $header-height: 100px; 3 | 4 | &.components-modal__frame { 5 | border-radius: 8px; 6 | } 7 | 8 | .components-modal__header { 9 | height: $header-height; 10 | 11 | h1 { 12 | margin: unset; 13 | } 14 | } 15 | 16 | .components-modal__content { 17 | margin-top: $header-height; 18 | padding: 0; 19 | } 20 | 21 | p { 22 | margin: 0 32px 1rem; 23 | // Match style of login page text in iframe 24 | font-size: 14px; 25 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 26 | } 27 | 28 | iframe { 29 | border: none; 30 | width: 100%; 31 | // Allow for error messages above form 32 | height: 330px; 33 | } 34 | } -------------------------------------------------------------------------------- /settings/src/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 2, 4 | "name": "wporg-two-factor/settings", 5 | "version": "0.1.4", 6 | "title": "wporg Two-Factor Settings", 7 | "category": "widgets", 8 | "icon": "lock", 9 | "supports": { 10 | "html": false, 11 | "inserter": false 12 | }, 13 | "attributes": { 14 | "userId": { 15 | "type": "integer" 16 | }, 17 | "isOnboarding": { 18 | "type": "boolean", 19 | "default": false 20 | } 21 | }, 22 | "textdomain": "wporg", 23 | "editorScript": "file:./index.js", 24 | "editorStyle": "file:./index.css", 25 | "viewScript": [ "wp-util", "zxcvbn-async", "two-factor-qr-code-generator", "file:./script.js" ], 26 | "style": [ "file:./style-index.css", "wp-components" ], 27 | "render": "file:./render.php" 28 | } 29 | -------------------------------------------------------------------------------- /settings/src/tests/utilities/common.test.js: -------------------------------------------------------------------------------- 1 | /* global jest, it, describe, beforeEach, expect, afterEach */ 2 | 3 | /** 4 | * Local dependencies 5 | */ 6 | import { refreshRecord } from '../../utilities/common'; 7 | 8 | describe( 'refreshRecord', () => { 9 | let mockRecord; 10 | 11 | beforeEach( () => { 12 | mockRecord = { 13 | edit: jest.fn(), 14 | save: jest.fn(), 15 | }; 16 | } ); 17 | 18 | afterEach( () => { 19 | mockRecord.edit.mockReset(); 20 | mockRecord.save.mockReset(); 21 | } ); 22 | 23 | it( 'should call edit and save methods on the record object', async () => { 24 | await refreshRecord( mockRecord ); 25 | 26 | expect( mockRecord.edit ).toHaveBeenCalledWith( { 27 | refreshRecordFakeKey: '', 28 | } ); 29 | expect( mockRecord.save ).toHaveBeenCalled(); 30 | } ); 31 | } ); 32 | -------------------------------------------------------------------------------- /settings/src/components/download-button.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useCallback } from '@wordpress/element'; 5 | import { Button } from '@wordpress/components'; 6 | 7 | export default function DownloadTxtButton( { codes, fileName = 'backup-codes.txt' } ) { 8 | const downloadTxtFile = useCallback( () => { 9 | const element = document.createElement( 'a' ); 10 | const file = new Blob( [ codes ], { type: 'text/plain' } ); 11 | element.href = URL.createObjectURL( file ); 12 | element.download = fileName; 13 | document.body.appendChild( element ); // Required for Firefox 14 | element.click(); 15 | document.body.removeChild( element ); 16 | }, [ codes, fileName ] ); 17 | 18 | return ( 19 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /settings/src/components/auto-tabbing-input.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__auto-tabbing-input { 2 | display: flex; 3 | margin-top: 8px; 4 | 5 | input { 6 | width: 40px !important; /* Override bbPress styles */ 7 | height: 40px; 8 | text-align: center; 9 | border: 1px solid #949494; 10 | border-radius: 2px; 11 | padding: 0px !important; 12 | 13 | &:focus { 14 | outline-color: $blue-50; 15 | } 16 | 17 | &:not(:last-child) { 18 | margin-right: 16px; 19 | } 20 | 21 | @media screen and (max-width: 500px) { 22 | &:not(:last-child) { 23 | margin-right: 8px; 24 | } 25 | } 26 | 27 | @media screen and (max-width: 400px) { 28 | &:not(:last-child) { 29 | margin-right: 4px; 30 | } 31 | } 32 | } 33 | 34 | &.is-error input { 35 | border: 1px solid $alert-red !important; 36 | box-shadow: unset !important; 37 | &:focus { 38 | outline: unset; 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /settings/src/components/backup-codes.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__backup-codes { 2 | > *:not(:last-child) { 3 | margin-bottom: 1em; 4 | } 5 | 6 | .wporg-2fa__backup-codes-list { 7 | background-color: var(--wp--preset--color--light-grey-2, #f6f6f6); 8 | margin-top: 24px; 9 | padding: 15px 20px; 10 | 11 | ol { 12 | column-count: 2; 13 | column-width: 110px; 14 | margin: 0; 15 | } 16 | 17 | @media (max-width: 600px) { 18 | ol { 19 | column-count: 1; 20 | } 21 | } 22 | 23 | li::marker { 24 | /* todo: a11y issues w/ contrast here? mockup calls for this to be lighter than the backup code 25 | -- presumably so that users don't mistakenly think it's part of the code -- 26 | but darker than the background 27 | */ 28 | color: $gray-700; 29 | } 30 | } 31 | } 32 | 33 | #bbpress-forums .wporg-2fa__backup-codes { 34 | .wporg-2fa__backup-codes-list { 35 | li { 36 | list-style-type: decimal; 37 | list-style-position: inside; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | # This workflow is triggered on pushes to trunk, and any PRs. 4 | on: 5 | push: 6 | branches: [trunk] 7 | pull_request: 8 | 9 | jobs: 10 | 11 | lint: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | 22 | - name: Setup PHP and Composer 23 | uses: shivammathur/setup-php@v2 24 | with: 25 | php-version: '7.4' 26 | tools: composer:v2 27 | 28 | - name: Install NPM dependencies 29 | run: yarn 30 | 31 | - name: Start the Docker testing environment 32 | run: yarn wp-env start --xdebug=coverage 33 | 34 | - name: Setup environment tools 35 | run: yarn setup:tools 36 | 37 | - name: Test PHP 38 | run: yarn test 39 | 40 | - name: Test JS 41 | run: yarn test:js 42 | -------------------------------------------------------------------------------- /settings/src/utilities/common.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Refresh a `useEntityRecord` object from the REST API. 3 | * 4 | * This is necessary after an the underlying data in the database has been changed by a method other than 5 | * `userRecord.save()`. When that happens, the `userRecord` object isn't automatically updated, and needs to be manually 6 | * refreshed to get the latest data. 7 | * 8 | * todo Replace this with native method if one is added in https://github.com/WordPress/gutenberg/issues/47746. 9 | * 10 | * @param userRecord An userRecord object that was generated by `useEntityRecord()`. 11 | * 12 | * @return {Promise} A promise that resolves when the record has been refreshed. 13 | */ 14 | export function refreshRecord( userRecord ) { 15 | // The fake key will be ignored by the REST API because it isn't a registered field. But the request will still 16 | // result in the latest data being returned. 17 | userRecord.edit( { refreshRecordFakeKey: '' } ); 18 | 19 | return userRecord.save(); 20 | } 21 | -------------------------------------------------------------------------------- /settings/src/components/success.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { Flex } from '@wordpress/components'; 5 | import { useState } from '@wordpress/element'; 6 | import { Icon, check } from '@wordpress/icons'; 7 | 8 | /** 9 | * Render the "Success" component. 10 | * 11 | * Shows a message and animation, then calls the `afterTimeout` callback 12 | * 13 | * @param props 14 | * @param props.afterTimeout 15 | * @param props.message 16 | */ 17 | export default function Success( { message, afterTimeout } ) { 18 | const [ hasTimer, setHasTimer ] = useState( false ); 19 | 20 | if ( ! hasTimer ) { 21 | // Time matches the length of the CSS animation property on .wporg-2fa__success 22 | setTimeout( afterTimeout, 3000 ); 23 | setHasTimer( true ); 24 | } 25 | 26 | return ( 27 | 28 |
29 |
30 | 31 |
32 |
{ message }
33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /settings/src/components/screen-navigation.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { Icon, chevronLeft } from '@wordpress/icons'; 5 | import { Card, CardHeader, CardBody } from '@wordpress/components'; 6 | 7 | /** 8 | * Internal dependencies 9 | */ 10 | import ScreenLink from './screen-link'; 11 | 12 | /** 13 | * @param props 14 | * @param props.children 15 | * @param props.screen 16 | * @param props.title 17 | * @param props.canNavigate 18 | */ 19 | const ScreenNavigation = ( { screen, children, title = '', canNavigate = true } ) => ( 20 | 21 | 22 | { canNavigate && ( 23 | 28 | 29 | Back 30 | 31 | } 32 | /> 33 | ) } 34 | 35 |

{ title }

36 |
37 | { children } 38 |
39 | ); 40 | 41 | export default ScreenNavigation; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wporg-two-factor", 3 | "version": "1.0.0", 4 | "description": "WordPress.org Two Factor", 5 | "directories": { 6 | "test": "tests" 7 | }, 8 | "private": true, 9 | "scripts": { 10 | "wp-env": "wp-env", 11 | "setup:tools": "yarn wp-env run cli --env-cwd=wp-content/plugins/wporg-two-factor composer install", 12 | "test": "yarn wp-env run cli --env-cwd=wp-content/plugins/wporg-two-factor vendor/bin/phpunit", 13 | "test:js": "yarn workspace wporg-two-factor-settings test:unit", 14 | "lint:js": "yarn workspace wporg-two-factor-settings lint:js" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/WordPress/wporg-two-factor.git" 19 | }, 20 | "author": "", 21 | "license": "GPL-2.0", 22 | "bugs": { 23 | "url": "https://github.com/WordPress/wporg-two-factor/issues" 24 | }, 25 | "homepage": "https://github.com/WordPress/wporg-two-factor#readme", 26 | "dependencies": { 27 | "@wordpress/base-styles": "^4.17.0", 28 | "@wordpress/env": "^10.1.0" 29 | }, 30 | "workspaces": { 31 | "packages": [ 32 | "settings" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /settings/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wporg-two-factor-settings", 3 | "description": "Custom React-based UI for the Two-Factor plugin on WordPress.org.", 4 | "version": "0.1.0", 5 | "repository": "https://github.com/WordPress/wporg-two-factor", 6 | "license": "GPL-2.0-or-later", 7 | "main": "build/index.js", 8 | "scripts": { 9 | "build": "wp-scripts build --webpack-copy-php", 10 | "format": "wp-scripts format", 11 | "lint:css": "wp-scripts lint-style", 12 | "lint:js": "wp-scripts lint-js", 13 | "packages-update": "wp-scripts packages-update", 14 | "start": "wp-scripts start --webpack-copy-php", 15 | "test:unit": "wp-scripts test-unit-js", 16 | "test:unit:watch": "wp-scripts test-unit-js --watch" 17 | }, 18 | "dependencies": { 19 | "@automattic/generate-password": "^0.1.0" 20 | }, 21 | "devDependencies": { 22 | "@testing-library/react": "^14.0.0", 23 | "@wordpress/api-fetch": "^6.24.0", 24 | "@wordpress/base-styles": "^4.17.0", 25 | "@wordpress/components": "^23.4.0", 26 | "@wordpress/core-data": "^6.4.0", 27 | "@wordpress/data": "^8.4.0", 28 | "@wordpress/element": "^5.4.0", 29 | "@wordpress/icons": "^9.49.0", 30 | "@wordpress/scripts": "^27.6.0", 31 | "lodash": "^4.17.21" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /settings/src/hooks/useUser.js: -------------------------------------------------------------------------------- 1 | import { useSelect } from '@wordpress/data'; 2 | import { store as coreDataStore, useEntityRecord } from '@wordpress/core-data'; 3 | 4 | /** 5 | * Get the user. 6 | * 7 | * @param userId 8 | */ 9 | export function useUser( userId ) { 10 | const userRecord = useEntityRecord( 'root', 'user', userId ); 11 | const isSaving = useSelect( ( select ) => 12 | select( coreDataStore ).isSavingEntityRecord( 'root', 'user', userId ) 13 | ); 14 | 15 | const availableProviders = userRecord.record?.[ '2fa_available_providers' ] ?? []; 16 | const primaryProvider = userRecord.record?.[ '2fa_primary_provider' ] ?? null; 17 | const backupCodesRemaining = userRecord.record?.[ '2fa_backup_codes_remaining' ] ?? 0; 18 | const totpEnabled = availableProviders.includes( 'Two_Factor_Totp' ); 19 | const backupCodesEnabled = availableProviders.includes( 'Two_Factor_Backup_Codes' ); 20 | const webAuthnEnabled = availableProviders.includes( 'TwoFactor_Provider_WebAuthn' ); 21 | const hasPrimaryProvider = totpEnabled || webAuthnEnabled; 22 | 23 | return { 24 | userRecord: { ...userRecord }, 25 | isSaving, 26 | hasPrimaryProvider, 27 | primaryProvider, 28 | totpEnabled, 29 | backupCodesEnabled, 30 | webAuthnEnabled, 31 | backupCodesRemaining, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ./tests/ 19 | 20 | 21 | 22 | 23 | 30 | 31 | 32 | wporg-two-factor.php 33 | settings/settings.php 34 | settings/rest-api.php 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wordpress/wporg-two-factor", 3 | "description": "WordPress.org-specific customizations for the Two Factor plugin", 4 | "license": "GPL-2.0-or-later", 5 | "support": { 6 | "issues": "https://github.com/WordPress/wporg-two-factor/issues" 7 | }, 8 | "config": { 9 | "platform": { 10 | "php": "7.4" 11 | }, 12 | "allow-plugins": { 13 | "dealerdirect/phpcodesniffer-composer-installer": true, 14 | "composer/installers": true 15 | } 16 | }, 17 | "extra": { 18 | "installer-paths": { 19 | "../../mu-plugins/pub": [ "wporg/wporg-mu-plugins" ] 20 | } 21 | }, 22 | "repositories": [ 23 | { 24 | "type": "vcs", 25 | "url": "git@github.com:WordPress/wporg-mu-plugins.git" 26 | } 27 | ], 28 | "require-dev" : { 29 | "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", 30 | "wp-coding-standards/wpcs": "^3.1", 31 | "wporg/wporg-mu-plugins": "dev-build", 32 | "phpunit/phpunit": "^9.6", 33 | "spatie/phpunit-watcher": "^1.23", 34 | "yoast/phpunit-polyfills": "^2", 35 | "composer/installers": "^2.2" 36 | }, 37 | "scripts": { 38 | "lint": "phpcs --extensions=php -s -p", 39 | "format": "phpcbf -p", 40 | "test" : "phpunit --no-coverage", 41 | "test:watch": [ 42 | "Composer\\Config::disableProcessTimeout", 43 | "phpunit-watcher watch --no-coverage" 44 | ], 45 | "test:coverage": "php -d xdebug.mode=coverage ./vendor/bin/phpunit" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /settings/src/components/first-time/setup-progress-bar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useCallback } from '@wordpress/element'; 5 | 6 | export default function SetupProgressBar( { currentStepIndex, steps } ) { 7 | const getCompletionPercentage = useCallback( 8 | () => ( currentStepIndex / ( steps.length - 1 ) ) * 100, 9 | [ currentStepIndex, steps ] 10 | ); 11 | 12 | const getStepClass = ( index ) => { 13 | if ( index === currentStepIndex ) { 14 | return 'is-enabled'; 15 | } 16 | 17 | if ( currentStepIndex > index ) { 18 | return 'is-complete'; 19 | } 20 | 21 | return 'is-disabled'; 22 | }; 23 | 24 | const flexWidth = 100 / steps.length; 25 | 26 | return ( 27 |
28 | 40 | 41 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /.wp-env/mu-plugin.php: -------------------------------------------------------------------------------- 1 | user_login, $GLOBALS['supes'], true ); 14 | } 15 | } 16 | 17 | require_once __DIR__ . '/pub/mu-plugins/loader.php'; 18 | 19 | // Enable dummy provider for convenience when running locally. 20 | add_filter( 'two_factor_providers', function( $providers ) { 21 | if ( ! defined( 'WP_TESTS_DOMAIN' ) ) { 22 | $providers['Two_Factor_Dummy'] = TWO_FACTOR_DIR . 'providers/class-two-factor-dummy.php'; 23 | } 24 | 25 | return $providers; 26 | }, 100 ); // Must run _after_ wporg-two-factor. 27 | 28 | // Mimics `mu-plugins/main-network/site-support.php`. 29 | add_action( 'init', function() { 30 | if ( ! function_exists( 'bbp_get_user_slug' ) ) { 31 | return; 32 | } 33 | 34 | // e.g., https://wordpress.org/support/users/foo/edit/account/ 35 | add_rewrite_rule( 36 | bbp_get_user_slug() . '/([^/]+)/' . bbp_get_edit_slug() . '/account/?$', 37 | 'index.php?' . bbp_get_user_rewrite_id() . '=$matches[1]&' . 'edit_account=1', 38 | 'top' 39 | ); 40 | } ); 41 | 42 | // Activate the wporg-support theme. 43 | add_action( 'wp_install', function() { 44 | update_option( 'template', 'wporg-support' ); 45 | update_option( 'stylesheet', 'wporg-support' ); 46 | } ); -------------------------------------------------------------------------------- /settings/src/components/screen-link.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useCallback, useContext } from '@wordpress/element'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import { GlobalContext } from '../script'; 10 | 11 | export default function ScreenLink( { screen, anchorText, buttonStyle = false, ariaLabel } ) { 12 | const { navigateToScreen, setBackupCodesVerified } = useContext( GlobalContext ); 13 | const classes = []; 14 | const screenUrl = new URL( document.location.href ); 15 | 16 | screenUrl.searchParams.set( 'screen', screen ); 17 | 18 | if ( 'primary' === buttonStyle ) { 19 | classes.push( 'components-button' ); 20 | classes.push( 'is-primary' ); 21 | } else if ( 'secondary' === buttonStyle ) { 22 | classes.push( 'components-button' ); 23 | classes.push( 'is-secondary' ); 24 | } 25 | 26 | const onClick = useCallback( 27 | ( event ) => { 28 | event.preventDefault(); 29 | 30 | // When generating Backup Codes, they're automatically saved to the database, so clicking `Back` is 31 | // implicitly verifying them, or at least needs to be treated that way. This should be removed once 32 | // `two-factor/#507` is fixed, though. 33 | setBackupCodesVerified( true ); 34 | 35 | navigateToScreen( screen ); 36 | }, 37 | [ navigateToScreen ] 38 | ); 39 | 40 | return ( 41 | 47 | { anchorText } 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and push to build branch. 2 | 3 | on: 4 | push: 5 | branches: [trunk] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.4.0 13 | 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | 18 | - name: Install all dependencies 19 | run: | 20 | composer install 21 | yarn 22 | 23 | - name: Build 24 | run: yarn workspaces run build 25 | 26 | - name: Trim the repo down to just the plugin files 27 | run: | 28 | rm -rf node_modules 29 | rm -rf settings/node_modules 30 | rm -rf vendor 31 | 32 | - name: Append build number to version 33 | run: | 34 | VER=$( jq -r .version ./settings/build/block.json ) 35 | VER="${VER%%-*}" 36 | new_file="$(jq --arg ver "$VER-${GITHUB_SHA::7}" '.version = $ver' ./settings/build/block.json)" 37 | echo -E "$new_file" > ./settings/build/block.json 38 | 39 | - name: Add all the plugin files 40 | run: | 41 | git add * --force 42 | 43 | - name: Commit and push 44 | uses: actions-js/push@a52398fac807b0c1e5f1492c969b477c8560a0ba # 1.3 45 | with: 46 | github_token: ${{ secrets.GITHUB_TOKEN }} 47 | branch: build 48 | force: true 49 | message: 'Build: ${{ github.sha }}' 50 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | div { 60 | display: flex; 61 | flex-direction: column; 62 | align-items: flex-start; 63 | 64 | p { 65 | color: var(--wp--preset--color--charcoal-4, #656a71); 66 | margin: 0; 67 | z-index: 1; 68 | } 69 | } 70 | } 71 | } 72 | 73 | .wporg-2fa__congratulations h3 { 74 | margin-bottom: 14px; 75 | } 76 | -------------------------------------------------------------------------------- /settings/src/components/first-time/home.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/label-has-associated-control */ 2 | /** 3 | * WordPress dependencies 4 | */ 5 | import { useState } from '@wordpress/element'; 6 | import { Button, Flex } from '@wordpress/components'; 7 | 8 | export default function Home( { onSelect } ) { 9 | const [ selectedOption, setSelectedOption ] = useState( 'webauthn' ); 10 | 11 | const handleOptionChange = ( event ) => { 12 | setSelectedOption( event.target.value ); 13 | }; 14 | 15 | const handleButtonClick = () => { 16 | if ( selectedOption === '' ) { 17 | return; 18 | } 19 | 20 | onSelect( selectedOption ); 21 | }; 22 | 23 | return ( 24 | <> 25 |

Select a method to configure two-factor authentication for your account.

26 |
27 | 41 | 55 |
56 | 57 | 60 | 61 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /settings/src/components/auto-tabbing-input.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useCallback } from '@wordpress/element'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import NumericControl from './numeric-control'; 10 | 11 | const AutoTabbingInput = ( props ) => { 12 | const { inputs, setInputs, error, setError } = props; 13 | 14 | const handleChange = useCallback( ( value, event, index, inputRef ) => { 15 | setInputs( ( prevInputs ) => { 16 | const newInputs = [ ...prevInputs ]; 17 | 18 | newInputs[ index ] = value.trim() === '' ? '' : value; 19 | 20 | return newInputs; 21 | } ); 22 | 23 | if ( value && '' !== value.trim() && inputRef.current.nextElementSibling ) { 24 | inputRef.current.nextElementSibling.focus(); 25 | } 26 | }, [] ); 27 | 28 | const handleKeyDown = useCallback( ( value, event, index, inputRef ) => { 29 | if ( event.key === 'Backspace' && ! value && inputRef.current.previousElementSibling ) { 30 | inputRef.current.previousElementSibling.focus(); 31 | } 32 | }, [] ); 33 | 34 | const handlePaste = useCallback( ( event ) => { 35 | event.preventDefault(); 36 | 37 | const newInputs = event.clipboardData 38 | .getData( 'Text' ) 39 | .replace( /[^0-9]/g, '' ) 40 | .split( '' ); 41 | 42 | if ( inputs.length === newInputs.length ) { 43 | setInputs( newInputs ); 44 | } else { 45 | setError( 'The code you pasted is not the correct length.' ); 46 | } 47 | }, [] ); 48 | 49 | return ( 50 |
54 | { inputs.map( ( value, index ) => ( 55 | 65 | ) ) } 66 |
67 | ); 68 | }; 69 | 70 | export default AutoTabbingInput; 71 | -------------------------------------------------------------------------------- /settings/src/components/numeric-control.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useRef, useCallback } from '@wordpress/element'; 5 | 6 | /** 7 | * Input field for values that use digits, but aren't strictly numbers in the mathematical sense. 8 | * 9 | * The classic example is a credit card, but in our context we have TOTP codes, backup codes, etc. We may want to 10 | * display them with spaces for easier reading, etc. 11 | * 12 | * If we used Gutenberg's `NumberControl`, we'd have to hide the extraneous UI elements, and it would still be 13 | * using the underlying `input[type="number"]`, which has some accessibility issues. 14 | * 15 | * @param props 16 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number#accessibility 17 | * @see https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/ 18 | * @see https://stackoverflow.com/a/66759105/450127 19 | */ 20 | export default function NumericControl( props ) { 21 | const { autoComplete, pattern, title, onChange, onKeyDown, index, value, maxLength, required } = 22 | props; 23 | 24 | const inputRef = useRef( null ); 25 | 26 | const handleChange = useCallback( 27 | // Most callers will only need the value, so make it convenient for them. 28 | ( event ) => onChange && onChange( event.target.value, event, index, inputRef ), 29 | [] 30 | ); 31 | 32 | const handleKeyDown = useCallback( 33 | // Most callers will only need the value, so make it convenient for them. 34 | ( event ) => onKeyDown && onKeyDown( event.target.value, event, index, inputRef ), 35 | [] 36 | ); 37 | 38 | return ( 39 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /settings/src/components/account-status.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__account-status { 2 | .wporg-2fa__status-card { 3 | &:hover, 4 | &:focus-within { 5 | z-index: 1; 6 | } 7 | 8 | &.is-disabled, 9 | &.is-disabled .wporg-2fa__status-card-open { 10 | cursor: not-allowed; 11 | } 12 | 13 | a { 14 | display: block; 15 | text-decoration: none !important; 16 | 17 | &:hover, 18 | &:focus { 19 | outline: var(--wp-admin-border-width-focus) solid var(--wp-components-color-accent,var(--wp-admin-theme-color,#007cba)); 20 | outline-offset: 0; 21 | } 22 | } 23 | 24 | .components-card__body { 25 | display: grid; 26 | grid-template-columns: min-content auto min-content min-content; 27 | grid-template-areas: 28 | "status header primary open" 29 | "status description primary open" 30 | ; 31 | grid-column-gap: 18px; 32 | padding: 18px; 33 | 34 | .wporg-2fa__status-icon { 35 | grid-area: status; 36 | align-self: center; 37 | } 38 | 39 | h3 { 40 | grid-area: header; 41 | align-self: end; 42 | margin: 0; 43 | font-size: 1em; 44 | color: $gray-900; 45 | font-weight: 600; 46 | } 47 | 48 | .wporg-2fa__status-card-body { 49 | grid-area: description; 50 | align-self: start; 51 | margin: 0; 52 | color: $gray-700; 53 | } 54 | 55 | .wporg-2fa__status-card-badge { 56 | align-self: center; 57 | border: 1px solid var(--wp-components-color-accent,var(--wp-admin-theme-color,#007cba)); 58 | border-radius: 3px; 59 | font-size: 0.7rem; 60 | line-height: 1; 61 | padding: 6px 10px; 62 | color: var(--wp-components-color-accent,var(--wp-admin-theme-color,#007cba)); 63 | word-break: normal; 64 | } 65 | 66 | .wporg-2fa__status-card-open { 67 | grid-area: open; 68 | align-self: center; 69 | } 70 | } 71 | } 72 | 73 | .wporg-2fa__status-icon { 74 | &.is-enabled, 75 | &.is-ok { 76 | fill: $black; 77 | } 78 | 79 | &.is-pending { 80 | fill: $alert-yellow; 81 | } 82 | 83 | &.is-info, 84 | &.is-disabled, 85 | &.is-error { 86 | fill: $alert-red; 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /settings/src/components/totp.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__totp { 2 | $min-container-height: 500px; 3 | 4 | .wporg-2fa__totp_success { 5 | min-height: $min-container-height; 6 | } 7 | 8 | .wporg-2fa__totp_setup-container { 9 | min-height: $min-container-height; 10 | position: relative; 11 | 12 | > .components-button { 13 | position: absolute; 14 | right: 0; 15 | bottom: -10px; 16 | text-decoration: none !important; 17 | } 18 | 19 | > .components-notice { 20 | width: 100%; 21 | } 22 | } 23 | 24 | .wporg-2fa__totp_setup-method-container { 25 | max-width: 360px; 26 | 27 | .components-button { 28 | width: fit-content; 29 | } 30 | 31 | p { 32 | margin: 0; 33 | } 34 | 35 | @media screen and (max-width: 480px) { 36 | max-width: unset; 37 | width: 100%; 38 | } 39 | } 40 | 41 | .wporg-2fa__setup-form-container { 42 | 43 | & > .components-notice.is-error { 44 | opacity: 0; 45 | height: 0; 46 | 47 | &.is-shown { 48 | transition: opacity 0.5s ease; 49 | margin-bottom: 16px; 50 | height: auto; 51 | opacity: 1; 52 | } 53 | } 54 | 55 | @media screen and (max-width: 480px) { 56 | width: 100%; 57 | } 58 | } 59 | 60 | .wporg-2fa__qr-code { 61 | min-height: 250px; 62 | } 63 | 64 | .wporg-2fa__manual { 65 | display: flex; 66 | flex-direction: column; 67 | width: fit-content; 68 | align-self: center; 69 | margin-bottom: 32px; 70 | max-width: 360px; 71 | 72 | .components-button { 73 | width: fit-content; 74 | } 75 | 76 | code { 77 | font-family: 'IBM Plex Mono'; 78 | font-style: normal; 79 | font-weight: 400; 80 | font-size: 15px; 81 | line-height: 20px; 82 | margin-top: 32px; 83 | } 84 | 85 | } 86 | 87 | .wporg-2fa__enabled-status { 88 | color: $alert-green; 89 | text-transform: uppercase; 90 | font-weight: 700; 91 | } 92 | 93 | .wporg-2fa__setup-form { 94 | display: flex; 95 | flex-direction: column; 96 | justify-content: center; 97 | align-self: center; 98 | text-align: left; 99 | 100 | @media screen and (max-width: 480px) { 101 | width: 100%; 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /class-encrypted-totp-provider.php: -------------------------------------------------------------------------------- 1 | set_user_totp_key( $user_id, $key ); 72 | } 73 | } 74 | 75 | return $key; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /settings/src/components/settings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useContext } from '@wordpress/element'; 5 | 6 | /** 7 | * Internal dependencies 8 | */ 9 | import ScreenNavigation from './screen-navigation'; 10 | import AccountStatus from './account-status'; 11 | import Password from './password'; 12 | import EmailAddress from './email-address'; 13 | import TOTP from './totp'; 14 | import WebAuthn from './webauthn/webauthn'; 15 | import BackupCodes from './backup-codes'; 16 | import SVNPassword from './svn-password'; 17 | 18 | import { GlobalContext } from '../script'; 19 | 20 | /** 21 | * Render the correct component based on the URL. 22 | */ 23 | export default function Settings() { 24 | const { 25 | user: { backupCodesEnabled }, 26 | navigateToScreen, 27 | screen, 28 | } = useContext( GlobalContext ); 29 | 30 | // The index is the URL slug and the value is the React component. 31 | const components = { 32 | email: { 33 | title: 'Account email', 34 | component: , 35 | }, 36 | password: { 37 | title: 'Password', 38 | component: , 39 | }, 40 | totp: { 41 | title: 'Two-factor authentication', 42 | component: ( 43 | { 45 | if ( ! backupCodesEnabled ) { 46 | navigateToScreen( 'backup-codes' ); 47 | } else { 48 | navigateToScreen( 'home' ); 49 | } 50 | } } 51 | /> 52 | ), 53 | }, 54 | 'backup-codes': { 55 | title: 'Backup codes', 56 | component: navigateToScreen( 'home' ) } />, 57 | }, 58 | webauthn: { 59 | title: 'Two-factor security key', 60 | component: ( 61 | { 63 | if ( ! backupCodesEnabled ) { 64 | navigateToScreen( 'backup-codes' ); 65 | } 66 | } } 67 | /> 68 | ), 69 | }, 70 | 'svn-password': { 71 | title: 'SVN credentials', 72 | component: , 73 | }, 74 | }; 75 | 76 | const currentScreenComponent = 77 | 'home' === screen ? ( 78 | 79 | ) : ( 80 | 81 | { components[ screen ].component } 82 | 83 | ); 84 | 85 | return currentScreenComponent; 86 | } 87 | -------------------------------------------------------------------------------- /settings/src/components/first-time/setup-progress-bar.scss: -------------------------------------------------------------------------------- 1 | .wporg-2fa__progress-bar, 2 | #bbpress-forums .wporg-2fa__progress-bar, 3 | #bbpress-forums.bbpress-wrapper .wporg-2fa__progress-bar { 4 | --color-enabled: var(--wp--preset--color--blueberry-1, #3858e9); 5 | --color-disabled: #{$gray-400}; 6 | --color-disabled-text: #{$gray-700}; // Darker than `color-disabled` to meet a11y contrast standards. 7 | 8 | position: relative; 9 | 10 | .wporg-2fa__setup-steps { 11 | position: relative; 12 | margin-bottom: 32px; 13 | z-index: 2; /* On top of the separators. */ 14 | display: flex; 15 | justify-content: space-between; 16 | 17 | .wporg-2fa__setup-count { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | background-color: white; 22 | width: 32px; 23 | height: 32px; 24 | border-radius: 32px; 25 | color: var(--color-disabled-text); 26 | border: 1.5px solid var(--color-disabled); 27 | } 28 | 29 | li { 30 | display: flex; 31 | flex-direction: column; 32 | align-items: center; 33 | 34 | .wporg-2fa__setup-label { 35 | display: block; 36 | font-size: 12px; 37 | } 38 | } 39 | 40 | li.is-enabled { 41 | color: var(--wp--preset--color--charcoal-1 #1e1e1e); 42 | 43 | .wporg-2fa__setup-count { 44 | background-color: var(--color-enabled); 45 | border-color: var(--color-enabled); 46 | color: white; 47 | } 48 | } 49 | 50 | li.is-disabled { 51 | color: var(--color-disabled-text); 52 | 53 | .wporg-2fa__setup-count { 54 | background-color: white; 55 | border-color: var(--color-disabled); 56 | } 57 | } 58 | 59 | li.is-complete { 60 | color: var(--color-disabled-text); 61 | 62 | .wporg-2fa__setup-count { 63 | background-color: var(--color-enabled); 64 | border-color: var(--color-enabled); 65 | color: white; 66 | } 67 | } 68 | } 69 | } 70 | 71 | .wporg-2fa__progress-bar { 72 | margin: 0; 73 | width: 100%; 74 | max-width: 350px; 75 | } 76 | 77 | .wporg-2fa__progress-bar .wporg-2fa__setup-step-separator::before { 78 | content: ''; 79 | position: absolute; 80 | height: 1.5px; 81 | width: var(--wporg-separator-width, 0%); 82 | background: var(--wp--preset--color--blueberry-1, #3858e9); 83 | z-index: 1; 84 | } 85 | 86 | .wporg-2fa__progress-bar .wporg-2fa__setup-step-separator { 87 | position: absolute; 88 | background: var(--color-disabled); 89 | height: 1.5px; 90 | top: 16px; 91 | z-index: 0; 92 | } -------------------------------------------------------------------------------- /settings/src/components/first-time/congratulations.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { Button } from '@wordpress/components'; 5 | import { useContext } from '@wordpress/element'; 6 | 7 | /** 8 | * Internal dependencies 9 | */ 10 | import { GlobalContext } from '../../script'; 11 | 12 | /** 13 | * Check if the URL is valid. Make sure it stays on wordpress.org. 14 | * 15 | * @param url 16 | * @return {boolean} Whether it's a valid URL. 17 | */ 18 | const isValidUrl = ( url ) => { 19 | try { 20 | const { hostname } = new URL( url ); 21 | return hostname.endsWith( 'wordpress.org' ); 22 | } catch ( exception ) { 23 | return false; 24 | } 25 | }; 26 | 27 | export default function Congratulations() { 28 | const { 29 | user: { webAuthnEnabled, totpEnabled, userRecord }, 30 | } = useContext( GlobalContext ); 31 | const { 32 | record: { username }, 33 | } = userRecord; 34 | 35 | const profileURL = `https://profiles.wordpress.org/${ username }/profile/edit/group/3/`; 36 | 37 | const getAuthSuggestion = () => { 38 | if ( webAuthnEnabled && totpEnabled ) { 39 | return null; 40 | } 41 | 42 | return ( 43 | 56 | ); 57 | }; 58 | 59 | return ( 60 | <> 61 |

62 | To ensure the highest level of security for your account, please remember to keep 63 | your authentication methods up-to-date, and consult{ ' ' } 64 | 65 | our documentation 66 | { ' ' } 67 | if you need help or have any questions. 68 |

69 |

70 | We recommend configuring multiple authentication methods and generating backup codes 71 | to guarantee you always have access to your account. 72 |

73 | 74 | { getAuthSuggestion() } 75 | 76 |
77 | 93 |
94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /settings/src/components/revalidate-modal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useCallback, useContext, useEffect, useRef } from '@wordpress/element'; 5 | import { GlobalContext } from '../script'; 6 | import { Modal } from '@wordpress/components'; 7 | import { useMergeRefs, useFocusableIframe } from '@wordpress/compose'; 8 | import { refreshRecord } from '../utilities/common'; 9 | 10 | export default function RevalidateModal() { 11 | const { navigateToScreen } = useContext( GlobalContext ); 12 | 13 | const goBack = useCallback( 14 | ( event ) => { 15 | event.preventDefault(); 16 | navigateToScreen( 'home' ); 17 | }, 18 | [ navigateToScreen ] 19 | ); 20 | 21 | return ( 22 | 30 |

To update your two-factor options, you must first revalidate your session.

31 | 32 | 33 |
34 | ); 35 | } 36 | 37 | function RevalidateIframe() { 38 | const { 39 | user: { userRecord }, 40 | } = useContext( GlobalContext ); 41 | const { record } = userRecord; 42 | const ref = useRef(); 43 | 44 | useEffect( () => { 45 | async function maybeRefreshUser( { data: { type } = {} } ) { 46 | if ( type !== 'reValidationComplete' ) { 47 | return; 48 | } 49 | 50 | // Pretend that the expires_at is in the future (+1hr), this provides a 'faster' UI. 51 | // This intentionally doesn't use `edit()` to prevent it attempting to update it on the server. 52 | record[ '2fa_revalidation' ].expires_at = new Date().getTime() / 1000 + 3600; 53 | 54 | // Refresh the user record, to fetch the correct 2fa_revalidation data. 55 | try { 56 | await refreshRecord( userRecord ); 57 | } catch ( error ) { 58 | // TODO: handle error more properly here, likely by showing a error notice 59 | // eslint-disable-next-line no-console 60 | console.error( 'Failed to refresh user record:', error ); 61 | } 62 | } 63 | 64 | window.addEventListener( 'message', maybeRefreshUser ); 65 | 66 | return () => { 67 | window.removeEventListener( 'message', maybeRefreshUser ); 68 | }; 69 | }, [] ); 70 | 71 | return ( 72 |