diff --git a/app/soapbox/features/security/index.js b/app/soapbox/features/security/index.js
index f69c31f6d..b13305dd1 100644
--- a/app/soapbox/features/security/index.js
+++ b/app/soapbox/features/security/index.js
@@ -5,6 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../ui/components/column';
+import Button from 'soapbox/components/button';
import {
SimpleForm,
SimpleInput,
@@ -18,7 +19,9 @@ import {
revokeOAuthToken,
deleteAccount,
} from 'soapbox/actions/auth';
+import { fetchUserMfaSettings } from '../../actions/mfa';
import { showAlert } from 'soapbox/actions/alerts';
+import { changeSetting, getSettings } from 'soapbox/actions/settings';
/*
Security settings page for user account
@@ -51,9 +54,22 @@ const messages = defineMessages({
deleteSubmit: { id: 'security.submit.delete', defaultMessage: 'Delete Account' },
deleteAccountSuccess: { id: 'security.delete_account.success', defaultMessage: 'Account successfully deleted.' },
deleteAccountFail: { id: 'security.delete_account.fail', defaultMessage: 'Account deletion failed.' },
+ mfa: { id: 'security.mfa', defaultMessage: 'Set up 2-Factor Auth' },
+ mfa_setup_hint: { id: 'security.mfa_setup_hint', defaultMessage: 'Configure multi-factor authentication with OTP' },
+ mfa_enabled: { id: 'security.mfa_enabled', defaultMessage: 'You have multi-factor authentication set up with OTP.' },
+ disable_mfa: { id: 'security.disable_mfa', defaultMessage: 'Disable' },
+ mfaHeader: { id: 'security.mfa_header', defaultMessage: 'Authorization Methods' },
+
});
-export default @injectIntl
+const mapStateToProps = state => ({
+ backup_codes: state.getIn(['auth', 'backup_codes', 'codes']),
+ settings: getSettings(state),
+ tokens: state.getIn(['auth', 'tokens']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
class SecurityForm extends ImmutablePureComponent {
static propTypes = {
@@ -68,6 +84,7 @@ class SecurityForm extends ImmutablePureComponent {
+
@@ -227,9 +244,56 @@ class ChangePasswordForm extends ImmutablePureComponent {
}
-const mapStateToProps = state => ({
- tokens: state.getIn(['auth', 'tokens']),
-});
+@connect(mapStateToProps)
+@injectIntl
+class SetUpMfa extends ImmutablePureComponent {
+
+ constructor(props) {
+ super(props);
+ this.props.dispatch(fetchUserMfaSettings()).then(response => {
+ this.props.dispatch(changeSetting(['otpEnabled'], response.data.settings.enabled));
+ }).catch(e => e);
+ }
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ settings: ImmutablePropTypes.map.isRequired,
+ };
+
+ handleMfaClick = e => {
+ this.context.router.history.push('../auth/mfa');
+ }
+
+ render() {
+ const { intl, settings } = this.props;
+
+ return (
+
+ {intl.formatMessage(messages.mfaHeader)}
+ { settings.get('otpEnabled') === false ?
+
+
+ {intl.formatMessage(messages.mfa_setup_hint)}
+
+
+
:
+
+
+ {intl.formatMessage(messages.mfa_enabled)}
+
+
+
+ }
+
+ );
+ }
+
+}
+
@connect(mapStateToProps)
@injectIntl
diff --git a/app/soapbox/features/security/mfa_form.js b/app/soapbox/features/security/mfa_form.js
new file mode 100644
index 000000000..572579d53
--- /dev/null
+++ b/app/soapbox/features/security/mfa_form.js
@@ -0,0 +1,333 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import QRCode from 'qrcode.react';
+import Column from '../ui/components/column';
+import ColumnSubheading from '../ui/components/column_subheading';
+import LoadingIndicator from 'soapbox/components/loading_indicator';
+import Button from 'soapbox/components/button';
+import { changeSetting, getSettings } from 'soapbox/actions/settings';
+import { showAlert } from 'soapbox/actions/alerts';
+import {
+ SimpleForm,
+ SimpleInput,
+ FieldsGroup,
+ TextInput,
+} from 'soapbox/features/forms';
+import {
+ fetchBackupCodes,
+ fetchToptSetup,
+ confirmToptSetup,
+ fetchUserMfaSettings,
+ disableToptSetup,
+} from '../../actions/mfa';
+
+/*
+Security settings page for user account
+Routed to /auth/mfa
+Includes following features:
+- Set up Multi-factor Auth
+*/
+
+const messages = defineMessages({
+ heading: { id: 'column.security', defaultMessage: 'Security' },
+ subheading: { id: 'column.mfa', defaultMessage: 'Multi-Factor Authentication' },
+ mfa_cancel_button: { id: 'column.mfa_cancel', defaultMessage: 'Cancel' },
+ mfa_setup_button: { id: 'column.mfa_setup', defaultMessage: 'Proceed to Setup' },
+ mfa_setup_confirm_button: { id: 'column.mfa_confirm_button', defaultMessage: 'Confirm' },
+ mfa_setup_disable_button: { id: 'column.mfa_disable_button', defaultMessage: 'Disable' },
+ passwordFieldLabel: { id: 'security.fields.password.label', defaultMessage: 'Password' },
+ confirmFail: { id: 'security.confirm.fail', defaultMessage: 'Incorrect code or password. Try again.' },
+ qrFail: { id: 'security.qr.fail', defaultMessage: 'Failed to fetch setup key' },
+ codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' },
+ disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' },
+});
+
+const mapStateToProps = state => ({
+ backup_codes: state.getIn(['auth', 'backup_codes', 'codes']),
+ settings: getSettings(state),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class MfaForm extends ImmutablePureComponent {
+
+ constructor(props) {
+ super(props);
+ this.props.dispatch(fetchUserMfaSettings()).then(response => {
+ this.props.dispatch(changeSetting(['otpEnabled'], response.data.settings.enabled));
+ // this.setState({ otpEnabled: response.data.settings.enabled });
+ }).catch(e => e);
+ this.handleSetupProceedClick = this.handleSetupProceedClick.bind(this);
+ }
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ settings: ImmutablePropTypes.map.isRequired,
+ };
+
+ state = {
+ displayOtpForm: false,
+ }
+
+ handleSetupProceedClick = e => {
+ e.preventDefault();
+ this.setState({ displayOtpForm: true });
+ }
+
+ render() {
+ const { intl, settings } = this.props;
+ const { displayOtpForm } = this.state;
+
+ return (
+
+
+ { settings.get('otpEnabled') === true && }
+ { settings.get('otpEnabled') === false && }
+ { settings.get('otpEnabled') === false && displayOtpForm && }
+
+ );
+ }
+
+}
+
+
+@connect()
+@injectIntl
+class DisableOtpForm extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ state = {
+ password: '',
+ }
+
+ handleInputChange = e => {
+ this.setState({ [e.target.name]: e.target.value });
+ }
+
+ handleOtpDisableClick = e => {
+ e.preventDefault();
+ const { password } = this.state;
+ const { dispatch, intl } = this.props;
+ dispatch(disableToptSetup(password)).then(response => {
+ this.context.router.history.push('../auth/edit');
+ dispatch(changeSetting(['otpEnabled'], false));
+ }).catch(error => {
+ dispatch(showAlert('', intl.formatMessage(messages.disableFail)));
+ });
+ }
+
+ render() {
+ const { intl } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+}
+
+
+@connect()
+@injectIntl
+class EnableOtpForm extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ state = {
+ backupCodes: [],
+ }
+
+ componentDidMount() {
+ const { dispatch, intl } = this.props;
+ dispatch(fetchBackupCodes()).then(response => {
+ this.setState({ backupCodes: response.data.codes });
+ }).catch(error => {
+ dispatch(showAlert('', intl.formatMessage(messages.codesFail)));
+ });
+ }
+
+ handleCancelClick = e => {
+ this.context.router.history.push('../auth/edit');
+ }
+
+ render() {
+ const { intl } = this.props;
+ const { backupCodes, displayOtpForm } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ { backupCodes.length ?
+
+ {backupCodes.map((code, i) => (
+
+ ))}
+
:
+
+ }
+
+ { !displayOtpForm &&
+
+
+ { backupCodes.length ?
+ :
+ null
+ }
+
+ }
+
+
+ );
+ }
+
+}
+
+
+@connect()
+@injectIntl
+class OtpConfirmForm extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ intl: PropTypes.object.isRequired,
+ };
+
+ state = {
+ password: '',
+ done: false,
+ code: '',
+ qrCodeURI: '',
+ confirm_key: '',
+ }
+
+ componentDidMount() {
+ const { dispatch, intl } = this.props;
+ dispatch(fetchToptSetup()).then(response => {
+ this.setState({ qrCodeURI: response.data.provisioning_uri, confirm_key: response.data.key });
+ }).catch(error => {
+ dispatch(showAlert('', intl.formatMessage(messages.qrFail)));
+ });
+ }
+
+ handleInputChange = e => {
+ this.setState({ [e.target.name]: e.target.value });
+ }
+
+ handleOtpConfirmClick = e => {
+ e.preventDefault();
+ const { code, password } = this.state;
+ const { dispatch, intl } = this.props;
+ dispatch(confirmToptSetup(code, password)).then(response => {
+ dispatch(changeSetting(['otpEnabled'], true));
+ }).catch(error => {
+ dispatch(showAlert('', intl.formatMessage(messages.confirmFail)));
+ });
+ }
+
+ render() {
+ const { intl } = this.props;
+ const { qrCodeURI, confirm_key } = this.state;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js
index 7ca9f788f..f4a4be413 100644
--- a/app/soapbox/features/ui/index.js
+++ b/app/soapbox/features/ui/index.js
@@ -74,6 +74,7 @@ import {
EditProfile,
PasswordReset,
SecurityForm,
+ MfaForm,
} from './util/async-components';
// Dummy import, to make sure that
ends up in the application bundle.
@@ -197,6 +198,7 @@ class SwitchingColumnsArea extends React.PureComponent {
+
diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js
index 48f36a954..e6c468ea0 100644
--- a/app/soapbox/features/ui/util/async-components.js
+++ b/app/soapbox/features/ui/util/async-components.js
@@ -189,3 +189,7 @@ export function PasswordReset() {
export function SecurityForm() {
return import(/* webpackChunkName: "features/security" */'../../security');
}
+
+export function MfaForm() {
+ return import(/* webpackChunkName: "features/security/mfa_form" */'../../security/mfa_form');
+}
diff --git a/app/styles/about.scss b/app/styles/about.scss
index 27a02f011..77d93917e 100644
--- a/app/styles/about.scss
+++ b/app/styles/about.scss
@@ -34,6 +34,7 @@ $small-breakpoint: 960px;
flex-wrap: nowrap;
padding: 14px 0;
box-sizing: border-box;
+ position: relative;
@media screen and (max-width: 1024px) {
padding: 14px 20px;
@@ -1712,7 +1713,40 @@ $small-breakpoint: 960px;
.header,
.container {
position: relative;
- z-index: 1;
+ }
+
+ .otp-form-overlay__container {
+ z-index: 9998;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba($base-overlay-background, 0.7);
+
+ .otp-form-overlay__form {
+ @include standard-panel-shadow;
+ border-radius: 10px;
+ z-index: 9999;
+ margin: 0 auto;
+ max-width: 800px;
+ position: relative;
+ padding: 20px;
+ background-color: var(--background-color);
+ display: flex;
+ flex-direction: column;
+
+ .simple_form {
+ padding: 30px 50px 50px;
+ }
+
+ .otp-form-overlay__close {
+ align-self: flex-end;
+ }
+ }
}
}
@@ -1725,3 +1759,10 @@ $small-breakpoint: 960px;
bottom: 0;
right: 0;
}
+
+h1.otp-login {
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 800;
+ padding: 10px 0;
+}
diff --git a/app/styles/application.scss b/app/styles/application.scss
index b040685c9..dee4df0ba 100644
--- a/app/styles/application.scss
+++ b/app/styles/application.scss
@@ -72,3 +72,4 @@
@import 'components/video-player';
@import 'components/audio-player';
@import 'components/profile_hover_card';
+@import 'components/mfa_form';
diff --git a/app/styles/components/buttons.scss b/app/styles/components/buttons.scss
index fd664d06d..49070ad6c 100644
--- a/app/styles/components/buttons.scss
+++ b/app/styles/components/buttons.scss
@@ -102,7 +102,7 @@ button {
&:focus,
&:hover {
border-color: var(--brand-color);
- color: var(--primary-text-color);
+ color: var(--background-color);
}
&:disabled {
diff --git a/app/styles/components/mfa_form.scss b/app/styles/components/mfa_form.scss
new file mode 100644
index 000000000..80e16da1e
--- /dev/null
+++ b/app/styles/components/mfa_form.scss
@@ -0,0 +1,81 @@
+.security-settings-panel {
+ margin: 20px;
+
+ h1.security-settings-panel__setup-otp {
+ font-size: 20px;
+ line-height: 1.25;
+ color: var(--primary-text-color);
+ font-weight: 600;
+ }
+
+ h2.security-settings-panel__setup-otp {
+ display: block;
+ font-size: 16px;
+ line-height: 1.5;
+ color: var(--primary-text-color--faint);
+ font-weight: 400;
+ }
+
+ div {
+ display: block;
+ margin: 10px 0;
+ }
+
+ .security-warning {
+ color: var(--primary-text-color);
+ padding: 15px 20px;
+ font-size: 14px;
+ background-color: var(--warning-color--faint);
+ margin: 5px 20px;
+ border-radius: 8px;
+ margin: 20px auto;
+ }
+
+ .backup_codes {
+ margin: 20px;
+ font-weight: bold;
+ padding: 15px 20px;
+ font-size: 14px;
+ background-color: var(--brand-color--faint);
+ border-radius: 8px;
+ margin: 20px;
+ text-align: center;
+ position: relative;
+ min-height: 125px;
+
+ .backup_code {
+ margin: 5px auto;
+ }
+
+ .loading-indicator {
+ position: absolute;
+ }
+ }
+
+ .security-settings-panel__setup-otp__buttons {
+ margin: 20px;
+ display: flex;
+ justify-content: space-between;
+
+ .button {
+ min-width: 182px;
+ }
+ }
+
+ div.confirm-key {
+ display: block;
+ font-size: 16px;
+ line-height: 1.5;
+ color: var(--primary-text-color--faint);
+ font-weight: 400;
+ margin: 0 0 20px 20px;
+ }
+}
+
+form.otp-auth {
+ .error-box {
+ width: 100%;
+ text-align: center;
+ color: $error-red;
+ }
+}
diff --git a/app/styles/components/tabs-bar.scss b/app/styles/components/tabs-bar.scss
index 02dac4e2b..76920534b 100644
--- a/app/styles/components/tabs-bar.scss
+++ b/app/styles/components/tabs-bar.scss
@@ -255,9 +255,9 @@
display: block;
margin-right: 30px;
border: 0;
- height: 50px;
+ height: 40px;
overflow: hidden;
- padding: 10px 0;
+ padding: 13px 0 0;
box-sizing: border-box;
filter: brightness(0%) grayscale(100%) invert(100%);
& span {display: none !important;}
diff --git a/app/styles/themes.scss b/app/styles/themes.scss
index 1a717e243..13d952b17 100644
--- a/app/styles/themes.scss
+++ b/app/styles/themes.scss
@@ -30,12 +30,14 @@ body {
--accent-color: hsl(var(--accent-color_hsl));
--primary-text-color: hsl(var(--primary-text-color_hsl));
--background-color: hsl(var(--background-color_hsl));
+ --warning-color: hsla(var(--warning-color_hsl));
// Meta-variables
--brand-color_hsl: var(--brand-color_h), var(--brand-color_s), var(--brand-color_l);
--accent-color_hsl: var(--accent-color_h), var(--accent-color_s), var(--accent-color_l);
--primary-text-color_hsl: var(--primary-text-color_h), var(--primary-text-color_s), var(--primary-text-color_l);
--background-color_hsl: var(--background-color_h), var(--background-color_s), var(--background-color_l);
+ --warning-color_hsl: var(--warning-color_h), var(--warning-color_s), var(--warning-color_l);
--accent-color_h: calc(var(--brand-color_h) - 15);
--accent-color_s: 86%;
--accent-color_l: 44%;
@@ -51,6 +53,7 @@ body {
calc(var(--accent-color_l) + 3%)
);
--primary-text-color--faint: hsla(var(--primary-text-color_hsl), 0.6);
+ --warning-color--faint: hsla(var(--warning-color_hsl), 0.5);
}
body.theme-mode-light {
@@ -69,6 +72,9 @@ body.theme-mode-light {
--background-color_h: 0;
--background-color_s: 0%;
--background-color_l: 94.9%;
+ --warning-color_h: 0;
+ --warning-color_s: 100%;
+ --warning-color_l: 66%;
// Modifiers
--brand-color--hicontrast: hsl(
@@ -94,6 +100,9 @@ body.theme-mode-dark {
--background-color_h: 0;
--background-color_s: 0%;
--background-color_l: 20%;
+ --warning-color_h: 0;
+ --warning-color_s: 100%;
+ --warning-color_l: 66%;
// Modifiers
--brand-color--hicontrast: hsl(
diff --git a/package.json b/package.json
index 9682ef309..ad53a512d 100644
--- a/package.json
+++ b/package.json
@@ -100,6 +100,7 @@
"postcss-object-fit-images": "^1.1.2",
"prop-types": "^15.5.10",
"punycode": "^2.1.0",
+ "qrcode.react": "^1.0.0",
"rails-ujs": "^5.2.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
diff --git a/yarn.lock b/yarn.lock
index c6908bd86..496871844 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9320,6 +9320,20 @@ q@^1.1.2:
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
+qr.js@0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f"
+ integrity sha1-ys6GOG9ZoNuAUPqQ2baw6IoeNk8=
+
+qrcode.react@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-1.0.0.tgz#7e8889db3b769e555e8eb463d4c6de221c36d5de"
+ integrity sha512-jBXleohRTwvGBe1ngV+62QvEZ/9IZqQivdwzo9pJM4LQMoCM2VnvNBnKdjvGnKyDZ/l0nCDgsPod19RzlPvm/Q==
+ dependencies:
+ loose-envify "^1.4.0"
+ prop-types "^15.6.0"
+ qr.js "0.0.0"
+
qs@6.7.0:
version "6.7.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"