Refactor MFA setup, fixes #792

This commit is contained in:
Alex Gleason 2022-01-07 14:26:19 -06:00
parent 8192c93873
commit 2fd5e5cd35
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
6 changed files with 135 additions and 206 deletions

View File

@ -1,180 +1,80 @@
import api from '../api'; import api from '../api';
export const TOTP_SETTINGS_FETCH_REQUEST = 'TOTP_SETTINGS_FETCH_REQUEST'; export const MFA_FETCH_REQUEST = 'MFA_FETCH_REQUEST';
export const TOTP_SETTINGS_FETCH_SUCCESS = 'TOTP_SETTINGS_FETCH_SUCCESS'; export const MFA_FETCH_SUCCESS = 'MFA_FETCH_SUCCESS';
export const TOTP_SETTINGS_FETCH_FAIL = 'TOTP_SETTINGS_FETCH_FAIL'; export const MFA_FETCH_FAIL = 'MFA_FETCH_FAIL';
export const BACKUP_CODES_FETCH_REQUEST = 'BACKUP_CODES_FETCH_REQUEST'; export const MFA_BACKUP_CODES_FETCH_REQUEST = 'MFA_BACKUP_CODES_FETCH_REQUEST';
export const BACKUP_CODES_FETCH_SUCCESS = 'BACKUP_CODES_FETCH_SUCCESS'; export const MFA_BACKUP_CODES_FETCH_SUCCESS = 'MFA_BACKUP_CODES_FETCH_SUCCESS';
export const BACKUP_CODES_FETCH_FAIL = 'BACKUP_CODES_FETCH_FAIL'; export const MFA_BACKUP_CODES_FETCH_FAIL = 'MFA_BACKUP_CODES_FETCH_FAIL';
export const TOTP_SETUP_FETCH_REQUEST = 'TOTP_SETUP_FETCH_REQUEST'; export const MFA_SETUP_REQUEST = 'MFA_SETUP_REQUEST';
export const TOTP_SETUP_FETCH_SUCCESS = 'TOTP_SETUP_FETCH_SUCCESS'; export const MFA_SETUP_SUCCESS = 'MFA_SETUP_SUCCESS';
export const TOTP_SETUP_FETCH_FAIL = 'TOTP_SETUP_FETCH_FAIL'; export const MFA_SETUP_FAIL = 'MFA_SETUP_FAIL';
export const CONFIRM_TOTP_REQUEST = 'CONFIRM_TOTP_REQUEST'; export const MFA_CONFIRM_REQUEST = 'MFA_CONFIRM_REQUEST';
export const CONFIRM_TOTP_SUCCESS = 'CONFIRM_TOTP_SUCCESS'; export const MFA_CONFIRM_SUCCESS = 'MFA_CONFIRM_SUCCESS';
export const CONFIRM_TOTP_FAIL = 'CONFIRM_TOTP_FAIL'; export const MFA_CONFIRM_FAIL = 'MFA_CONFIRM_FAIL';
export const DISABLE_TOTP_REQUEST = 'DISABLE_TOTP_REQUEST'; export const MFA_DISABLE_REQUEST = 'MFA_DISABLE_REQUEST';
export const DISABLE_TOTP_SUCCESS = 'DISABLE_TOTP_SUCCESS'; export const MFA_DISABLE_SUCCESS = 'MFA_DISABLE_SUCCESS';
export const DISABLE_TOTP_FAIL = 'DISABLE_TOTP_FAIL'; export const MFA_DISABLE_FAIL = 'MFA_DISABLE_FAIL';
export function fetchUserMfaSettings() { export function fetchMfa() {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: TOTP_SETTINGS_FETCH_REQUEST }); dispatch({ type: MFA_FETCH_REQUEST });
return api(getState).get('/api/pleroma/accounts/mfa').then(response => { return api(getState).get('/api/pleroma/accounts/mfa').then(({ data }) => {
dispatch({ type: TOTP_SETTINGS_FETCH_SUCCESS, totpEnabled: response.data.totp }); dispatch({ type: MFA_FETCH_SUCCESS, data });
return response;
}).catch(error => { }).catch(error => {
dispatch({ type: TOTP_SETTINGS_FETCH_FAIL }); dispatch({ type: MFA_FETCH_FAIL });
}); });
}; };
} }
export function fetchUserMfaSettingsRequest() {
return {
type: TOTP_SETTINGS_FETCH_REQUEST,
};
}
export function fetchUserMfaSettingsSuccess() {
return {
type: TOTP_SETTINGS_FETCH_SUCCESS,
};
}
export function fetchUserMfaSettingsFail() {
return {
type: TOTP_SETTINGS_FETCH_FAIL,
};
}
export function fetchBackupCodes() { export function fetchBackupCodes() {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: BACKUP_CODES_FETCH_REQUEST }); dispatch({ type: MFA_BACKUP_CODES_FETCH_REQUEST });
return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(response => { return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(({ data }) => {
dispatch({ type: BACKUP_CODES_FETCH_SUCCESS, backup_codes: response.data }); dispatch({ type: MFA_BACKUP_CODES_FETCH_SUCCESS, data });
return response; return data;
}).catch(error => { }).catch(error => {
dispatch({ type: BACKUP_CODES_FETCH_FAIL }); dispatch({ type: MFA_BACKUP_CODES_FETCH_FAIL });
}); });
}; };
} }
export function fetchBackupCodesRequest() { export function setupMfa(method) {
return {
type: BACKUP_CODES_FETCH_REQUEST,
};
}
export function fetchBackupCodesSuccess(backup_codes, response) {
return {
type: BACKUP_CODES_FETCH_SUCCESS,
backup_codes: response.data,
};
}
export function fetchBackupCodesFail(error) {
return {
type: BACKUP_CODES_FETCH_FAIL,
error,
};
}
export function fetchToptSetup() {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: TOTP_SETUP_FETCH_REQUEST }); dispatch({ type: MFA_SETUP_REQUEST, method });
return api(getState).get('/api/pleroma/accounts/mfa/setup/totp').then(response => { return api(getState).get(`/api/pleroma/accounts/mfa/setup/${method}`).then(({ data }) => {
dispatch({ type: TOTP_SETUP_FETCH_SUCCESS, totp_setup: response.data }); dispatch({ type: MFA_SETUP_SUCCESS, data });
return response; return data;
}).catch(error => { }).catch(error => {
dispatch({ type: TOTP_SETUP_FETCH_FAIL }); dispatch({ type: MFA_SETUP_FAIL });
throw error;
}); });
}; };
} }
export function fetchToptSetupRequest() { export function confirmMfa(method, code, password) {
return {
type: TOTP_SETUP_FETCH_REQUEST,
};
}
export function fetchToptSetupSuccess(totp_setup, response) {
return {
type: TOTP_SETUP_FETCH_SUCCESS,
totp_setup: response.data,
};
}
export function fetchToptSetupFail(error) {
return {
type: TOTP_SETUP_FETCH_FAIL,
error,
};
}
export function confirmToptSetup(code, password) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: CONFIRM_TOTP_REQUEST, code }); const params = { code, password };
return api(getState).post('/api/pleroma/accounts/mfa/confirm/totp', { dispatch({ type: MFA_CONFIRM_REQUEST, method, code });
code, return api(getState).post(`/api/pleroma/accounts/mfa/confirm/${method}`, params).then(() => {
password, dispatch({ type: MFA_CONFIRM_SUCCESS, method, code });
}).then(response => {
dispatch({ type: CONFIRM_TOTP_SUCCESS });
return response;
}).catch(error => { }).catch(error => {
dispatch({ type: CONFIRM_TOTP_FAIL }); dispatch({ type: MFA_CONFIRM_FAIL, method, code, error });
}); });
}; };
} }
export function confirmToptRequest() { export function disableMfa(method, password) {
return {
type: CONFIRM_TOTP_REQUEST,
};
}
export function confirmToptSuccess(backup_codes, response) {
return {
type: CONFIRM_TOTP_SUCCESS,
};
}
export function confirmToptFail(error) {
return {
type: CONFIRM_TOTP_FAIL,
error,
};
}
export function disableToptSetup(password) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: DISABLE_TOTP_REQUEST }); dispatch({ type: MFA_DISABLE_REQUEST, method });
return api(getState).delete('/api/pleroma/accounts/mfa/totp', { data: { password } }).then(response => { return api(getState).delete(`/api/pleroma/accounts/mfa/${method}`, { data: { password } }).then(response => {
dispatch({ type: DISABLE_TOTP_SUCCESS }); dispatch({ type: MFA_DISABLE_SUCCESS, method });
return response;
}).catch(error => { }).catch(error => {
dispatch({ type: DISABLE_TOTP_FAIL }); dispatch({ type: MFA_DISABLE_FAIL, method });
}); });
}; };
} }
export function disableToptRequest() {
return {
type: DISABLE_TOTP_REQUEST,
};
}
export function disableToptSuccess(backup_codes, response) {
return {
type: DISABLE_TOTP_SUCCESS,
};
}
export function disableToptFail(error) {
return {
type: DISABLE_TOTP_FAIL,
error,
};
}

