Resolved merge conflicts
Merge branch 'develop' into 'translate_public_header' # Conflicts: # app/soapbox/features/public_layout/components/header.js
This commit is contained in:
commit
6435bb0bc5
28 changed files with 1088 additions and 52 deletions
|
@ -112,12 +112,32 @@ export function refreshUserToken() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function otpVerify(code, mfa_token) {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const app = getState().getIn(['auth', 'app']);
|
||||||
|
return api(getState, 'app').post('/oauth/mfa/challenge', {
|
||||||
|
client_id: app.get('client_id'),
|
||||||
|
client_secret: app.get('client_secret'),
|
||||||
|
mfa_token: mfa_token,
|
||||||
|
code: code,
|
||||||
|
challenge_type: 'totp',
|
||||||
|
redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
|
||||||
|
}).then(response => {
|
||||||
|
dispatch(authLoggedIn(response.data));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function logIn(username, password) {
|
export function logIn(username, password) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
return dispatch(createAppAndToken()).then(() => {
|
return dispatch(createAppAndToken()).then(() => {
|
||||||
return dispatch(createUserToken(username, password));
|
return dispatch(createUserToken(username, password));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(showAlert('Login failed.', 'Invalid username or password.'));
|
if (error.response.data.error === 'mfa_required') {
|
||||||
|
throw error;
|
||||||
|
} else {
|
||||||
|
dispatch(showAlert('Login failed.', 'Invalid username or password.'));
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
180
app/soapbox/actions/mfa.js
Normal file
180
app/soapbox/actions/mfa.js
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export const TOTP_SETTINGS_FETCH_REQUEST = 'TOTP_SETTINGS_FETCH_REQUEST';
|
||||||
|
export const TOTP_SETTINGS_FETCH_SUCCESS = 'TOTP_SETTINGS_FETCH_SUCCESS';
|
||||||
|
export const TOTP_SETTINGS_FETCH_FAIL = 'TOTP_SETTINGS_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const BACKUP_CODES_FETCH_REQUEST = 'BACKUP_CODES_FETCH_REQUEST';
|
||||||
|
export const BACKUP_CODES_FETCH_SUCCESS = 'BACKUP_CODES_FETCH_SUCCESS';
|
||||||
|
export const BACKUP_CODES_FETCH_FAIL = 'BACKUP_CODES_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const TOTP_SETUP_FETCH_REQUEST = 'TOTP_SETUP_FETCH_REQUEST';
|
||||||
|
export const TOTP_SETUP_FETCH_SUCCESS = 'TOTP_SETUP_FETCH_SUCCESS';
|
||||||
|
export const TOTP_SETUP_FETCH_FAIL = 'TOTP_SETUP_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const CONFIRM_TOTP_REQUEST = 'CONFIRM_TOTP_REQUEST';
|
||||||
|
export const CONFIRM_TOTP_SUCCESS = 'CONFIRM_TOTP_SUCCESS';
|
||||||
|
export const CONFIRM_TOTP_FAIL = 'CONFIRM_TOTP_FAIL';
|
||||||
|
|
||||||
|
export const DISABLE_TOTP_REQUEST = 'DISABLE_TOTP_REQUEST';
|
||||||
|
export const DISABLE_TOTP_SUCCESS = 'DISABLE_TOTP_SUCCESS';
|
||||||
|
export const DISABLE_TOTP_FAIL = 'DISABLE_TOTP_FAIL';
|
||||||
|
|
||||||
|
export function fetchUserMfaSettings() {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch({ type: TOTP_SETTINGS_FETCH_REQUEST });
|
||||||
|
return api(getState).get('/api/pleroma/accounts/mfa').then(response => {
|
||||||
|
dispatch({ type: TOTP_SETTINGS_FETCH_SUCCESS, totpEnabled: response.data.totp });
|
||||||
|
return response;
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch({ type: TOTP_SETTINGS_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() {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
dispatch({ type: BACKUP_CODES_FETCH_REQUEST });
|
||||||
|
return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(response => {
|
||||||
|
dispatch({ type: BACKUP_CODES_FETCH_SUCCESS, backup_codes: response.data });
|
||||||
|
return response;
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch({ type: BACKUP_CODES_FETCH_FAIL });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBackupCodesRequest() {
|
||||||
|
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) => {
|
||||||
|
dispatch({ type: TOTP_SETUP_FETCH_REQUEST });
|
||||||
|
return api(getState).get('/api/pleroma/accounts/mfa/setup/totp').then(response => {
|
||||||
|
dispatch({ type: TOTP_SETUP_FETCH_SUCCESS, totp_setup: response.data });
|
||||||
|
return response;
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch({ type: TOTP_SETUP_FETCH_FAIL });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchToptSetupRequest() {
|
||||||
|
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) => {
|
||||||
|
dispatch({ type: CONFIRM_TOTP_REQUEST, code });
|
||||||
|
return api(getState).post('/api/pleroma/accounts/mfa/confirm/totp', {
|
||||||
|
code,
|
||||||
|
password,
|
||||||
|
}).then(response => {
|
||||||
|
dispatch({ type: CONFIRM_TOTP_SUCCESS });
|
||||||
|
return response;
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch({ type: CONFIRM_TOTP_FAIL });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmToptRequest() {
|
||||||
|
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) => {
|
||||||
|
dispatch({ type: DISABLE_TOTP_REQUEST });
|
||||||
|
return api(getState).delete('/api/pleroma/accounts/mfa/totp', { data: { password } }).then(response => {
|
||||||
|
dispatch({ type: DISABLE_TOTP_SUCCESS });
|
||||||
|
return response;
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch({ type: DISABLE_TOTP_FAIL });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -23,6 +23,7 @@ const defaultSettings = ImmutableMap({
|
||||||
themeMode: 'light',
|
themeMode: 'light',
|
||||||
locale: navigator.language.split(/[-_]/)[0] || 'en',
|
locale: navigator.language.split(/[-_]/)[0] || 'en',
|
||||||
explanationBox: true,
|
explanationBox: true,
|
||||||
|
otpEnabled: false,
|
||||||
|
|
||||||
systemFont: false,
|
systemFont: false,
|
||||||
dyslexicFont: false,
|
dyslexicFont: false,
|
||||||
|
|
|
@ -172,7 +172,7 @@ class SidebarMenu extends ImmutablePureComponent {
|
||||||
<Icon id='filter' />
|
<Icon id='filter' />
|
||||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.filters)}</span>
|
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.filters)}</span>
|
||||||
</NavLink> */}
|
</NavLink> */}
|
||||||
{ isStaff && <a className='sidebar-menu-item' href={'/pleroma/admin/'} onClick={onClose}>
|
{ isStaff && <a className='sidebar-menu-item' href={'/pleroma/admin/'} target='_blank' onClick={onClose}>
|
||||||
<Icon id='shield' />
|
<Icon id='shield' />
|
||||||
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.admin_settings)}</span>
|
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.admin_settings)}</span>
|
||||||
</a> }
|
</a> }
|
||||||
|
|
|
@ -166,7 +166,7 @@ class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (account.get('id') !== me && isStaff) {
|
if (account.get('id') !== me && isStaff) {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/pleroma/admin/#/users/${account.get('id')}/` });
|
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/pleroma/admin/#/users/${account.get('id')}/`, newTab: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return menu;
|
return menu;
|
||||||
|
|
|
@ -3,11 +3,8 @@
|
||||||
exports[`<LoginForm /> renders correctly 1`] = `
|
exports[`<LoginForm /> renders correctly 1`] = `
|
||||||
<form
|
<form
|
||||||
className="simple_form new_user"
|
className="simple_form new_user"
|
||||||
onSubmit={[Function]}
|
|
||||||
>
|
>
|
||||||
<fieldset
|
<fieldset>
|
||||||
disabled={false}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="fields-group"
|
className="fields-group"
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`<LoginPage /> renders correctly 1`] = `
|
exports[`<LoginPage /> renders correctly on load 1`] = `
|
||||||
<form
|
<form
|
||||||
className="simple_form new_user"
|
className="simple_form new_user"
|
||||||
onSubmit={[Function]}
|
onSubmit={[Function]}
|
||||||
|
@ -59,4 +59,4 @@ exports[`<LoginPage /> renders correctly 1`] = `
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<LoginPage /> renders correctly 2`] = `null`;
|
exports[`<LoginPage /> renders correctly on load 2`] = `null`;
|
||||||
|
|
|
@ -2,9 +2,16 @@ import React from 'react';
|
||||||
import LoginPage from '../login_page';
|
import LoginPage from '../login_page';
|
||||||
import { createComponent, mockStore } from 'soapbox/test_helpers';
|
import { createComponent, mockStore } from 'soapbox/test_helpers';
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
// import { __stub as stubApi } from 'soapbox/api';
|
||||||
|
// import { logIn } from 'soapbox/actions/auth';
|
||||||
|
|
||||||
describe('<LoginPage />', () => {
|
describe('<LoginPage />', () => {
|
||||||
it('renders correctly', () => {
|
beforeEach(() => {
|
||||||
|
const store = mockStore(ImmutableMap({}));
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly on load', () => {
|
||||||
expect(createComponent(
|
expect(createComponent(
|
||||||
<LoginPage />
|
<LoginPage />
|
||||||
).toJSON()).toMatchSnapshot();
|
).toJSON()).toMatchSnapshot();
|
||||||
|
@ -12,7 +19,38 @@ describe('<LoginPage />', () => {
|
||||||
const store = mockStore(ImmutableMap({ me: '1234' }));
|
const store = mockStore(ImmutableMap({ me: '1234' }));
|
||||||
expect(createComponent(
|
expect(createComponent(
|
||||||
<LoginPage />,
|
<LoginPage />,
|
||||||
{ store },
|
{ store }
|
||||||
).toJSON()).toMatchSnapshot();
|
).toJSON()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// it('renders the OTP form when logIn returns with mfa_required', () => {
|
||||||
|
//
|
||||||
|
// stubApi(mock => {
|
||||||
|
// mock.onPost('/api/v1/apps').reply(200, {
|
||||||
|
// data: {
|
||||||
|
// client_id:'12345', client_secret:'12345', id:'111', name:'SoapboxFE', redirect_uri:'urn:ietf:wg:oauth:2.0:oob', website:null, vapid_key:'12345',
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// mock.onPost('/oauth/token').reply(403, {
|
||||||
|
// error:'mfa_required', mfa_token:'12345', supported_challenge_types:'totp',
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// const app = new Map();
|
||||||
|
// app.set('app', { client_id: '12345', client_secret:'12345' });
|
||||||
|
// const store = mockStore(ImmutableMap({
|
||||||
|
// auth: { app },
|
||||||
|
// }));
|
||||||
|
// const loginPage = createComponent(<LoginPage />, { store });
|
||||||
|
//
|
||||||
|
// return loginPage.handleSubmit().then(() => {
|
||||||
|
// const wrapper = loginPage.toJSON();
|
||||||
|
// expect(wrapper.children[0].children[0].children[0].children[0]).toEqual(expect.objectContaining({
|
||||||
|
// type: 'h1',
|
||||||
|
// props: { className: 'otp-login' },
|
||||||
|
// children: [ 'OTP Login' ],
|
||||||
|
// }));
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react';
|
||||||
|
import OtpAuthForm from '../otp_auth_form';
|
||||||
|
import { createComponent, mockStore } from 'soapbox/test_helpers';
|
||||||
|
import { Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
|
describe('<OtpAuthForm />', () => {
|
||||||
|
it('renders correctly', () => {
|
||||||
|
|
||||||
|
const store = mockStore(ImmutableMap({ mfa_auth_needed: true }));
|
||||||
|
|
||||||
|
const wrapper = createComponent(
|
||||||
|
<OtpAuthForm
|
||||||
|
mfa_token={'12345'}
|
||||||
|
/>,
|
||||||
|
{ store }
|
||||||
|
).toJSON();
|
||||||
|
|
||||||
|
expect(wrapper).toEqual(expect.objectContaining({
|
||||||
|
type: 'form',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(wrapper.children[0].children[0].children[0].children[0]).toEqual(expect.objectContaining({
|
||||||
|
type: 'h1',
|
||||||
|
props: { className: 'otp-login' },
|
||||||
|
children: [ 'OTP Login' ],
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,8 +3,6 @@ import { connect } from 'react-redux';
|
||||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { logIn } from 'soapbox/actions/auth';
|
|
||||||
import { fetchMe } from 'soapbox/actions/me';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' },
|
username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' },
|
||||||
|
@ -15,34 +13,12 @@ export default @connect()
|
||||||
@injectIntl
|
@injectIntl
|
||||||
class LoginForm extends ImmutablePureComponent {
|
class LoginForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
state = {
|
|
||||||
isLoading: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
getFormData = (form) => {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Array.from(form).map(i => [i.name, i.value])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit = (event) => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
const { username, password } = this.getFormData(event.target);
|
|
||||||
dispatch(logIn(username, password)).then(() => {
|
|
||||||
return dispatch(fetchMe());
|
|
||||||
}).catch(error => {
|
|
||||||
this.setState({ isLoading: false });
|
|
||||||
});
|
|
||||||
this.setState({ isLoading: true });
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { intl } = this.props;
|
const { intl, isLoading, handleSubmit } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className='simple_form new_user' onSubmit={this.handleSubmit}>
|
<form className='simple_form new_user' onSubmit={handleSubmit}>
|
||||||
<fieldset disabled={this.state.isLoading}>
|
<fieldset disabled={isLoading}>
|
||||||
<div className='fields-group'>
|
<div className='fields-group'>
|
||||||
<div className='input email optional user_email'>
|
<div className='input email optional user_email'>
|
||||||
<input aria-label={intl.formatMessage(messages.username)} className='string email optional' placeholder={intl.formatMessage(messages.username)} type='text' name='username' />
|
<input aria-label={intl.formatMessage(messages.username)} className='string email optional' placeholder={intl.formatMessage(messages.username)} type='text' name='username' />
|
||||||
|
|
|
@ -3,19 +3,57 @@ import { connect } from 'react-redux';
|
||||||
import { Redirect } from 'react-router-dom';
|
import { Redirect } from 'react-router-dom';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import LoginForm from './login_form';
|
import LoginForm from './login_form';
|
||||||
|
import OtpAuthForm from './otp_auth_form';
|
||||||
|
import { logIn } from 'soapbox/actions/auth';
|
||||||
|
import { fetchMe } from 'soapbox/actions/me';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
me: state.get('me'),
|
me: state.get('me'),
|
||||||
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
export default @connect(mapStateToProps)
|
||||||
class LoginPage extends ImmutablePureComponent {
|
class LoginPage extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormData = (form) => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Array.from(form).map(i => [i.name, i.value])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
mfa_auth_needed: false,
|
||||||
|
mfa_token: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit = (event) => {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
const { username, password } = this.getFormData(event.target);
|
||||||
|
dispatch(logIn(username, password)).then(() => {
|
||||||
|
return dispatch(fetchMe());
|
||||||
|
}).catch(error => {
|
||||||
|
if (error.response.data.error === 'mfa_required') {
|
||||||
|
this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token });
|
||||||
|
}
|
||||||
|
this.setState({ isLoading: false });
|
||||||
|
});
|
||||||
|
this.setState({ isLoading: true });
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { me } = this.props;
|
const { me, isLoading } = this.props;
|
||||||
|
const { mfa_auth_needed, mfa_token } = this.state;
|
||||||
if (me) return <Redirect to='/' />;
|
if (me) return <Redirect to='/' />;
|
||||||
|
|
||||||
return <LoginForm />;
|
if (mfa_auth_needed) return <OtpAuthForm mfa_token={mfa_token} />;
|
||||||
|
|
||||||
|
return <LoginForm handleSubmit={this.handleSubmit} isLoading={isLoading} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
92
app/soapbox/features/auth_login/components/otp_auth_form.js
Normal file
92
app/soapbox/features/auth_login/components/otp_auth_form.js
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import { otpVerify } from 'soapbox/actions/auth';
|
||||||
|
import { fetchMe } from 'soapbox/actions/me';
|
||||||
|
import { SimpleInput } from 'soapbox/features/forms';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
otpCodeHint: { id: 'login.fields.otp_code_hint', defaultMessage: 'Enter the two-factor code generated by your phone app or use one of your recovery codes' },
|
||||||
|
otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect()
|
||||||
|
@injectIntl
|
||||||
|
class OtpAuthForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
state = {
|
||||||
|
isLoading: false,
|
||||||
|
code_error: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
mfa_token: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
getFormData = (form) => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Array.from(form).map(i => [i.name, i.value])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSubmit = (event) => {
|
||||||
|
const { dispatch, mfa_token } = this.props;
|
||||||
|
const { code } = this.getFormData(event.target);
|
||||||
|
dispatch(otpVerify(code, mfa_token)).then(() => {
|
||||||
|
this.setState({ code_error: false });
|
||||||
|
return dispatch(fetchMe());
|
||||||
|
}).catch(error => {
|
||||||
|
this.setState({ isLoading: false });
|
||||||
|
if (error.response.data.error === 'Invalid code') {
|
||||||
|
this.setState({ code_error: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.setState({ isLoading: true });
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { intl } = this.props;
|
||||||
|
const { code_error } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className='simple_form new_user otp-auth' onSubmit={this.handleSubmit}>
|
||||||
|
<fieldset disabled={this.state.isLoading}>
|
||||||
|
<div className='fields-group'>
|
||||||
|
<div className='input email optional user_email'>
|
||||||
|
<h1 className='otp-login'>
|
||||||
|
<FormattedMessage id='login.otp_log_in' defaultMessage='OTP Login' />
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className='input code optional otp_code'>
|
||||||
|
<SimpleInput
|
||||||
|
label={intl.formatMessage(messages.otpCodeLabel)}
|
||||||
|
hint={intl.formatMessage(messages.otpCodeHint)}
|
||||||
|
name='code'
|
||||||
|
type='text'
|
||||||
|
autoComplete='off'
|
||||||
|
onChange={this.onInputChange}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{ code_error &&
|
||||||
|
<div className='error-box'>
|
||||||
|
<FormattedMessage id='login.otp_log_in.fail' defaultMessage='Invalid code, please try again.' />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div className='actions'>
|
||||||
|
<button name='button' type='submit' className='btn button button-primary'>
|
||||||
|
<FormattedMessage id='login.log_in' defaultMessage='Log in' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -80,7 +80,7 @@ class ActionBar extends React.PureComponent {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
menu.push({ text: intl.formatMessage(messages.keyboard_shortcuts), action: this.handleHotkeyClick });
|
menu.push({ text: intl.formatMessage(messages.keyboard_shortcuts), action: this.handleHotkeyClick });
|
||||||
if (isStaff) {
|
if (isStaff) {
|
||||||
menu.push({ text: intl.formatMessage(messages.admin_settings), href: '/pleroma/admin/' });
|
menu.push({ text: intl.formatMessage(messages.admin_settings), href: '/pleroma/admin/', newTab: true });
|
||||||
}
|
}
|
||||||
menu.push({ text: intl.formatMessage(messages.preferences), to: '/settings/preferences' });
|
menu.push({ text: intl.formatMessage(messages.preferences), to: '/settings/preferences' });
|
||||||
menu.push({ text: intl.formatMessage(messages.security), to: '/auth/edit' });
|
menu.push({ text: intl.formatMessage(messages.security), to: '/auth/edit' });
|
||||||
|
|
|
@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import ClearColumnButton from './clear_column_button';
|
import ClearColumnButton from './clear_column_button';
|
||||||
import SettingToggle from './setting_toggle';
|
import SettingToggle from './setting_toggle';
|
||||||
|
import MultiSettingToggle from './multi_setting_toggle';
|
||||||
|
|
||||||
export default class ColumnSettings extends React.PureComponent {
|
export default class ColumnSettings extends React.PureComponent {
|
||||||
|
|
||||||
|
@ -18,15 +19,24 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
this.props.onChange(['push', ...path], checked);
|
this.props.onChange(['push', ...path], checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onAllSoundsChange = (path, checked) => {
|
||||||
|
const soundSettings = [['sounds', 'follow'], ['sounds', 'favourite'], ['sounds', 'mention'], ['sounds', 'reblog'], ['sounds', 'poll']];
|
||||||
|
|
||||||
|
for (var i = 0; i < soundSettings.length; i++) {
|
||||||
|
this.props.onChange(soundSettings[i], checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { settings, pushSettings, onChange, onClear } = this.props;
|
const { settings, pushSettings, onChange, onClear } = this.props;
|
||||||
|
|
||||||
const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
|
const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
|
||||||
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
|
const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
|
||||||
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
|
||||||
|
const allSoundsStr = <FormattedMessage id='notifications.column_settings.sounds.all_sounds' defaultMessage='Play sound for all notifications' />;
|
||||||
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
const showStr = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
|
||||||
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
|
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
|
||||||
|
const soundSettings = [['sounds', 'follow'], ['sounds', 'favourite'], ['sounds', 'mention'], ['sounds', 'reblog'], ['sounds', 'poll']];
|
||||||
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
|
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
|
||||||
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
|
const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
|
||||||
|
|
||||||
|
@ -36,11 +46,19 @@ export default class ColumnSettings extends React.PureComponent {
|
||||||
<ClearColumnButton onClick={onClear} />
|
<ClearColumnButton onClick={onClear} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div role='group' aria-labelledby='notifications-all_sounds'>
|
||||||
|
<span id='notifications-filter-bar' className='column-settings__section'>
|
||||||
|
<FormattedMessage id='notifications.column_settings.sounds' defaultMessage='Sounds' />
|
||||||
|
</span>
|
||||||
|
<MultiSettingToggle prefix='notifications_all_sounds' settings={settings} settingPaths={soundSettings} onChange={this.onAllSoundsChange} label={allSoundsStr} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div role='group' aria-labelledby='notifications-filter-bar'>
|
<div role='group' aria-labelledby='notifications-filter-bar'>
|
||||||
<span id='notifications-filter-bar' className='column-settings__section'>
|
<span id='notifications-filter-bar' className='column-settings__section'>
|
||||||
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
|
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
|
||||||
</span>
|
</span>
|
||||||
<div className='column-settings__row'>
|
<div className='column-settings__row'>
|
||||||
|
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
|
||||||
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
|
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
|
||||||
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
|
<SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import Toggle from 'react-toggle';
|
||||||
|
|
||||||
|
export default class MultiSettingToggle extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
prefix: PropTypes.string,
|
||||||
|
settings: ImmutablePropTypes.map.isRequired,
|
||||||
|
settingPaths: PropTypes.array.isRequired,
|
||||||
|
label: PropTypes.node,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
icons: PropTypes.oneOfType([
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.object,
|
||||||
|
]),
|
||||||
|
ariaLabel: PropTypes.string,
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange = ({ target }) => {
|
||||||
|
for (var i = 0; i < this.props.settingPaths.length; i++) {
|
||||||
|
this.props.onChange(this.props.settingPaths[i], target.checked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
areTrue = (settingPath) => {
|
||||||
|
return this.props.settings.getIn(settingPath) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { prefix, settingPaths, label, icons, ariaLabel } = this.props;
|
||||||
|
const id = ['setting-toggle', prefix].filter(Boolean).join('-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='setting-toggle' aria-label={ariaLabel}>
|
||||||
|
<Toggle id={id} checked={settingPaths.every(this.areTrue)} onChange={this.onChange} icons={icons} onKeyDown={this.onKeyDown} />
|
||||||
|
{label && (<label htmlFor={id} className='setting-toggle__label'>{label}</label>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -8,34 +8,88 @@ import SiteLogo from './site_logo';
|
||||||
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { logIn } from 'soapbox/actions/auth';
|
||||||
|
import { fetchMe } from 'soapbox/actions/me';
|
||||||
|
import OtpAuthForm from 'soapbox/features/auth_login/components/otp_auth_form';
|
||||||
|
import IconButton from 'soapbox/components/icon_button';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
home: { id: 'header.home.label', defaultMessage: 'Home' },
|
home: { id: 'header.home.label', defaultMessage: 'Home' },
|
||||||
about: { id: 'header.about.label', defaultMessage: 'About' },
|
about: { id: 'header.about.label', defaultMessage: 'About' },
|
||||||
backTo: { id: 'header.back_to.label', defaultMessage: 'Back to' },
|
backTo: { id: 'header.back_to.label', defaultMessage: 'Back to' },
|
||||||
login: { id: 'header.login.label', defaultMessage: 'Log in' },
|
login: { id: 'header.login.label', defaultMessage: 'Log in' },
|
||||||
|
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
me: state.get('me'),
|
me: state.get('me'),
|
||||||
instance: state.get('instance'),
|
instance: state.get('instance'),
|
||||||
|
isLoading: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
export default @connect(mapStateToProps)
|
||||||
@injectIntl
|
@injectIntl
|
||||||
class Header extends ImmutablePureComponent {
|
class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.handleSubmit = this.handleSubmit.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFormData = (form) => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Array.from(form).map(i => [i.name, i.value])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSubmit = (event) => {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
const { username, password } = this.getFormData(event.target);
|
||||||
|
dispatch(logIn(username, password)).then(() => {
|
||||||
|
return dispatch(fetchMe());
|
||||||
|
}).catch(error => {
|
||||||
|
if (error.response.data.error === 'mfa_required') {
|
||||||
|
this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token });
|
||||||
|
}
|
||||||
|
this.setState({ isLoading: false });
|
||||||
|
});
|
||||||
|
this.setState({ isLoading: true });
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickClose = (event) => {
|
||||||
|
this.setState({ mfa_auth_needed: false, mfa_token: '' });
|
||||||
|
}
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
me: SoapboxPropTypes.me,
|
me: SoapboxPropTypes.me,
|
||||||
instance: ImmutablePropTypes.map,
|
instance: ImmutablePropTypes.map,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state = {
|
||||||
|
mfa_auth_needed: false,
|
||||||
|
mfa_token: '',
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { me, instance, intl } = this.props;
|
const { me, instance, isLoading, intl } = this.props;
|
||||||
|
const { mfa_auth_needed, mfa_token } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className='header'>
|
<nav className='header'>
|
||||||
|
{ mfa_auth_needed &&
|
||||||
|
<div className='otp-form-overlay__container'>
|
||||||
|
<div className='otp-form-overlay__form'>
|
||||||
|
<IconButton className='otp-form-overlay__close' title={intl.formatMessage(messages.close)} icon='times' onClick={this.onClickClose} size={20} />
|
||||||
|
<OtpAuthForm mfa_token={mfa_token} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div className='header-container'>
|
<div className='header-container'>
|
||||||
<div className='nav-left'>
|
<div className='nav-left'>
|
||||||
<Link className='brand' to='/'>
|
<Link className='brand' to='/'>
|
||||||
|
@ -49,7 +103,7 @@ class Header extends ImmutablePureComponent {
|
||||||
<div className='hidden-sm'>
|
<div className='hidden-sm'>
|
||||||
{me
|
{me
|
||||||
? <Link className='nav-link nav-button webapp-btn' to='/'>{intl.formatMessage(messages.backTo)} {instance.get('title')}</Link>
|
? <Link className='nav-link nav-button webapp-btn' to='/'>{intl.formatMessage(messages.backTo)} {instance.get('title')}</Link>
|
||||||
: <LoginForm />
|
: <LoginForm handleSubmit={this.handleSubmit} isLoading={isLoading} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className='visible-sm'>
|
<div className='visible-sm'>
|
||||||
|
|
|
@ -5,6 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
|
import Button from 'soapbox/components/button';
|
||||||
import {
|
import {
|
||||||
SimpleForm,
|
SimpleForm,
|
||||||
SimpleInput,
|
SimpleInput,
|
||||||
|
@ -18,7 +19,9 @@ import {
|
||||||
revokeOAuthToken,
|
revokeOAuthToken,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
} from 'soapbox/actions/auth';
|
} from 'soapbox/actions/auth';
|
||||||
|
import { fetchUserMfaSettings } from '../../actions/mfa';
|
||||||
import { showAlert } from 'soapbox/actions/alerts';
|
import { showAlert } from 'soapbox/actions/alerts';
|
||||||
|
import { changeSetting, getSettings } from 'soapbox/actions/settings';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Security settings page for user account
|
Security settings page for user account
|
||||||
|
@ -51,9 +54,22 @@ const messages = defineMessages({
|
||||||
deleteSubmit: { id: 'security.submit.delete', defaultMessage: 'Delete Account' },
|
deleteSubmit: { id: 'security.submit.delete', defaultMessage: 'Delete Account' },
|
||||||
deleteAccountSuccess: { id: 'security.delete_account.success', defaultMessage: 'Account successfully deleted.' },
|
deleteAccountSuccess: { id: 'security.delete_account.success', defaultMessage: 'Account successfully deleted.' },
|
||||||
deleteAccountFail: { id: 'security.delete_account.fail', defaultMessage: 'Account deletion failed.' },
|
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 {
|
class SecurityForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -68,6 +84,7 @@ class SecurityForm extends ImmutablePureComponent {
|
||||||
<Column icon='lock' heading={intl.formatMessage(messages.heading)} backBtnSlim>
|
<Column icon='lock' heading={intl.formatMessage(messages.heading)} backBtnSlim>
|
||||||
<ChangeEmailForm />
|
<ChangeEmailForm />
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm />
|
||||||
|
<SetUpMfa />
|
||||||
<AuthTokenList />
|
<AuthTokenList />
|
||||||
<DeactivateAccount />
|
<DeactivateAccount />
|
||||||
</Column>
|
</Column>
|
||||||
|
@ -227,9 +244,56 @@ class ChangePasswordForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
@connect(mapStateToProps)
|
||||||
tokens: state.getIn(['auth', 'tokens']),
|
@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 (
|
||||||
|
<SimpleForm>
|
||||||
|
<h2>{intl.formatMessage(messages.mfaHeader)}</h2>
|
||||||
|
{ settings.get('otpEnabled') === false ?
|
||||||
|
<div>
|
||||||
|
<p className='hint'>
|
||||||
|
{intl.formatMessage(messages.mfa_setup_hint)}
|
||||||
|
</p>
|
||||||
|
<Button className='button button-secondary set-up-mfa' text={intl.formatMessage(messages.mfa)} onClick={this.handleMfaClick} />
|
||||||
|
</div> :
|
||||||
|
<div>
|
||||||
|
<p className='hint'>
|
||||||
|
{intl.formatMessage(messages.mfa_enabled)}
|
||||||
|
</p>
|
||||||
|
<Button className='button button--destructive disable-mfa' text={intl.formatMessage(messages.disable_mfa)} onClick={this.handleMfaClick} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</SimpleForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@connect(mapStateToProps)
|
@connect(mapStateToProps)
|
||||||
@injectIntl
|
@injectIntl
|
||||||
|
|
333
app/soapbox/features/security/mfa_form.js
Normal file
333
app/soapbox/features/security/mfa_form.js
Normal file
|
@ -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 (
|
||||||
|
<Column icon='lock' heading={intl.formatMessage(messages.heading)} backBtnSlim>
|
||||||
|
<ColumnSubheading text={intl.formatMessage(messages.subheading)} />
|
||||||
|
{ settings.get('otpEnabled') === true && <DisableOtpForm />}
|
||||||
|
{ settings.get('otpEnabled') === false && <EnableOtpForm handleSetupProceedClick={this.handleSetupProceedClick} />}
|
||||||
|
{ settings.get('otpEnabled') === false && displayOtpForm && <OtpConfirmForm /> }
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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 (
|
||||||
|
<SimpleForm>
|
||||||
|
<div className='security-settings-panel'>
|
||||||
|
<h1 className='security-settings-panel__setup-otp'>
|
||||||
|
<FormattedMessage id='mfa.otp_enabled_title' defaultMessage='OTP Enabled' />
|
||||||
|
</h1>
|
||||||
|
<div><FormattedMessage id='mfa.otp_enabled_description' defaultMessage='You have enabled two-factor authentication via OTP.' /></div>
|
||||||
|
<div><FormattedMessage id='mfa.mfa_disable_enter_password' defaultMessage='Enter your current password to disable two-factor auth:' /></div>
|
||||||
|
<SimpleInput
|
||||||
|
type='password'
|
||||||
|
name='password'
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
<Button className='button button-primary disable' text={intl.formatMessage(messages.mfa_setup_disable_button)} onClick={this.handleOtpDisableClick} />
|
||||||
|
</div>
|
||||||
|
</SimpleForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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 (
|
||||||
|
<SimpleForm>
|
||||||
|
<div className='security-settings-panel'>
|
||||||
|
<h1 className='security-settings-panel__setup-otp'>
|
||||||
|
<FormattedMessage id='mfa.setup_otp_title' defaultMessage='OTP Disabled' />
|
||||||
|
</h1>
|
||||||
|
<h2 className='security-settings-panel__setup-otp'>
|
||||||
|
<FormattedMessage id='mfa.setup_hint' defaultMessage='Follow these steps to set up multi-factor authentication on your account with OTP' />
|
||||||
|
</h2>
|
||||||
|
<div className='security-warning'>
|
||||||
|
<FormattedMessage id='mfa.setup_warning' defaultMessage="Write these codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account." />
|
||||||
|
</div>
|
||||||
|
<h2 className='security-settings-panel__setup-otp'>
|
||||||
|
<FormattedMessage id='mfa.setup_recoverycodes' defaultMessage='Recovery codes' />
|
||||||
|
</h2>
|
||||||
|
<div className='backup_codes'>
|
||||||
|
{ backupCodes.length ?
|
||||||
|
<div>
|
||||||
|
{backupCodes.map((code, i) => (
|
||||||
|
<div key={i} className='backup_code'>
|
||||||
|
<div className='backup_code'>{code}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div> :
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{ !displayOtpForm &&
|
||||||
|
<div className='security-settings-panel__setup-otp__buttons'>
|
||||||
|
<Button className='button button-secondary cancel' text={intl.formatMessage(messages.mfa_cancel_button)} onClick={this.handleCancelClick} />
|
||||||
|
{ backupCodes.length ?
|
||||||
|
<Button className='button button-primary setup' text={intl.formatMessage(messages.mfa_setup_button)} onClick={this.props.handleSetupProceedClick} /> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</SimpleForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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 (
|
||||||
|
<SimpleForm>
|
||||||
|
<div className='security-settings-panel'>
|
||||||
|
|
||||||
|
<fieldset disabled={false}>
|
||||||
|
<FieldsGroup>
|
||||||
|
<div className='security-settings-panel__section-container'>
|
||||||
|
<h2><FormattedMessage id='mfa.mfa_setup_scan_title' defaultMessage='Scan' /></h2>
|
||||||
|
|
||||||
|
<div><FormattedMessage id='mfa.mfa_setup_scan_description' defaultMessage='Using your two-factor app, scan this QR code or enter text key:' /></div>
|
||||||
|
|
||||||
|
<span className='security-settings-panel qr-code'>
|
||||||
|
<QRCode value={qrCodeURI} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className='security-settings-panel confirm-key'><FormattedMessage id='mfa.mfa_setup_scan_key' defaultMessage='Key:' /> {confirm_key}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='security-settings-panel__section-container'>
|
||||||
|
<h2><FormattedMessage id='mfa.mfa_setup_verify_title' defaultMessage='Verify' /></h2>
|
||||||
|
|
||||||
|
<div><FormattedMessage id='mfa.mfa_setup_verify_description' defaultMessage='To enable two-factor authentication, enter the code from your two-factor app:' /></div>
|
||||||
|
<TextInput
|
||||||
|
name='code'
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div><FormattedMessage id='mfa.mfa_setup_enter_password' defaultMessage='Enter your current password to confirm your identity:' /></div>
|
||||||
|
<SimpleInput
|
||||||
|
type='password'
|
||||||
|
name='password'
|
||||||
|
onChange={this.handleInputChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FieldsGroup>
|
||||||
|
</fieldset>
|
||||||
|
<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-primary setup' text={intl.formatMessage(messages.mfa_setup_confirm_button)} onClick={this.handleOtpConfirmClick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SimpleForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -74,6 +74,7 @@ import {
|
||||||
EditProfile,
|
EditProfile,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
SecurityForm,
|
SecurityForm,
|
||||||
|
MfaForm,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
|
|
||||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||||
|
@ -197,6 +198,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||||
<WrappedRoute path='/auth/sign_in' component={LoginPage} publicRoute exact />
|
<WrappedRoute path='/auth/sign_in' component={LoginPage} publicRoute exact />
|
||||||
<WrappedRoute path='/auth/reset_password' component={PasswordReset} publicRoute exact />
|
<WrappedRoute path='/auth/reset_password' component={PasswordReset} publicRoute exact />
|
||||||
<WrappedRoute path='/auth/edit' component={SecurityForm} exact />
|
<WrappedRoute path='/auth/edit' component={SecurityForm} exact />
|
||||||
|
<WrappedRoute path='/auth/mfa' component={MfaForm} exact />
|
||||||
|
|
||||||
<WrappedRoute path='/' exact page={HomePage} component={HomeTimeline} content={children} />
|
<WrappedRoute path='/' exact page={HomePage} component={HomeTimeline} content={children} />
|
||||||
<WrappedRoute path='/timeline/local' exact page={HomePage} component={CommunityTimeline} content={children} />
|
<WrappedRoute path='/timeline/local' exact page={HomePage} component={CommunityTimeline} content={children} />
|
||||||
|
|
|
@ -189,3 +189,7 @@ export function PasswordReset() {
|
||||||
export function SecurityForm() {
|
export function SecurityForm() {
|
||||||
return import(/* webpackChunkName: "features/security" */'../../security');
|
return import(/* webpackChunkName: "features/security" */'../../security');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MfaForm() {
|
||||||
|
return import(/* webpackChunkName: "features/security/mfa_form" */'../../security/mfa_form');
|
||||||
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ $small-breakpoint: 960px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
@media screen and (max-width: 1024px) {
|
@media screen and (max-width: 1024px) {
|
||||||
padding: 14px 20px;
|
padding: 14px 20px;
|
||||||
|
@ -1712,7 +1713,40 @@ $small-breakpoint: 960px;
|
||||||
.header,
|
.header,
|
||||||
.container {
|
.container {
|
||||||
position: relative;
|
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;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1.otp-login {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
|
@ -72,3 +72,4 @@
|
||||||
@import 'components/video-player';
|
@import 'components/video-player';
|
||||||
@import 'components/audio-player';
|
@import 'components/audio-player';
|
||||||
@import 'components/profile_hover_card';
|
@import 'components/profile_hover_card';
|
||||||
|
@import 'components/mfa_form';
|
||||||
|
|
|
@ -102,7 +102,7 @@ button {
|
||||||
&:focus,
|
&:focus,
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: var(--brand-color);
|
border-color: var(--brand-color);
|
||||||
color: var(--primary-text-color);
|
color: var(--background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
|
|
81
app/styles/components/mfa_form.scss
Normal file
81
app/styles/components/mfa_form.scss
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -255,9 +255,9 @@
|
||||||
display: block;
|
display: block;
|
||||||
margin-right: 30px;
|
margin-right: 30px;
|
||||||
border: 0;
|
border: 0;
|
||||||
height: 50px;
|
height: 40px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 10px 0;
|
padding: 13px 0 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
filter: brightness(0%) grayscale(100%) invert(100%);
|
filter: brightness(0%) grayscale(100%) invert(100%);
|
||||||
& span {display: none !important;}
|
& span {display: none !important;}
|
||||||
|
|
|
@ -30,12 +30,14 @@ body {
|
||||||
--accent-color: hsl(var(--accent-color_hsl));
|
--accent-color: hsl(var(--accent-color_hsl));
|
||||||
--primary-text-color: hsl(var(--primary-text-color_hsl));
|
--primary-text-color: hsl(var(--primary-text-color_hsl));
|
||||||
--background-color: hsl(var(--background-color_hsl));
|
--background-color: hsl(var(--background-color_hsl));
|
||||||
|
--warning-color: hsla(var(--warning-color_hsl));
|
||||||
|
|
||||||
// Meta-variables
|
// Meta-variables
|
||||||
--brand-color_hsl: var(--brand-color_h), var(--brand-color_s), var(--brand-color_l);
|
--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);
|
--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);
|
--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);
|
--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_h: calc(var(--brand-color_h) - 15);
|
||||||
--accent-color_s: 86%;
|
--accent-color_s: 86%;
|
||||||
--accent-color_l: 44%;
|
--accent-color_l: 44%;
|
||||||
|
@ -51,6 +53,7 @@ body {
|
||||||
calc(var(--accent-color_l) + 3%)
|
calc(var(--accent-color_l) + 3%)
|
||||||
);
|
);
|
||||||
--primary-text-color--faint: hsla(var(--primary-text-color_hsl), 0.6);
|
--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 {
|
body.theme-mode-light {
|
||||||
|
@ -69,6 +72,9 @@ body.theme-mode-light {
|
||||||
--background-color_h: 0;
|
--background-color_h: 0;
|
||||||
--background-color_s: 0%;
|
--background-color_s: 0%;
|
||||||
--background-color_l: 94.9%;
|
--background-color_l: 94.9%;
|
||||||
|
--warning-color_h: 0;
|
||||||
|
--warning-color_s: 100%;
|
||||||
|
--warning-color_l: 66%;
|
||||||
|
|
||||||
// Modifiers
|
// Modifiers
|
||||||
--brand-color--hicontrast: hsl(
|
--brand-color--hicontrast: hsl(
|
||||||
|
@ -94,6 +100,9 @@ body.theme-mode-dark {
|
||||||
--background-color_h: 0;
|
--background-color_h: 0;
|
||||||
--background-color_s: 0%;
|
--background-color_s: 0%;
|
||||||
--background-color_l: 20%;
|
--background-color_l: 20%;
|
||||||
|
--warning-color_h: 0;
|
||||||
|
--warning-color_s: 100%;
|
||||||
|
--warning-color_l: 66%;
|
||||||
|
|
||||||
// Modifiers
|
// Modifiers
|
||||||
--brand-color--hicontrast: hsl(
|
--brand-color--hicontrast: hsl(
|
||||||
|
|
|
@ -100,6 +100,7 @@
|
||||||
"postcss-object-fit-images": "^1.1.2",
|
"postcss-object-fit-images": "^1.1.2",
|
||||||
"prop-types": "^15.5.10",
|
"prop-types": "^15.5.10",
|
||||||
"punycode": "^2.1.0",
|
"punycode": "^2.1.0",
|
||||||
|
"qrcode.react": "^1.0.0",
|
||||||
"rails-ujs": "^5.2.3",
|
"rails-ujs": "^5.2.3",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -9320,6 +9320,20 @@ q@^1.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
|
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
|
||||||
integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
|
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:
|
qs@6.7.0:
|
||||||
version "6.7.0"
|
version "6.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
|
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
|
||||||
|
|
Loading…
Reference in a new issue