Merge branch 'admin_cfg' of https://gitlab.com/soapbox-pub/soapbox-fe into admin_cfg

preload
crockwave 4 years ago
commit a001fa2dfe

@ -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) {
return (dispatch, getState) => {
return dispatch(createAppAndToken()).then(() => {
return dispatch(createUserToken(username, password));
}).catch(error => {
if (error.response.data.error === 'mfa_required') {
throw error;
} else {
dispatch(showAlert('Login failed.', 'Invalid username or password.'));
}
throw error;
});
};

@ -1,4 +1,5 @@
import api from '../api';
import { showAlert } from 'soapbox/actions/alerts';
export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST';
export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS';
@ -8,6 +9,10 @@ export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST';
export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS';
export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL';
export const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST';
export const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS';
export const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL';
export const fetchFilters = () => (dispatch, getState) => {
if (!getState().get('me')) return;
@ -31,13 +36,33 @@ export const fetchFilters = () => (dispatch, getState) => {
}));
};
export function createFilter(params) {
export function createFilter(phrase, expires_at, context, whole_word, irreversible) {
return (dispatch, getState) => {
dispatch({ type: FILTERS_CREATE_REQUEST });
return api(getState).post('/api/v1/filters', params).then(response => {
return api(getState).post('/api/v1/filters', {
phrase,
context,
irreversible,
whole_word,
expires_at,
}).then(response => {
dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data });
dispatch(showAlert('', 'Filter added'));
}).catch(error => {
dispatch({ type: FILTERS_CREATE_FAIL, error });
});
};
}
export function deleteFilter(id) {
return (dispatch, getState) => {
dispatch({ type: FILTERS_DELETE_REQUEST });
return api(getState).delete('/api/v1/filters/'+id).then(response => {
dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data });
dispatch(showAlert('', 'Filter deleted'));
}).catch(error => {
dispatch({ type: FILTERS_DELETE_FAIL, error });
});
};
}