View File

@ -30,7 +30,6 @@ export const defaultSettings = ImmutableMap({
locale: navigator.language.split(/[-_]/)[0] || 'en', locale: navigator.language.split(/[-_]/)[0] || 'en',
showExplanationBox: true, showExplanationBox: true,
explanationBox: true, explanationBox: true,
otpEnabled: false,
autoloadTimelines: true, autoloadTimelines: true,
autoloadMore: true, autoloadMore: true,

View File

@ -18,9 +18,9 @@ import {
deleteAccount, deleteAccount,
} from 'soapbox/actions/security'; } from 'soapbox/actions/security';
import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security'; import { fetchOAuthTokens, revokeOAuthTokenById } from 'soapbox/actions/security';
import { fetchUserMfaSettings } from '../../actions/mfa'; import { fetchMfa } from '../../actions/mfa';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import { changeSetting, getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
/* /*
Security settings page for user account Security settings page for user account
@ -64,6 +64,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
settings: getSettings(state), settings: getSettings(state),
tokens: state.getIn(['security', 'tokens']), tokens: state.getIn(['security', 'tokens']),
mfa: state.getIn(['security', 'mfa']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -242,33 +243,30 @@ class ChangePasswordForm extends ImmutablePureComponent {
@injectIntl @injectIntl
class SetUpMfa extends ImmutablePureComponent { 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 = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
}; };
static propTypes = { static propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
settings: ImmutablePropTypes.map.isRequired, mfa: ImmutablePropTypes.map.isRequired,
}; };
handleMfaClick = e => { handleMfaClick = e => {
this.context.router.history.push('../auth/mfa'); this.context.router.history.push('../auth/mfa');
} }
componentDidMount() {
this.props.dispatch(fetchMfa());
}
render() { render() {
const { intl, settings } = this.props; const { intl, mfa } = this.props;
return ( return (
<SimpleForm> <SimpleForm>
<h2>{intl.formatMessage(messages.mfaHeader)}</h2> <h2>{intl.formatMessage(messages.mfaHeader)}</h2>
{ settings.get('otpEnabled') === false ? {!mfa.getIn(['settings', 'totp']) ?
<div> <div>
<p className='hint'> <p className='hint'>
{intl.formatMessage(messages.mfa_setup_hint)} {intl.formatMessage(messages.mfa_setup_hint)}

View File

@ -9,7 +9,6 @@ import Column from '../ui/components/column';
import ColumnSubheading from '../ui/components/column_subheading'; import ColumnSubheading from '../ui/components/column_subheading';
import LoadingIndicator from 'soapbox/components/loading_indicator'; import LoadingIndicator from 'soapbox/components/loading_indicator';
import Button from 'soapbox/components/button'; import Button from 'soapbox/components/button';
import { changeSetting, getSettings } from 'soapbox/actions/settings';
import snackbar from 'soapbox/actions/snackbar'; import snackbar from 'soapbox/actions/snackbar';
import ShowablePassword from 'soapbox/components/showable_password'; import ShowablePassword from 'soapbox/components/showable_password';
import { import {
@ -18,11 +17,11 @@ import {
TextInput, TextInput,
} from 'soapbox/features/forms'; } from 'soapbox/features/forms';
import { import {
fetchMfa,
fetchBackupCodes, fetchBackupCodes,
fetchToptSetup, setupMfa,
confirmToptSetup, confirmMfa,
fetchUserMfaSettings, disableMfa,
disableToptSetup,
} from '../../actions/mfa'; } from '../../actions/mfa';
/* /*
@ -44,26 +43,19 @@ const messages = defineMessages({
qrFail: { id: 'security.qr.fail', defaultMessage: 'Failed to fetch setup key' }, qrFail: { id: 'security.qr.fail', defaultMessage: 'Failed to fetch setup key' },
codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' }, codesFail: { id: 'security.codes.fail', defaultMessage: 'Failed to fetch backup codes' },
disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' }, disableFail: { id: 'security.disable.fail', defaultMessage: 'Incorrect password. Try again.' },
mfaDisableSuccess: { id: 'mfa.disable.success_message', defaultMessage: 'MFA disabled' },
mfaConfirmSuccess: { id: 'mfa.confirm.success_message', defaultMessage: 'MFA confirmed' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
backup_codes: state.getIn(['auth', 'backup_codes', 'codes']), backup_codes: state.getIn(['auth', 'backup_codes', 'codes']),
settings: getSettings(state), mfa: state.getIn(['security', 'mfa']),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@injectIntl @injectIntl
class MfaForm extends ImmutablePureComponent { 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 = { static contextTypes = {
router: PropTypes.object, router: PropTypes.object,
}; };
@ -71,7 +63,7 @@ class MfaForm extends ImmutablePureComponent {
static propTypes = { static propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
settings: ImmutablePropTypes.map.isRequired, mfa: ImmutablePropTypes.map.isRequired,
}; };
state = { state = {
@ -79,20 +71,29 @@ class MfaForm extends ImmutablePureComponent {
} }
handleSetupProceedClick = e => { handleSetupProceedClick = e => {
e.preventDefault();
this.setState({ displayOtpForm: true }); this.setState({ displayOtpForm: true });
e.preventDefault();
}
componentDidMount() {
this.props.dispatch(fetchMfa());
} }
render() { render() {
const { intl, settings } = this.props; const { intl, mfa } = this.props;
const { displayOtpForm } = this.state; const { displayOtpForm } = this.state;
return ( return (
<Column icon='lock' heading={intl.formatMessage(messages.heading)}> <Column icon='lock' heading={intl.formatMessage(messages.heading)}>
<ColumnSubheading text={intl.formatMessage(messages.subheading)} /> <ColumnSubheading text={intl.formatMessage(messages.subheading)} />
{ settings.get('otpEnabled') === true && <DisableOtpForm />} {mfa.getIn(['settings', 'totp']) ? (
{ settings.get('otpEnabled') === false && <EnableOtpForm handleSetupProceedClick={this.handleSetupProceedClick} />} <DisableOtpForm />
{ settings.get('otpEnabled') === false && displayOtpForm && <OtpConfirmForm /> } ) : (
<>
<EnableOtpForm handleSetupProceedClick={this.handleSetupProceedClick} />
{displayOtpForm && <OtpConfirmForm />}
</>
)}
</Column> </Column>
); );
} }
@ -122,15 +123,17 @@ class DisableOtpForm extends ImmutablePureComponent {
} }
handleOtpDisableClick = e => { handleOtpDisableClick = e => {
e.preventDefault();
const { password } = this.state; const { password } = this.state;
const { dispatch, intl } = this.props; const { dispatch, intl } = this.props;
dispatch(disableToptSetup(password)).then(response => {
this.context.router.history.push('../auth/edit'); dispatch(disableMfa('totp', password)).then(() => {
dispatch(changeSetting(['otpEnabled'], false)); dispatch(snackbar.success(intl.formatMessage(messages.mfaDisableSuccess)));
}).catch(error => { }).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.disableFail))); dispatch(snackbar.error(intl.formatMessage(messages.disableFail)));
}); });
this.context.router.history.push('../auth/edit');
e.preventDefault();
} }
render() { render() {
@ -176,8 +179,9 @@ class EnableOtpForm extends ImmutablePureComponent {
componentDidMount() { componentDidMount() {
const { dispatch, intl } = this.props; const { dispatch, intl } = this.props;
dispatch(fetchBackupCodes()).then(response => {
this.setState({ backupCodes: response.data.codes }); dispatch(fetchBackupCodes()).then(({ codes: backupCodes }) => {
this.setState({ backupCodes });
}).catch(error => { }).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.codesFail))); dispatch(snackbar.error(intl.formatMessage(messages.codesFail)));
}); });
@ -207,26 +211,26 @@ class EnableOtpForm extends ImmutablePureComponent {
<FormattedMessage id='mfa.setup_recoverycodes' defaultMessage='Recovery codes' /> <FormattedMessage id='mfa.setup_recoverycodes' defaultMessage='Recovery codes' />
</h2> </h2>
<div className='backup_codes'> <div className='backup_codes'>
{ backupCodes.length ? {backupCodes.length > 0 ? (
<div> <div>
{backupCodes.map((code, i) => ( {backupCodes.map((code, i) => (
<div key={i} className='backup_code'> <div key={i} className='backup_code'>
<div className='backup_code'>{code}</div> <div className='backup_code'>{code}</div>
</div> </div>
))} ))}
</div> : </div>
) : (
<LoadingIndicator /> <LoadingIndicator />
} )}
</div> </div>
{ !displayOtpForm && {!displayOtpForm && (
<div className='security-settings-panel__setup-otp__buttons'> <div className='security-settings-panel__setup-otp__buttons'>
<Button className='button button-secondary cancel' text={intl.formatMessage(messages.mfa_cancel_button)} onClick={this.handleCancelClick} /> <Button className='button button-secondary cancel' text={intl.formatMessage(messages.mfa_cancel_button)} onClick={this.handleCancelClick} />
{ backupCodes.length ? {backupCodes.length > 0 && (
<Button className='button button-primary setup' text={intl.formatMessage(messages.mfa_setup_button)} onClick={this.props.handleSetupProceedClick} /> : <Button className='button button-primary setup' text={intl.formatMessage(messages.mfa_setup_button)} onClick={this.props.handleSetupProceedClick} />
null )}
}
</div> </div>
} )}
</div> </div>
</SimpleForm> </SimpleForm>
); );
@ -257,8 +261,9 @@ class OtpConfirmForm extends ImmutablePureComponent {
componentDidMount() { componentDidMount() {
const { dispatch, intl } = this.props; const { dispatch, intl } = this.props;
dispatch(fetchToptSetup()).then(response => {
this.setState({ qrCodeURI: response.data.provisioning_uri, confirm_key: response.data.key }); dispatch(setupMfa('totp')).then(data => {
this.setState({ qrCodeURI: data.provisioning_uri, confirm_key: data.key });
}).catch(error => { }).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.qrFail))); dispatch(snackbar.error(intl.formatMessage(messages.qrFail)));
}); });
@ -269,14 +274,17 @@ class OtpConfirmForm extends ImmutablePureComponent {
} }
handleOtpConfirmClick = e => { handleOtpConfirmClick = e => {
e.preventDefault();
const { code, password } = this.state; const { code, password } = this.state;
const { dispatch, intl } = this.props; const { dispatch, intl } = this.props;
dispatch(confirmToptSetup(code, password)).then(response => {
dispatch(changeSetting(['otpEnabled'], true)); dispatch(confirmMfa('totp', code, password)).then(() => {
dispatch(snackbar.success(intl.formatMessage(messages.mfaConfirmSuccess)));
}).catch(error => { }).catch(error => {
dispatch(snackbar.error(intl.formatMessage(messages.confirmFail))); dispatch(snackbar.error(intl.formatMessage(messages.confirmFail)));
}); });
this.context.router.history.push('../auth/edit');
e.preventDefault();
} }
render() { render() {

View File

@ -2,10 +2,22 @@ import {
FETCH_TOKENS_SUCCESS, FETCH_TOKENS_SUCCESS,
REVOKE_TOKEN_SUCCESS, REVOKE_TOKEN_SUCCESS,
} from '../actions/security'; } from '../actions/security';
import {
MFA_FETCH_SUCCESS,
MFA_CONFIRM_SUCCESS,
MFA_DISABLE_REQUEST,
MFA_DISABLE_SUCCESS,
MFA_DISABLE_FAIL,
} from '../actions/mfa';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
tokens: ImmutableList(), tokens: ImmutableList(),
mfa: ImmutableMap({
settings: ImmutableMap({
totp: false,
}),
}),
}); });
const deleteToken = (state, tokenId) => { const deleteToken = (state, tokenId) => {
@ -14,12 +26,33 @@ const deleteToken = (state, tokenId) => {
}); });
}; };
const importMfa = (state, data) => {
return state.set('mfa', data);
};
const enableMfa = (state, method) => {
return state.setIn(['mfa', 'settings', method], true);
};
const disableMfa = (state, method) => {
return state.setIn(['mfa', 'settings', method], false);
};
export default function security(state = initialState, action) { export default function security(state = initialState, action) {
switch(action.type) { switch(action.type) {
case FETCH_TOKENS_SUCCESS: case FETCH_TOKENS_SUCCESS:
return state.set('tokens', fromJS(action.tokens)); return state.set('tokens', fromJS(action.tokens));
case REVOKE_TOKEN_SUCCESS: case REVOKE_TOKEN_SUCCESS:
return deleteToken(state, action.id); return deleteToken(state, action.id);
case MFA_FETCH_SUCCESS:
return importMfa(state, fromJS(action.data));
case MFA_CONFIRM_SUCCESS:
return enableMfa(state, action.method);
case MFA_DISABLE_REQUEST:
case MFA_DISABLE_SUCCESS:
return disableMfa(state, action.method);
case MFA_DISABLE_FAIL:
return enableMfa(state, action.method);
default: default:
return state; return state;
} }

View File

@ -16,11 +16,6 @@
font-weight: 400; font-weight: 400;
} }
div {
display: block;
margin: 10px 0;
}
.security-warning { .security-warning {
color: var(--primary-text-color); color: var(--primary-text-color);
padding: 15px 20px; padding: 15px 20px;
@ -44,10 +39,6 @@
.backup_code { .backup_code {
margin: 5px auto; margin: 5px auto;
} }
.loading-indicator {
position: absolute;
}
} }
.security-settings-panel__setup-otp__buttons { .security-settings-panel__setup-otp__buttons {