@ -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',
locale: navigator.language.split(/[-_]/)[0] || 'en',
explanationBox: true,
otpEnabled: false,
systemFont: false,
dyslexicFont: false,
@ -32,6 +33,7 @@ const defaultSettings = ImmutableMap({
shows: ImmutableMap({
reblog: true,
reply: true,
direct: false,
}),
regex: ImmutableMap({
@ -72,6 +74,10 @@ const defaultSettings = ImmutableMap({
}),
community: ImmutableMap({
shows: ImmutableMap({
reblog: true,
reply: true,
}),
other: ImmutableMap({
onlyMedia: false,
}),
@ -81,6 +87,10 @@ const defaultSettings = ImmutableMap({
}),
public: ImmutableMap({
shows: ImmutableMap({
reblog: true,
reply: true,
}),
other: ImmutableMap({
onlyMedia: false,
}),

@ -148,13 +148,21 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
};
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandGroupTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import VerificationBadge from './verification_badge';
import { acctFull } from '../utils/accounts';
import { List as ImmutableList } from 'immutable';
export default class DisplayName extends React.PureComponent {
@ -16,13 +17,14 @@ export default class DisplayName extends React.PureComponent {
const { account, others, children } = this.props;
let displayName, suffix;
const verified = account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified');
if (others && others.size > 1) {
displayName = others.take(2).map(a => [
<bdi key={a.get('id')}>
<strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} />
</bdi>,
a.get('is_verified') && <VerificationBadge />,
verified && <VerificationBadge />,
]).reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
@ -32,7 +34,7 @@ export default class DisplayName extends React.PureComponent {
displayName = (
<>
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /></bdi>
{account.get('is_verified') && <VerificationBadge />}
{verified && <VerificationBadge />}
</>
);
suffix = <span className='display-name__account'>@{acctFull(account)}</span>;

@ -169,11 +169,11 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='times-circle' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.mutes)}</span>
</NavLink>
{/* <NavLink className='sidebar-menu-item' to='/filters' onClick={onClose}>
<NavLink className='sidebar-menu-item' to='/filters' onClick={onClose}>
<Icon id='filter' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.filters)}</span>
</NavLink> */}
{ isStaff && <a className='sidebar-menu-item' href={'/pleroma/admin/'} onClick={onClose}>
</NavLink>
{ isStaff && <a className='sidebar-menu-item' href={'/pleroma/admin/'} target='_blank' onClick={onClose}>
<Icon id='shield' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.admin_settings)}</span>
</a> }

@ -476,7 +476,9 @@ class Status extends ImmutablePureComponent {
<NavLink to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name'>
<DisplayName account={status.get('account')} others={otherAccounts} />
</NavLink>
<ProfileHoverCardContainer accountId={status.getIn(['account', 'id'])} visible={!isMobile(window.innerWidth) && profileCardVisible} />
{ profileCardVisible &&
<ProfileHoverCardContainer accountId={status.getIn(['account', 'id'])} visible={!isMobile(window.innerWidth)} />
}
</div>
</div>

@ -367,7 +367,9 @@ class StatusActionBar extends ImmutablePureComponent {
onMouseLeave={this.handleLikeButtonLeave}
ref={this.setRef}
>
{ emojiSelectorVisible &&
<EmojiSelector onReact={this.handleReactClick} visible={emojiSelectorVisible} />
}
<IconButton
className='status__action-bar-button star-icon'
animate

@ -166,7 +166,7 @@ class Header extends ImmutablePureComponent {
if (account.get('id') !== me && isStaff) {
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;

@ -3,11 +3,8 @@
exports[`<LoginForm /> renders correctly 1`] = `
<form
className="simple_form new_user"
onSubmit={[Function]}
>
<fieldset
disabled={false}
>
<fieldset>
<div
className="fields-group"
>

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginPage /> renders correctly 1`] = `
exports[`<LoginPage /> renders correctly on load 1`] = `
<form
className="simple_form new_user"
onSubmit={[Function]}
@ -59,4 +59,4 @@ exports[`<LoginPage /> renders correctly 1`] = `
</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 { createComponent, mockStore } from 'soapbox/test_helpers';
import { Map as ImmutableMap } from 'immutable';
// import { __stub as stubApi } from 'soapbox/api';
// import { logIn } from 'soapbox/actions/auth';
describe('<LoginPage />', () => {
it('renders correctly', () => {
beforeEach(() => {
const store = mockStore(ImmutableMap({}));
return store;
});
it('renders correctly on load', () => {
expect(createComponent(
<LoginPage />
).toJSON()).toMatchSnapshot();
@ -12,7 +19,38 @@ describe('<LoginPage />', () => {
const store = mockStore(ImmutableMap({ me: '1234' }));
expect(createComponent(
<LoginPage />,
{ store },
{ store }
).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 { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { logIn } from 'soapbox/actions/auth';
import { fetchMe } from 'soapbox/actions/me';
const messages = defineMessages({
username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' },
@ -15,34 +13,12 @@ export default @connect()
@injectIntl
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() {
const { intl } = this.props;
const { intl, isLoading, handleSubmit } = this.props;
return (
<form className='simple_form new_user' onSubmit={this.handleSubmit}>
<fieldset disabled={this.state.isLoading}>
<form className='simple_form new_user' onSubmit={handleSubmit}>
<fieldset disabled={isLoading}>
<div className='fields-group'>
<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' />

@ -3,19 +3,57 @@ import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
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 => ({
me: state.get('me'),
isLoading: false,
});
export default @connect(mapStateToProps)
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() {
const { me } = this.props;
const { me, isLoading } = this.props;
const { mfa_auth_needed, mfa_token } = this.state;
if (me) return <Redirect to='/' />;
return <LoginForm />;
if (mfa_auth_needed) return <OtpAuthForm mfa_token={mfa_token} />;
return <LoginForm handleSubmit={this.handleSubmit} isLoading={isLoading} />;
}
}

@ -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>
);
}
}

@ -18,6 +18,14 @@ class ColumnSettings extends React.PureComponent {
return (
<div>
<div className='column-settings__row'>
<SettingToggle prefix='community_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reposts' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='community_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
</div>

@ -77,12 +77,12 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
// menu.push({ text: intl.formatMessage(messages.filters), to: '/filters' });
menu.push({ text: intl.formatMessage(messages.filters), to: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.keyboard_shortcuts), action: this.handleHotkeyClick });
if (isStaff) {
menu.push({ text: intl.formatMessage(messages.admin_settings), href: '/pleroma/admin/' });
menu.push({ text: intl.formatMessage(messages.soapbox_settings), href: '/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.security), to: '/auth/edit' });

@ -33,17 +33,9 @@ const messages = defineMessages({
});
const mapStateToProps = state => {
const soapbox = state.get('soapbox');
// const soapbox = state.get('soapbox');
return {
brandColor: soapbox.get('brandColor'),
customCssItems: soapbox.get('customCss'),
logo: soapbox.get('logo'),
banner: soapbox.get('banner'),
promoItems: soapbox.getIn(['promoPanel', 'items']),
patronEnabled: soapbox.getIn(['extensions', 'patron']),
autoPlayGif: soapbox.getIn(['defaultSettings', 'autoPlayGif']),
copyright: soapbox.get('copyright'),
homeFooterItems: soapbox.getIn(['navlinks', 'homeFooter']),
soapbox: state.get('soapbox'),
};
};
@ -52,17 +44,9 @@ export default @connect(mapStateToProps)
class ConfigSoapbox extends ImmutablePureComponent {
static propTypes = {
soapbox: ImmutablePropTypes.map,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
brandColor: PropTypes.string,
customCssItems: ImmutablePropTypes.list,
logo: PropTypes.string,
banner: PropTypes.string,
promoItems: ImmutablePropTypes.list,
patronEnabled: PropTypes.bool,
autoPlayGif: PropTypes.bool,
copyright: PropTypes.string,
homeFooterItems: ImmutablePropTypes.list,
};
state = {
@ -71,25 +55,28 @@ class ConfigSoapbox extends ImmutablePureComponent {
constructor(props) {
super(props);
this.state = {
logo: props.logo,
banner: props.banner,
brandColor: props.brandColor,
customCssItems: props.customCssItems,
promoItems: props.promoItems,
homeFooterItems: props.homeFooterItems,
copyright: props.copyright,
patronEnabled: false,
autoPlayGif: false,
};
const initialState = props.soapbox.withMutations(map => {
});
this.state = initialState.toObject();
// this.state = ImmutableMap(props.soapbox);
// console.log(this.state);
// console.log(JSON.stringify(this.state, null, 2));
if (!this.state.logo) {
this.state.logo = '';
}
if (!this.state.banner) {
this.state.banner = '';
}
if (!this.state.promoItems) {
this.state.promoItems = ImmutableList([
if (!this.state.defaultSettings.autoPlayGif) {
this.state.defaultSettings.autoPlayGif = false;
// console.log(this.state.defaultSettings.autoPlayGif);
};
if (!this.state.extensions.patron) {
this.state.extensions.patron = false;
// console.log(this.state.extensions.patron);
};
if (!this.state.promoPanel.items) {
this.state.promoPanel.items = ImmutableList([
ImmutableMap({
icon: '',
text: '',
@ -97,8 +84,8 @@ class ConfigSoapbox extends ImmutablePureComponent {
}),
]);
};
if (!this.state.homeFooterItems) {
this.state.homeFooterItems = ImmutableList([
if (!this.state.navlinks.homeFooter) {
this.state.navlinks.homeFooter = ImmutableList([
ImmutableMap({
title: '',
url: '',
@ -106,12 +93,15 @@ class ConfigSoapbox extends ImmutablePureComponent {
]);
};
if (!this.state.customCssItems) {
this.state.customCssItems = ImmutableList([]);
this.state.customCssItems = ImmutableList([' ']);
};
this.handlecustomCSSChange = this.handleCustomCSSChange.bind(this);
this.handleAddPromoPanelItem = this.handleAddPromoPanelItem.bind(this);
this.handleAddHomeFooterItem = this.handleAddHomeFooterItem.bind(this);
this.handleAddCssItem = this.handleAddCssItem.bind(this);
this.handleExtensionsCheckboxChange = this.handleExtensionsCheckboxChange.bind(this);
this.handleDefaultSettingsCheckboxChange = this.handleDefaultSettingsCheckboxChange.bind(this);
this.handleBrandColorChange = this.handleBrandColorChange.bind(this);
this.getParams = this.getParams.bind(this);
}
@ -149,8 +139,8 @@ class ConfigSoapbox extends ImmutablePureComponent {
obj.configs[0].value[0].tuple[1].logo = state.logo;
obj.configs[0].value[0].tuple[1].banner = state.banner;
obj.configs[0].value[0].tuple[1].brandColor = state.brandColor;
obj.configs[0].value[0].tuple[1].extensions.patron = state.patronEnabled;
obj.configs[0].value[0].tuple[1].defaultSettings.autoPlayGif = state.autoPlayGif;
obj.configs[0].value[0].tuple[1].extensions.patron = state.extensions.patron;
obj.configs[0].value[0].tuple[1].defaultSettings.autoPlayGif = state.defaultSettings.autoPlayGif;
obj.configs[0].value[0].tuple[1].copyright = state.copyright;
this.state.homeFooterItems.forEach((f) =>
obj.configs[0].value[0].tuple[1].navlinks.homeFooter.push({ title: f.get('title'), url: f.get('url') })
@ -175,20 +165,45 @@ class ConfigSoapbox extends ImmutablePureComponent {
event.preventDefault();
}
handleCheckboxChange = e => {
this.setState({ [e.target.name]: e.target.checked });
handleExtensionsCheckboxChange = e => {
var extensions = { ...this.state.extensions };
if (e.target.name === 'patron') {
extensions.patron = e.target.value;
}
this.setState({ extensions });
// this.setState({
// extensions: this.state.setIn(['extensions', e.target.name], e.target.value),
// });
}
handleDefaultSettingsCheckboxChange = e => {
var defaultSettings = { ...this.state.defaultSettings };
if (e.target.name === 'autoPlayGif') {
defaultSettings.autoPlayGif = e.target.value;
}
this.setState({ defaultSettings });
// this.setState({
// defaultSettings: this.state.setIn(['defaultSettings', '[e.target.name]'], e.target.value),
// });
}
handleBrandColorChange = e => {
this.setState({ brandColor: e.hex });
this.setState({
brandColor: e.hex,
});
// this.state.setIn(['brandColor'], e.hex);
}
handleTextChange = e => {
this.setState({ [e.target.name]: e.target.value });
// this.state.soapbox.setIn(['{e.target.name}'], e.target.value);
this.setState({
[e.target.name]: e.target.value,
});
}
handlePromoItemsChange = (i, key) => {
return (e) => {
// this.state.soapbox.promoItems.setIn([i, key], e.target.value);
this.setState({
promoItems: this.state.promoItems.setIn([i, key], e.target.value),
});
@ -197,6 +212,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
handleHomeFooterItemsChange = (i, key) => {
return (e) => {
// this.state.soapbox.homeFooterItems.setIn([i, key], e.target.value);
this.setState({
homeFooterItems: this.state.homeFooterItems.setIn([i, key], e.target.value),
});
@ -205,6 +221,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
handleCustomCSSChange = i => {
return (e) => {
// this.state.soapbox.customCssItems.setIn([i], e.target.value);
this.setState({
customCssItems: this.state.customCssItems.setIn([i], e.target.value),
});
@ -215,6 +232,8 @@ class ConfigSoapbox extends ImmutablePureComponent {
const { name } = e.target;
const [file] = e.target.files || [];
const url = file ? URL.createObjectURL(file) : this.state[name];
// this.state.soapbox.setIn([name], url);
// this.state.soapbox.setIn([`${name}_file`], file);
this.setState({
[name]: url,
@ -224,7 +243,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
handleAddPromoPanelItem = () => {
this.setState({
promoItems: this.state.promoItems.concat([
Items: this.state.promoPanel.Items.concat([
ImmutableMap({
icon: '',
text: '',
@ -236,7 +255,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
handleAddHomeFooterItem = () => {
this.setState({
homeFooterItems: this.state.homeFooterItems.concat([
homeFooter: this.state.navlinks.homeFooter.concat([
ImmutableMap({
title: '',
url: '',
@ -247,12 +266,16 @@ class ConfigSoapbox extends ImmutablePureComponent {
handleAddCssItem = () => {
this.setState({
customCssItems: this.state.customCssItems.concat(['']),
customCss: this.state.customCss.concat(['']),
});
}
render() {
const { intl } = this.props;
const { logo, banner, brandColor, extensions, defaultSettings, copyright,
promoPanel, navlinks, customCss } = this.state;
// console.log(navlinks.homeFooter);
// console.log(promoPanel.items);
return (
<Column icon='shield' heading={intl.formatMessage(messages.heading)} backBtnSlim>
@ -261,7 +284,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
<FieldsGroup>
<div className='fields-row'>
<div className='fields-row__column fields-row__column-6'>
{this.state.logo ? (<StillImage src={this.state.logo || ''} />) : (<StillImage src={this.props.logo || ''} />)}
{logo ? (<StillImage src={logo || ''} />) : (<StillImage src={this.props.soapbox.logo || ''} />)}
</div>
<div className='fields-row__column fields-group fields-row__column-6'>
<FileChooserLogo
@ -274,7 +297,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
</div>
<div className='fields-row'>
<div className='fields-row__column fields-row__column-6'>
{this.state.banner ? (<StillImage src={this.state.banner || ''} />) : (<StillImage src={this.props.banner || ''} />)}
{banner ? (<StillImage src={banner || ''} />) : (<StillImage src={this.props.soapbox.banner || ''} />)}
</div>
<div className='fields-row__column fields-group fields-row__column-6'>
<FileChooser
@ -291,7 +314,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
<ColorWithPicker
buttonId='brand_color'
label={<FormattedMessage id='soapbox_settings.fields.brand_color_label' defaultMessage='Brand color' />}
value={this.state.brandColor || '#0482d8'}
value={brandColor || '#0482d8'}
onChange={this.handleBrandColorChange}
/>
</div>
@ -300,16 +323,16 @@ class ConfigSoapbox extends ImmutablePureComponent {
<Checkbox
label={<FormattedMessage id='soapbox_settings.fields.patron_enabled_label' defaultMessage='Patron module' />}
hint={<FormattedMessage id='soapbox_settings.hints.patron_enabled' defaultMessage='Enables display of Patron module. Requires installation of Patron module.' />}
name='patronEnabled'
checked={this.state.patronEnabled ? this.state.patronEnabled : this.props.patronEnabled || false}
onChange={this.handleCheckboxChange}
name='patron'
checked={extensions.patron ? extensions.patron : false}
onChange={this.handleExtensionsCheckboxChange}
/>
<Checkbox
label={<FormattedMessage id='soapbox_settings.fields.auto_play_gif_label' defaultMessage='Auto-play GIFs' />}
hint={<FormattedMessage id='soapbox_settings.hints.auto_play_gif' defaultMessage='Enable auto-playing of GIF files in timeline' />}
name='autoPlayGif'
checked={this.state.autoPlayGif ? this.state.autoPlayGif : this.props.autoPlayGif || false}
onChange={this.handleCheckboxChange}
checked={defaultSettings.autoPlayGif ? defaultSettings.autoPlayGif : false}
onChange={this.handleDefaultSettingsCheckboxChange}
/>
</FieldsGroup>
<FieldsGroup>
@ -317,7 +340,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
name='copyright'
label={intl.formatMessage(messages.copyrightFooterLabel)}
placeholder={intl.formatMessage(messages.copyrightFooterLabel)}
value={this.state.copyright}
value={copyright ? copyright : this.props.soapbox.copyright || ''}
onChange={this.handleTextChange}
/>
</FieldsGroup>
@ -332,7 +355,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
<FormattedMessage id='soapbox_settings.hints.promo_panel_icons' defaultMessage='{ link }' values={{ link: <a target='_blank' href='https://forkaweso.me/Fork-Awesome/icons/'>Soapbox Icons List</a> }} />
</span>
{
this.state.promoItems.map((field, i) => (
promoPanel.items.map((field, i) => (
<div className='row' key={i}>
<TextInput
label={intl.formatMessage(messages.promoItemIcon)}
@ -367,7 +390,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
<FormattedMessage id='soapbox_settings.hints.home_footer_fields' defaultMessage='You can have custom defined links displayed on the footer of your static pages' />
</span>
{
this.state.homeFooterItems.map((field, i) => (
navlinks.homeFooter.map((field, i) => (
<div className='row' key={i}>
<TextInput
label={intl.formatMessage(messages.homeFooterItemLabel)}
@ -397,7 +420,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
<FormattedMessage id='soapbox_settings.hints.custom_css_fields' defaultMessage='You can have custom CSS definitions' />
</span>
{
this.state.customCssItems.map((field, i) => (
customCss.map((field, i) => (
<div className='row' key={i}>
<TextInput
label={intl.formatMessage(messages.customCssLabel)}

@ -2,6 +2,8 @@ import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { acctFull } from 'soapbox/utils/accounts';
import StillImage from 'soapbox/components/still_image';
import VerificationBadge from 'soapbox/components/verification_badge';
import { List as ImmutableList } from 'immutable';
const ProfilePreview = ({ account }) => (
<div className='card h-card'>
@ -16,7 +18,10 @@ const ProfilePreview = ({ account }) => (
<div className='display-name'>
<span style={{ display: 'none' }}>{account.get('username')}</span>
<bdi>
<strong className='emojify p-name'>{account.get('display_name')}</strong>
<strong className='emojify p-name'>
{account.get('display_name')}
{account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified') && <VerificationBadge />}
</strong>
</bdi>
<span>{acctFull(account)}</span>
</div>

@ -4,6 +4,7 @@ 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 { showAlert } from 'soapbox/actions/alerts';
import Column from '../ui/components/column';
import {
SimpleForm,
@ -11,6 +12,7 @@ import {
TextInput,
Checkbox,
FileChooser,
SimpleTextarea,
} from 'soapbox/features/forms';
import ProfilePreview from './components/profile_preview';
import {
@ -20,24 +22,24 @@ import {
import { patchMe } from 'soapbox/actions/me';
import { unescape } from 'lodash';
const MAX_FIELDS = 4; // TODO: Make this dynamic by the instance
const messages = defineMessages({
heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' },
metaFieldLabel: { id: 'edit_profile.fields.meta_fields.label_placeholder', defaultMessage: 'Label' },
metaFieldContent: { id: 'edit_profile.fields.meta_fields.content_placeholder', defaultMessage: 'Content' },
verified: { id: 'edit_profile.fields.verified_display_name', defaultMessage: 'Verified users may not update their display name' },
});
const mapStateToProps = state => {
const me = state.get('me');
return {
account: state.getIn(['accounts', me]),
maxFields: state.getIn(['instance', 'pleroma', 'metadata', 'fields_limits', 'max_fields'], 4),
};
};
// Forces fields to be MAX_SIZE, filling empty values
const normalizeFields = fields => (
ImmutableList(fields).setSize(MAX_FIELDS).map(field =>
// Forces fields to be maxFields size, filling empty values
const normalizeFields = (fields, maxFields) => (
ImmutableList(fields).setSize(maxFields).map(field =>
field ? field : ImmutableMap({ name: '', value: '' })
)
);
@ -57,11 +59,11 @@ class EditProfile extends ImmutablePureComponent {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
account: ImmutablePropTypes.map,
maxFields: PropTypes.number,
};
state = {
isLoading: false,
fields: normalizeFields(Array.from({ length: MAX_FIELDS })),
}
constructor(props) {
@ -69,8 +71,8 @@ class EditProfile extends ImmutablePureComponent {
const initialState = props.account.withMutations(map => {
map.merge(map.get('source'));
map.delete('source');
map.set('fields', normalizeFields(map.get('fields')));
unescapeParams(map, ['display_name', 'note']);
map.set('fields', normalizeFields(map.get('fields'), props.maxFields));
unescapeParams(map, ['display_name', 'bio']);
});
this.state = initialState.toObject();
}
@ -111,8 +113,8 @@ class EditProfile extends ImmutablePureComponent {
const data = this.getParams();
let formData = new FormData();
for (let key in data) {
const shouldAppend = Boolean(data[key]
|| key.startsWith('fields_attributes'));
// Compact the submission. This should probably be done better.
const shouldAppend = Boolean(data[key] || key.startsWith('fields_attributes'));
if (shouldAppend) formData.append(key, data[key] || '');
}
return formData;
@ -122,6 +124,7 @@ class EditProfile extends ImmutablePureComponent {
const { dispatch } = this.props;
dispatch(patchMe(this.getFormdata())).then(() => {
this.setState({ isLoading: false });
dispatch(showAlert('', 'Profile saved!'));
}).catch((error) => {
this.setState({ isLoading: false });
});
@ -157,7 +160,8 @@ class EditProfile extends ImmutablePureComponent {
}
render() {
const { intl } = this.props;
const { intl, maxFields, account } = this.props;
const verified = account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified');
return (
<Column icon='user' heading={intl.formatMessage(messages.heading)} backBtnSlim>
@ -165,16 +169,22 @@ class EditProfile extends ImmutablePureComponent {
<fieldset disabled={this.state.isLoading}>
<FieldsGroup>
<TextInput
className={verified ? 'disabled' : ''}
label={<FormattedMessage id='edit_profile.fields.display_name_label' defaultMessage='Display name' />}
name='display_name'
value={this.state.display_name}
onChange={this.handleTextChange}
disabled={verified}
hint={verified && intl.formatMessage(messages.verified)}
/>
<TextInput
<SimpleTextarea
label={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
name='note'
autoComplete='off'
value={this.state.note}
wrap='hard'
onChange={this.handleTextChange}
rows={3}
/>
<div className='fields-row'>
<div className='fields-row__column fields-row__column-6'>
@ -215,7 +225,7 @@ class EditProfile extends ImmutablePureComponent {
<div className='input with_block_label'>
<label><FormattedMessage id='edit_profile.fields.meta_fields_label' defaultMessage='Profile metadata' /></label>
<span className='hint'>
<FormattedMessage id='edit_profile.hints.meta_fields' defaultMessage='You can have up to {count, plural, one {# item} other {# items}} displayed as a table on your profile' values={{ count: MAX_FIELDS }} />
<FormattedMessage id='edit_profile.hints.meta_fields' defaultMessage='You can have up to {count, plural, one {# item} other {# items}} displayed as a table on your profile' values={{ count: maxFields }} />
</span>
{
this.state.fields.map((field, i) => (

@ -4,16 +4,55 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import Column from '../ui/components/column';
import { fetchFilters } from '../../actions/filters';
import { fetchFilters, createFilter, deleteFilter } from '../../actions/filters';
import ScrollableList from '../../components/scrollable_list';
import Button from 'soapbox/components/button';
import {
SimpleForm,
SimpleInput,
FieldsGroup,
SelectDropdown,
Checkbox,
} from 'soapbox/features/forms';
import { showAlert } from 'soapbox/actions/alerts';
import Icon from 'soapbox/components/icon';
import ColumnSubheading from '../ui/components/column_subheading';
const messages = defineMessages({
heading: { id: 'column.filters', defaultMessage: 'Muted words' },
subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' },
keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' },
expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' },
expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' },
home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' },
public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' },
notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' },
conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' },
drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' },
drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' },
whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' },
whole_word_hint: { id: 'column.filters.whole_word_hint', defaultMessage: 'When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word' },
add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' },
create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' },
delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' },
subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' },
delete: { id: 'column.filters.delete', defaultMessage: 'Delete' },
});
const expirations = {
null: 'Never',
// 3600: '30 minutes',
// 21600: '1 hour',
// 43200: '12 hours',
// 86400 : '1 day',
// 604800: '1 week',
};
const mapStateToProps = state => ({
filters: state.get('filters'),
});
export default @connect(mapStateToProps)
@injectIntl
class Filters extends ImmutablePureComponent {
@ -24,17 +63,206 @@ class Filters extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
};
state = {
phrase: '',
expires_at: '',
home_timeline: true,
public_timeline: false,
notifications: false,
conversations: false,
irreversible: false,
whole_word: true,
}
componentDidMount() {
this.props.dispatch(fetchFilters());
}
handleInputChange = e => {
this.setState({ [e.target.name]: e.target.value });
}
handleSelectChange = e => {
this.setState({ [e.target.name]: e.target.value });
}
handleCheckboxChange = e => {
this.setState({ [e.target.name]: e.target.checked });
}
handleAddNew = e => {
e.preventDefault();
const { intl, dispatch } = this.props;
const { phrase, whole_word, expires_at, irreversible } = this.state;
const { home_timeline, public_timeline, notifications, conversations } = this.state;
let context = [];
if (home_timeline) {
context.push('home');
};
if (public_timeline) {
context.push('public');
};
if (notifications) {
context.push('notifications');
};
if (conversations) {
context.push('thread');
};
dispatch(createFilter(phrase, expires_at, context, whole_word, irreversible)).then(response => {
return dispatch(fetchFilters());
}).catch(error => {
dispatch(showAlert('', intl.formatMessage(messages.create_error)));
});
}
handleFilterDelete = e => {
const { intl, dispatch } = this.props;
dispatch(deleteFilter(e.currentTarget.dataset.value)).then(response => {
return dispatch(fetchFilters());
}).catch(error => {
dispatch(showAlert('', intl.formatMessage(messages.delete_error)));
});
}
render() {
const { intl } = this.props;
const { intl, filters } = this.props;
const emptyMessage = <FormattedMessage id='empty_column.filters' defaultMessage="You haven't created any muted words yet." />;
return (
<Column icon='filter' heading={intl.formatMessage(messages.heading)} backBtnSlim>
{emptyMessage}
<Column className='filter-settings-panel' icon='filter' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<ColumnSubheading text={intl.formatMessage(messages.subheading_add_new)} />
<SimpleForm>
<div className='filter-settings-panel'>
<fieldset disabled={false}>
<FieldsGroup>
<div className='two-col'>
<SimpleInput
label={intl.formatMessage(messages.keyword)}
required
type='text'
name='phrase'
onChange={this.handleInputChange}
/>
<div className='input with_label required'>
<SelectDropdown
label={intl.formatMessage(messages.expires)}
hint={intl.formatMessage(messages.expires_hint)}
items={expirations}
defaultValue={expirations.never}
onChange={this.handleSelectChange}
/>
</div>
</div>
</FieldsGroup>
<FieldsGroup>
<label className='checkboxes required'>
<FormattedMessage id='filters.context_header' defaultMessage='Filter contexts' />
</label>
<span className='hint'>
<FormattedMessage id='filters.context_hint' defaultMessage='One or multiple contexts where the filter should apply' />
</span>
<div className='two-col'>
<Checkbox
label={intl.formatMessage(messages.home_timeline)}
name='home_timeline'
checked={this.state.home_timeline}
onChange={this.handleCheckboxChange}
/>
<Checkbox
label={intl.formatMessage(messages.public_timeline)}
name='public_timeline'
checked={this.state.public_timeline}
onChange={this.handleCheckboxChange}
/>
<Checkbox
label={intl.formatMessage(messages.notifications)}
name='notifications'
checked={this.state.notifications}
onChange={this.handleCheckboxChange}
/>
<Checkbox
label={intl.formatMessage(messages.conversations)}
name='conversations'
checked={this.state.conversations}
onChange={this.handleCheckboxChange}
/>
</div>
</FieldsGroup>
<FieldsGroup>
<Checkbox
label={intl.formatMessage(messages.drop_header)}
hint={intl.formatMessage(messages.drop_hint)}
name='irreversible'
checked={this.state.irreversible}
onChange={this.handleCheckboxChange}
/>
<Checkbox
label={intl.formatMessage(messages.whole_word_header)}
hint={intl.formatMessage(messages.whole_word_hint)}
name='whole_word'
checked={this.state.whole_word}
onChange={this.handleCheckboxChange}
/>
</FieldsGroup>
</fieldset>
<Button className='button button-primary setup' text={intl.formatMessage(messages.add_new)} onClick={this.handleAddNew} />
<ColumnSubheading text={intl.formatMessage(messages.subheading_filters)} />
<ScrollableList
scrollKey='filters'
emptyMessage={emptyMessage}
>
{filters.map((filter, i) => (
<div key={i} className='filter__container'>
<div className='filter__details'>
<div className='filter__phrase'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_phrase_label' defaultMessage='Keyword or phrase:' /></span>
<span className='filter__list-value'>{filter.get('phrase')}</span>
</div>
<div className='filter__contexts'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_context_label' defaultMessage='Filter contexts:' /></span>
<span className='filter__list-value'>
{filter.get('context').map((context, i) => (
<span key={i} className='context'>{context}</span>
))}
</span>
</div>
<div className='filter__details'>
<span className='filter__list-label'><FormattedMessage id='filters.filters_list_details_label' defaultMessage='Filter settings:' /></span>
<span className='filter__list-value'>
{filter.get('irreversible') ?
<span><FormattedMessage id='filters.filters_list_drop' defaultMessage='Drop' /></span> :
<span><FormattedMessage id='filters.filters_list_hide' defaultMessage='Hide' /></span>
}
{filter.get('whole_word') &&
<span><FormattedMessage id='filters.filters_list_whole-word' defaultMessage='Whole word' /></span>
}
</span>
</div>
</div>
<div className='filter__delete' role='button' tabIndex='0' onClick={this.handleFilterDelete} data-value={filter.get('id')} aria-label={intl.formatMessage(messages.delete)}>
<Icon className='filter__delete-icon' id='times' size={40} />
<span className='filter__delete-label'><FormattedMessage id='filters.filters_list_delete' defaultMessage='Delete' /></span>
</div>
</div>
))}
</ScrollableList>
</div>
</SimpleForm>
</Column>
);
}

@ -42,7 +42,7 @@ InputContainer.propTypes = {
extraClass: PropTypes.string,
};
export const LabelInputContainer = ({ label, children, ...props }) => {
export const LabelInputContainer = ({ label, hint, children, ...props }) => {
const [id] = useState(uuidv4());
const childrenWithProps = React.Children.map(children, child => (
React.cloneElement(child, { id: id, key: id })
@ -54,12 +54,14 @@ export const LabelInputContainer = ({ label, children, ...props }) => {
<div className='label_input__wrapper'>
{childrenWithProps}
</div>
{hint && <span className='hint'>{hint}</span>}
</div>
);
};
LabelInputContainer.propTypes = {
label: FormPropTypes.label.isRequired,
hint: PropTypes.node,
children: PropTypes.node,
};
@ -321,11 +323,12 @@ export class SelectDropdown extends ImmutablePureComponent {
static propTypes = {
label: FormPropTypes.label,
hint: PropTypes.node,
items: PropTypes.object.isRequired,
}
render() {
const { label, items, ...props } = this.props;
const { label, hint, items, ...props } = this.props;
const optionElems = Object.keys(items).map(item => (
<option key={item} value={item}>{items[item]}</option>
@ -334,7 +337,7 @@ export class SelectDropdown extends ImmutablePureComponent {
const selectElem = <select {...props}>{optionElems}</select>;
return label ? (
<LabelInputContainer label={label}>{selectElem}</LabelInputContainer>
<LabelInputContainer label={label} hint={hint}>{selectElem}</LabelInputContainer>
) : selectElem;
}

@ -18,8 +18,6 @@ class ColumnSettings extends React.PureComponent {
return (
<div>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reposts' />} />
</div>
@ -27,6 +25,10 @@ class ColumnSettings extends React.PureComponent {
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'direct']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_direct' defaultMessage='Show direct messages' />} />
</div>
</div>
);
}

@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import ClearColumnButton from './clear_column_button';
import SettingToggle from './setting_toggle';
import MultiSettingToggle from './multi_setting_toggle';
export default class ColumnSettings extends React.PureComponent {
@ -18,15 +19,24 @@ export default class ColumnSettings extends React.PureComponent {
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() {
const { settings, pushSettings, onChange, onClear } = this.props;
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 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 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 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} />
</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'>
<span id='notifications-filter-bar' className='column-settings__section'>
<FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
</span>
<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', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
</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>
);
}
}

@ -6,25 +6,85 @@ import { Link } from 'react-router-dom';
import LoginForm from 'soapbox/features/auth_login/components/login_form';
import SiteLogo from './site_logo';
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
import { logIn } from 'soapbox/actions/auth';
import { fetchMe } from 'soapbox/actions/me';
import PropTypes from 'prop-types';
import OtpAuthForm from 'soapbox/features/auth_login/components/otp_auth_form';
import IconButton from 'soapbox/components/icon_button';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
const mapStateToProps = state => ({
me: state.get('me'),
instance: state.get('instance'),
isLoading: false,
});
export default @connect(mapStateToProps)
@injectIntl
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 = {
me: SoapboxPropTypes.me,
instance: ImmutablePropTypes.map,
}
state = {
mfa_auth_needed: false,
mfa_token: '',
}
render() {
const { me, instance } = this.props;
const { me, instance, isLoading, intl } = this.props;
const { mfa_auth_needed, mfa_token } = this.state;
return (
<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='nav-left'>
<Link className='brand' to='/'>
@ -38,7 +98,7 @@ class Header extends ImmutablePureComponent {
<div className='hidden-sm'>
{me
? <Link className='nav-link nav-button webapp-btn' to='/'>Back to {instance.get('title')}</Link>
: <LoginForm />
: <LoginForm handleSubmit={this.handleSubmit} isLoading={isLoading} />
}
</div>
<div className='visible-sm'>

@ -18,6 +18,14 @@ class ColumnSettings extends React.PureComponent {
return (
<div>
<div className='column-settings__row'>
<SettingToggle prefix='public_timeline' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show reposts' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='public_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
</div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} />
</div>

@ -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' },
});
const mapStateToProps = state => ({
backup_codes: state.getIn(['auth', 'backup_codes', 'codes']),
settings: getSettings(state),
tokens: state.getIn(['auth', 'tokens']),
});
export default @injectIntl
export default @connect(mapStateToProps)
@injectIntl
class SecurityForm extends ImmutablePureComponent {
static propTypes = {
@ -68,6 +84,7 @@ class SecurityForm extends ImmutablePureComponent {
<Column icon='lock' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<ChangeEmailForm />
<ChangePasswordForm />
<SetUpMfa />
<AuthTokenList />
<DeactivateAccount />
</Column>
@ -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 (
<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)
@injectIntl

@ -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>
);
}
}

@ -311,7 +311,9 @@ class ActionBar extends React.PureComponent {
onMouseLeave={this.handleLikeButtonLeave}
ref={this.setRef}
>
{ emojiSelectorVisible &&
<EmojiSelector onReact={this.handleReactClick} visible={emojiSelectorVisible} />
}
<IconButton
className='star-icon'
animate

@ -0,0 +1,59 @@
import React from 'react';
import Icon from 'soapbox/components/icon';
import { NavLink } from 'react-router-dom';
export default class FeaturesPanel extends React.PureComponent {
render() {
return (
<div className='wtf-panel promo-panel'>
<div className='promo-panel__container'>
<div className='promo-panel-item'>
<NavLink className='promo-panel-item__btn' to='/settings/profile'>
<Icon id='user' className='promo-panel-item__icon' fixedWidth />
Edit Profile
</NavLink>
</div>
<div className='promo-panel-item'>
<NavLink className='promo-panel-item__btn' to='/messages'>
<Icon id='envelope' className='promo-panel-item__icon' fixedWidth />
Messages
</NavLink>
</div>
<div className='promo-panel-item'>
<NavLink className='promo-panel-item__btn' to='/bookmarks'>
<Icon id='bookmark' className='promo-panel-item__icon' fixedWidth />
Bookmarks
</NavLink>
</div>
<div className='promo-panel-item'>
<NavLink className='promo-panel-item__btn' to='/lists'>
<Icon id='list' className='promo-panel-item__icon' fixedWidth />
Lists
</NavLink>
</div>
<div className='promo-panel-item'>
<NavLink className='promo-panel-item__btn' to='/auth/edit'>
<Icon id='lock' className='promo-panel-item__icon' fixedWidth />
Security
</NavLink>
</div>
<div className='promo-panel-item'>
<NavLink className='promo-panel-item__btn' to='/settings/preferences'>
<Icon id='cog' className='promo-panel-item__icon' fixedWidth />
Preferences
</NavLink>
</div>
</div>
</div>
);
}
}

@ -59,6 +59,7 @@ class ProfileInfoPanel extends ImmutablePureComponent {
const fields = account.get('fields');
const displayNameHtml = { __html: account.get('display_name_html') };
const memberSinceDate = intl.formatDate(account.get('created_at'), { month: 'long', year: 'numeric' });
const verified = account.get('pleroma').get('tags').includes('verified');
return (
<div className='profile-info-panel'>
@ -67,7 +68,7 @@ class ProfileInfoPanel extends ImmutablePureComponent {
<div className='profile-info-panel-content__name'>
<h1>
<span dangerouslySetInnerHTML={displayNameHtml} />
{account.get('is_verified') && <VerificationBadge />}
{verified && <VerificationBadge />}
{badge}
<small>@{acctFull(account)} {lockedIcon}</small>
</h1>

@ -10,6 +10,8 @@ import Avatar from 'soapbox/components/avatar';
import { shortNumberFormat } from 'soapbox/utils/numbers';
import { acctFull } from 'soapbox/utils/accounts';
import StillImage from 'soapbox/components/still_image';
import VerificationBadge from 'soapbox/components/verification_badge';
import { List as ImmutableList } from 'immutable';
class UserPanel extends ImmutablePureComponent {
@ -24,6 +26,7 @@ class UserPanel extends ImmutablePureComponent {
if (!account) return null;
const displayNameHtml = { __html: account.get('display_name_html') };
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
const verified = account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified');
return (
<div className='user-panel'>
@ -45,6 +48,7 @@ class UserPanel extends ImmutablePureComponent {
<h1>
<Link to={`/@${account.get('acct')}`}>
<span className='user-panel__account__name' dangerouslySetInnerHTML={displayNameHtml} />
{verified && <VerificationBadge />}
<small className='user-panel__account__username'>@{acctFull(account)}</small>
</Link>
</h1>

@ -5,9 +5,10 @@ import { createSelector } from 'reselect';
import { debounce } from 'lodash';
import { dequeueTimeline } from 'soapbox/actions/timelines';
import { scrollTopTimeline } from '../../../actions/timelines';
import { getSettings } from 'soapbox/actions/settings';
const makeGetStatusIds = () => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
(state, { type }) => getSettings(state).get(type, ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
(state) => state.get('statuses'),
(state) => state.get('me'),
@ -23,7 +24,11 @@ const makeGetStatusIds = () => createSelector([
}
if (columnSettings.getIn(['shows', 'reply']) === false) {
showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
showStatus = showStatus && (statusForId.get('in_reply_to_id') === null);
}
if (columnSettings.getIn(['shows', 'direct']) === false) {
showStatus = showStatus && (statusForId.get('visibility') !== 'direct');
}
return showStatus;

@ -22,8 +22,8 @@ import { openModal } from '../../actions/modal';
import { WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
import TabsBar from './components/tabs_bar';
import WhoToFollowPanel from './components/who_to_follow_panel';
import LinkFooter from './components/link_footer';
import FeaturesPanel from './components/features_panel';
import ProfilePage from 'soapbox/pages/profile_page';
// import GroupsPage from 'soapbox/pages/groups_page';
// import GroupPage from 'soapbox/pages/group_page';
@ -75,6 +75,7 @@ import {
ConfigSoapbox,
PasswordReset,
SecurityForm,
MfaForm,
} from './util/async-components';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
@ -137,19 +138,16 @@ const LAYOUT = {
},
DEFAULT: {
LEFT: [
<WhoToFollowPanel key='0' />,
<LinkFooter key='1' />,
],
RIGHT: [
// <GroupSidebarPanel key='0' />
<FeaturesPanel key='0' />,
],
},
STATUS: {
TOP: null,
LEFT: null,
RIGHT: [
// <GroupSidebarPanel key='0' />,
<WhoToFollowPanel key='1' />,
<LinkFooter key='2' />,
],
},
@ -197,12 +195,13 @@ class SwitchingColumnsArea extends React.PureComponent {
<Switch>
<WrappedRoute path='/auth/sign_in' component={LoginPage} publicRoute exact />
<WrappedRoute path='/auth/reset_password' component={PasswordReset} publicRoute exact />
<WrappedRoute path='/auth/edit' component={SecurityForm} exact />
<WrappedRoute path='/auth/edit' layout={LAYOUT.DEFAULT} component={SecurityForm} exact />
<WrappedRoute path='/auth/mfa' layout={LAYOUT.DEFAULT} component={MfaForm} exact />
<WrappedRoute path='/' exact page={HomePage} component={HomeTimeline} content={children} />
<WrappedRoute path='/timeline/local' exact page={HomePage} component={CommunityTimeline} content={children} />
<WrappedRoute path='/timeline/fediverse' exact page={HomePage} component={PublicTimeline} content={children} />
<WrappedRoute path='/messages' component={DirectTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
<WrappedRoute path='/messages' layout={LAYOUT.DEFAULT} component={DirectTimeline} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
{/*
<WrappedRoute path='/groups' exact page={GroupsPage} component={Groups} content={children} componentParams={{ activeTab: 'featured' }} />

@ -193,3 +193,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');
}

@ -4,6 +4,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import WhoToFollowPanel from '../features/ui/components/who_to_follow_panel';
import TrendsPanel from '../features/ui/components/trends_panel';
import LinkFooter from '../features/ui/components/link_footer';
import FeaturesPanel from '../features/ui/components/features_panel';
import PromoPanel from '../features/ui/components/promo_panel';
import UserPanel from '../features/ui/components/user_panel';
import FundingPanel from '../features/ui/components/funding_panel';
@ -42,8 +43,6 @@ class HomePage extends ImmutablePureComponent {
<div className='columns-area__panels__pane__inner'>
<UserPanel accountId={me} />
{hasPatron && <FundingPanel />}
<PromoPanel />
<LinkFooter />
</div>
</div>
@ -69,6 +68,9 @@ class HomePage extends ImmutablePureComponent {
{/* <GroupSidebarPanel /> */}
{features.trends && <TrendsPanel limit={3} />}
{features.suggestions && <WhoToFollowPanel limit={5} />}
<FeaturesPanel />
<PromoPanel />
<LinkFooter />
</div>
</div>
</div>

@ -12,6 +12,9 @@ const nodeinfoToInstance = nodeinfo => {
account_activation_required: nodeinfo.getIn(['metadata', 'accountActivationRequired']),
features: nodeinfo.getIn(['metadata', 'features']),
federation: nodeinfo.getIn(['metadata', 'federation']),
fields_limits: ImmutableMap({
max_fields: nodeinfo.getIn(['metadata', 'fieldsLimits', 'maxFields']),
}),
}),
}),
});
@ -31,9 +34,9 @@ const initialState = ImmutableMap({
export default function instance(state = initialState, action) {
switch(action.type) {
case INSTANCE_FETCH_SUCCESS:
return initialState.merge(fromJS(action.instance));
return initialState.mergeDeep(fromJS(action.instance));
case NODEINFO_FETCH_SUCCESS:
return nodeinfoToInstance(fromJS(action.nodeinfo)).merge(state);
return nodeinfoToInstance(fromJS(action.nodeinfo)).mergeDeep(state);
default:
return state;
}

@ -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;
}

@ -13,7 +13,7 @@
&:active,
&:focus {
.card__bar {
background: var(--brand-color--med);
background: var(--foreground-color);
}
}
}
@ -92,6 +92,11 @@
overflow: hidden;
text-overflow: ellipsis;
}
bdi,
span.verified-icon {
display: inline-block;
}
}
}
}
@ -584,7 +589,7 @@ a .account__avatar {
}
.account__section-headline {
background: var(--accent-color--faint);
background: var(--foreground-color);
button,
a {

@ -72,3 +72,5 @@
@import 'components/video-player';
@import 'components/audio-player';
@import 'components/profile_hover_card';
@import 'components/filters';
@import 'components/mfa_form';

@ -19,7 +19,7 @@
overflow: hidden;
height: 350px;
position: relative;
background: var(--accent-color--med);
background: var(--accent-color--faint);
@media screen and (max-width: 895px) {height: 225px;}
&--none {height: 125px;}
@ -58,7 +58,7 @@
min-height: 74px;
width: 100%;
position: relative;
background: var(--accent-color--med);
background: var(--background-color);
@media (min-width: 895px) {height: 74px;}
}

@ -205,7 +205,7 @@
}
.column-back-button {
background: var(--accent-color--med);
background: var(--accent-color--faint);
color: var(--highlight-text-color);
cursor: pointer;
flex: 0 0 auto;

@ -0,0 +1,101 @@
.filter-settings-panel {
h1 {
font-size: 18px;
line-height: 1.25;
color: var(--primary-text-color);
font-weight: 400;
margin: 20px auto;
}
.item-list article {
border-bottom: 1px solid var(--primary-text-color--faint);
&:last-child {
border-bottom: 0;
}
}
.fields-group .two-col {
display: flex;
align-items: flex-start;
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
div.input {
width: 45%;
margin-right: 20px;
.label_input {
width: 100%;
}
}
@media(max-width: 485px) {
div.input {
width: 100%;
margin-right: 5px;
.label_input {
width: auto;
}
}
}
}
.filter__container {
padding: 20px;
display: flex;
justify-content: space-between;
font-size: 14px;
.filter__phrase,
.filter__contexts,
.filter__details {
padding: 5px 0;
}
span.filter__list-label {
padding-right: 5px;
color: var(--primary-text-color--faint);
}
span.filter__list-value span {
padding-right: 5px;
text-transform: capitalize;
&::after {
content: ',';
}
&:last-of-type {
&::after {
content: '';
}
}
}
.filter__delete {
display: flex;
margin: 10px;
align-items: baseline;
cursor: pointer;
height: 20px;
span.filter__delete-label {
color: var(--primary-text-color--faint);
font-size: 14px;
font-weight: 800;
}
.filter__delete-icon {
background: none;
color: var(--primary-text-color--faint);
padding: 0 5px;
margin: 0 auto;
font-size: 16px;
}
}
}
}

@ -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;
}
}

@ -32,7 +32,7 @@
height: 24px;
padding: 0;
border-radius: 30px;
background-color: var(--foreground-color);
background-color: hsla(var(--brand-color_h), var(--brand-color_s), var(--brand-color_l), 0.35);
transition: background-color 0.2s ease;
}

@ -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;}

@ -9,6 +9,10 @@
.react-toggle {
vertical-align: middle;
&-track {
background-color: var(--foreground-color);
}
&-track-check,
&-track-x {
display: flex;

@ -4,12 +4,21 @@
width: 265px;
flex-direction: column;
.user-panel__account__name {
display: inline;
}
.verified-icon {
opacity: 1;
}
&,
.user-panel__account__name,
.user-panel__account__username {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--primary-text-color--faint);
}
&__header {
@ -49,13 +58,13 @@
&__meta {
display: block;
padding: 6px 20px 17px;
opacity: 0.6;
// opacity: 0.6;
}
&__account {
a {
text-decoration: none;
color: var(--primary-text-color);
color: var(--primary-text-color--faint);
}
&__name {
@ -63,7 +72,7 @@
font-size: 20px;
font-weight: bold;
line-height: 24px;
color: var(--primary-text-color);
color: var(--primary-text-color--faint);
}
&:hover & {
@ -96,7 +105,7 @@
a {
text-decoration: none;
color: var(--primary-text-color);
color: var(--primary-text-color--faint);
&:hover {
opacity: 0.8;
@ -106,7 +115,7 @@
&__value {
display: block;
width: 100%;
color: var(--primary-text-color);
color: var(--primary-text-color--faint);
font-size: 20px;
font-weight: 800;
line-height: 24px;

@ -8,6 +8,10 @@
box-sizing: border-box;
background: var(--foreground-color);
&:first-child {
margin-top: 0;
}
&:not(:last-of-type) {
margin-bottom: 10px;
}

@ -169,7 +169,7 @@ body.admin {
}
.funding-panel {
margin: 20px 0;
margin-top: 15px;
strong {
font-weight: bold;

@ -342,6 +342,15 @@ code {
}
}
input[type=text][disabled],
input[type=number][disabled],
input[type=email][disabled],
input[type=password][disabled],
textarea[disabled] {
color: var(--primary-text-color--faint);
border-color: var(--primary-text-color--faint);
}
.input.field_with_errors {
label {
color: lighten($error-red, 12%);

@ -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(

@ -654,6 +654,7 @@
&::after {
bottom: -1px;
border-color: transparent transparent var(--foreground-color);
}
}
}

@ -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-color": "^2.18.1",

@ -9335,6 +9335,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"

Loading…
Cancel
Save