Merge branch 'admin-dashboard' into 'develop'

Soapbox admin

See merge request soapbox-pub/soapbox-fe!403
This commit is contained in:
Alex Gleason 2020-12-30 02:44:42 +00:00
commit e7bb78712a
20 changed files with 562 additions and 113 deletions

View file

@ -8,6 +8,18 @@ export const ADMIN_REPORTS_FETCH_REQUEST = 'ADMIN_REPORTS_FETCH_REQUEST';
export const ADMIN_REPORTS_FETCH_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS'; export const ADMIN_REPORTS_FETCH_SUCCESS = 'ADMIN_REPORTS_FETCH_SUCCESS';
export const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL'; export const ADMIN_REPORTS_FETCH_FAIL = 'ADMIN_REPORTS_FETCH_FAIL';
export const ADMIN_USERS_FETCH_REQUEST = 'ADMIN_USERS_FETCH_REQUEST';
export const ADMIN_USERS_FETCH_SUCCESS = 'ADMIN_USERS_FETCH_SUCCESS';
export const ADMIN_USERS_FETCH_FAIL = 'ADMIN_USERS_FETCH_FAIL';
export const ADMIN_USERS_DELETE_REQUEST = 'ADMIN_USERS_DELETE_REQUEST';
export const ADMIN_USERS_DELETE_SUCCESS = 'ADMIN_USERS_DELETE_SUCCESS';
export const ADMIN_USERS_DELETE_FAIL = 'ADMIN_USERS_DELETE_FAIL';
export const ADMIN_USERS_APPROVE_REQUEST = 'ADMIN_USERS_APPROVE_REQUEST';
export const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS';
export const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL';
export function updateAdminConfig(params) { export function updateAdminConfig(params) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST }); dispatch({ type: ADMIN_CONFIG_UPDATE_REQUEST });
@ -33,3 +45,42 @@ export function fetchReports(params) {
}); });
}; };
} }
export function fetchUsers(params) {
return (dispatch, getState) => {
dispatch({ type: ADMIN_USERS_FETCH_REQUEST, params });
return api(getState)
.get('/api/pleroma/admin/users', { params })
.then(({ data }) => {
dispatch({ type: ADMIN_USERS_FETCH_SUCCESS, data, params });
}).catch(error => {
dispatch({ type: ADMIN_USERS_FETCH_FAIL, error, params });
});
};
}
export function deleteUsers(nicknames) {
return (dispatch, getState) => {
dispatch({ type: ADMIN_USERS_DELETE_REQUEST, nicknames });
return api(getState)
.delete('/api/pleroma/admin/users', { data: { nicknames } })
.then(({ data: nicknames }) => {
dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames });
}).catch(error => {
dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, nicknames });
});
};
}
export function approveUsers(nicknames) {
return (dispatch, getState) => {
dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, nicknames });
return api(getState)
.patch('/api/pleroma/admin/users/approve', { nicknames })
.then(({ data: { users } }) => {
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, nicknames });
}).catch(error => {
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, nicknames });
});
};
}

View file

@ -1,21 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { shortNumberFormat } from 'soapbox/utils/numbers';
const IconWithBadge = ({ id, count, className }) => {
if (count < 1) return null;
return (
<i className='icon-with-badge'>
{count > 0 && <i className='icon-with-badge__badge'>{shortNumberFormat(count)}</i>}
</i>
);
};
IconWithBadge.propTypes = {
id: PropTypes.string.isRequired,
count: PropTypes.number.isRequired,
className: PropTypes.string,
};
export default IconWithBadge;

View file

@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import Icon from 'soapbox/components/icon';
import { shortNumberFormat } from 'soapbox/utils/numbers';
const IconWithCounter = ({ icon, count, fixedWidth }) => {
return (
<div className='icon-with-counter'>
<Icon id={icon} fixedWidth={fixedWidth} />
{count > 0 && <i className='icon-with-counter__counter'>
{shortNumberFormat(count)}
</i>}
</div>
);
};
IconWithCounter.propTypes = {
icon: PropTypes.string.isRequired,
count: PropTypes.number.isRequired,
fixedWidth: PropTypes.bool,
};
export default IconWithCounter;

View file

@ -0,0 +1,83 @@
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
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 IconButton from 'soapbox/components/icon_button';
import ScrollableList from 'soapbox/components/scrollable_list';
import { fetchUsers, deleteUsers, approveUsers } from 'soapbox/actions/admin';
const messages = defineMessages({
heading: { id: 'column.admin.awaiting_approval', defaultMessage: 'Awaiting Approval' },
emptyMessage: { id: 'admin.awaiting_approval.empty_message', defaultMessage: 'There is nobody waiting for approval. When a new user signs up, you can review them here.' },
});
const mapStateToProps = state => {
const nicknames = state.getIn(['admin', 'awaitingApproval']);
return {
users: nicknames.toList().map(nickname => state.getIn(['admin', 'users', nickname])),
};
};
export default @connect(mapStateToProps)
@injectIntl
class AwaitingApproval extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
users: ImmutablePropTypes.list.isRequired,
};
state = {
isLoading: true,
}
componentDidMount() {
const { dispatch } = this.props;
const params = { page: 1, filters: 'local,need_approval' };
dispatch(fetchUsers(params))
.then(() => this.setState({ isLoading: false }))
.catch(() => {});
}
handleApprove = nickname => {
const { dispatch } = this.props;
return e => {
dispatch(approveUsers([nickname]));
};
}
handleReject = nickname => {
const { dispatch } = this.props;
return e => {
dispatch(deleteUsers([nickname]));
};
}
render() {
const { intl, users } = this.props;
const { isLoading } = this.state;
return (
<Column icon='user' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<ScrollableList isLoading={isLoading} showLoading={isLoading} scrollKey='awaiting-approval' emptyMessage={intl.formatMessage(messages.emptyMessage)}>
{users.map((user, i) => (
<div className='unapproved-account' key={user.get('id')}>
<div className='unapproved-account__bio'>
<div className='unapproved-account__nickname'>@{user.get('nickname')}</div>
<blockquote className='unapproved-account__reason'>{user.get('registration_reason')}</blockquote>
</div>
<div className='unapproved-account__actions'>
<IconButton icon='check' onClick={this.handleApprove(user.get('nickname'))} />
<IconButton icon='close' onClick={this.handleReject(user.get('nickname'))} />
</div>
</div>
))}
</ScrollableList>
</Column>
);
}
}

View file

@ -0,0 +1,98 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Icon from 'soapbox/components/icon';
import IconWithCounter from 'soapbox/components/icon_with_counter';
import { NavLink } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = (state, props) => ({
instance: state.get('instance'),
approvalCount: state.getIn(['admin', 'awaitingApproval']).count(),
reportsCount: state.getIn(['admin', 'open_report_count']),
});
export default @connect(mapStateToProps)
class AdminNav extends React.PureComponent {
static propTypes = {
instance: ImmutablePropTypes.map.isRequired,
approvalCount: PropTypes.number,
reportsCount: PropTypes.number,
};
render() {
const { instance, approvalCount, reportsCount } = this.props;
return (
<>
<div className='wtf-panel promo-panel'>
<div className='promo-panel__container'>
<NavLink className='promo-panel-item' to='/admin'>
<Icon id='tachometer' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.dashboard' defaultMessage='Dashboard' />
</NavLink>
<a className='promo-panel-item' href='/pleroma/admin/#/reports/index' target='_blank'>
<IconWithCounter icon='gavel' count={reportsCount} fixedWidth />
<FormattedMessage id='admin_nav.reports' defaultMessage='Reports' />
</a>
{instance.get('approval_required') && (
<NavLink className='promo-panel-item' to='/admin/approval'>
<IconWithCounter icon='user' count={approvalCount} fixedWidth />
<FormattedMessage id='admin_nav.awaiting_approval' defaultMessage='Awaiting Approval' />
</NavLink>
)}
{!instance.get('registrations') && (
{/* <NavLink className='promo-panel-item' to='#'>
<Icon id='envelope' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.invites' defaultMessage='Invites' />
</NavLink> */}
)}
{/* <NavLink className='promo-panel-item' to='#'>
<Icon id='group' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.registration' defaultMessage='Registration' />
</NavLink> */}
</div>
</div>
{/* <div className='wtf-panel promo-panel'>
<div className='promo-panel__container'>
<NavLink className='promo-panel-item' to='#'>
<Icon id='info-circle' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.site_profile' defaultMessage='Site Profile' />
</NavLink>
<NavLink className='promo-panel-item' to='#'>
<Icon id='paint-brush' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.branding' defaultMessage='Branding' />
</NavLink>
<NavLink className='promo-panel-item' to='#'>
<Icon id='bars' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.menus' defaultMessage='Menus' />
</NavLink>
<NavLink className='promo-panel-item' to='#'>
<Icon id='file-o' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.pages' defaultMessage='Pages' />
</NavLink>
</div>
</div>
<div className='wtf-panel promo-panel'>
<div className='promo-panel__container'>
<NavLink className='promo-panel-item' to='#'>
<Icon id='fediverse' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.mrf' defaultMessage='Federation' />
</NavLink>
<NavLink className='promo-panel-item' to='#'>
<Icon id='filter' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.filtering' defaultMessage='Filtering' />
</NavLink>
<a className='promo-panel-item' href='/pleroma/admin/#/settings/index' target='_blank'>
<Icon id='code' className='promo-panel-item__icon' fixedWidth />
<FormattedMessage id='admin_nav.advanced' defaultMessage='Advanced' />
</a>
</div>
</div> */}
</>
);
}
}

View file

@ -0,0 +1,81 @@
import React from 'react';
import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
import { connect } from 'react-redux';
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 { parseVersion } from 'soapbox/utils/features';
const messages = defineMessages({
heading: { id: 'column.admin.dashboard', defaultMessage: 'Dashboard' },
});
const mapStateToProps = (state, props) => ({
instance: state.get('instance'),
openReportCount: state.getIn(['admin', 'open_report_count']),
});
export default @connect(mapStateToProps)
@injectIntl
class Dashboard extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
instance: ImmutablePropTypes.map.isRequired,
openReportCount: PropTypes.number,
};
render() {
const { intl, instance } = this.props;
const v = parseVersion(instance.get('version'));
return (
<Column icon='tachometer' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<div className='dashcounters'>
<div className='dashcounter'>
<a href='/pleroma/admin/#/users/index' target='_blank'>
<div className='dashcounter__num'>
<FormattedNumber value={instance.getIn(['stats', 'user_count'])} />
</div>
<div className='dashcounter__label'>
<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='users' />
</div>
</a>
</div>
<div className='dashcounter'>
<a href='/pleroma/admin/#/statuses/index' target='_blank'>
<div className='dashcounter__num'>
<FormattedNumber value={instance.getIn(['stats', 'status_count'])} />
</div>
<div className='dashcounter__label'>
<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />
</div>
</a>
</div>
<div className='dashcounter'>
<a href='/pleroma/admin/#/reports/index' target='_blank'>
<div className='dashcounter__num'>
<FormattedNumber value={this.props.openReportCount} />
</div>
<div className='dashcounter__label'>
<FormattedMessage id='admin.dashcounters.open_report_count_label' defaultMessage='open reports' />
</div>
</a>
</div>
{/* TODO: Awaiting approval users count */}
</div>
<div className='dashwidgets'>
<div class='dashwidget'>
<h4><FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' /></h4>
<ul>
<li>Soapbox FE <span class='pull-right'>1.1.0</span></li>
<li>{v.software} <span class='pull-right'>{v.version}</span></li>
</ul>
</div>
</div>
</Column>
);
}
}

View file

@ -1,9 +0,0 @@
import { connect } from 'react-redux';
import IconWithBadge from 'soapbox/components/icon_with_badge';
const mapStateToProps = state => ({
count: state.get('chats').reduce((acc, curr) => acc + Math.min(curr.get('unread', 0), 1), 0),
id: 'comment',
});
export default connect(mapStateToProps)(IconWithBadge);

View file

@ -1,46 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { fetchFollowRequests } from 'soapbox/actions/accounts';
import { connect } from 'react-redux';
import { NavLink, withRouter } from 'react-router-dom';
import IconWithBadge from 'soapbox/components/icon_with_badge';
import { List as ImmutableList } from 'immutable';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = state => {
const me = state.get('me');
return {
locked: state.getIn(['accounts', me, 'locked']),
count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
};
};
export default @withRouter
@connect(mapStateToProps)
class FollowRequestsNavLink extends React.Component {
static propTypes = {
dispatch: PropTypes.func.isRequired,
locked: PropTypes.bool,
count: PropTypes.number.isRequired,
};
componentDidMount() {
const { dispatch, locked } = this.props;
if (locked) {
dispatch(fetchFollowRequests());
}
}
render() {
const { locked, count } = this.props;
if (!locked || count === 0) {
return null;
}
return <NavLink className='column-link column-link--transparent' to='/follow_requests'><IconWithBadge className='column-link__icon' id='user-plus' count={count} /><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></NavLink>;
}
}

View file

@ -1,9 +0,0 @@
import { connect } from 'react-redux';
import IconWithBadge from 'soapbox/components/icon_with_badge';
const mapStateToProps = state => ({
count: state.getIn(['notifications', 'unread']),
id: 'bell',
});
export default connect(mapStateToProps)(IconWithBadge);

View file

@ -1,9 +0,0 @@
import { connect } from 'react-redux';
import IconWithBadge from 'soapbox/components/icon_with_badge';
const mapStateToProps = state => ({
count: state.getIn(['admin', 'open_report_count']),
id: 'gavel',
});
export default connect(mapStateToProps)(IconWithBadge);

View file

@ -5,9 +5,7 @@ import { Link, NavLink, withRouter } from 'react-router-dom';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import classNames from 'classnames'; import classNames from 'classnames';
import NotificationsCounterIcon from './notifications_counter_icon'; import IconWithCounter from 'soapbox/components/icon_with_counter';
import ReportsCounterIcon from './reports_counter_icon';
import ChatsCounterIcon from './chats_counter_icon';
import SearchContainer from 'soapbox/features/compose/containers/search_container'; import SearchContainer from 'soapbox/features/compose/containers/search_container';
import Avatar from '../../../components/avatar'; import Avatar from '../../../components/avatar';
import ActionBar from 'soapbox/features/compose/components/action_bar'; import ActionBar from 'soapbox/features/compose/components/action_bar';
@ -32,6 +30,9 @@ class TabsBar extends React.PureComponent {
onOpenSidebar: PropTypes.func.isRequired, onOpenSidebar: PropTypes.func.isRequired,
logo: PropTypes.string, logo: PropTypes.string,
account: ImmutablePropTypes.map, account: ImmutablePropTypes.map,
dashboardCount: PropTypes.number,
notificationCount: PropTypes.number,
chatsCount: PropTypes.number,
} }
state = { state = {
@ -52,7 +53,7 @@ class TabsBar extends React.PureComponent {
} }
getNavLinks() { getNavLinks() {
const { intl: { formatMessage }, logo, account } = this.props; const { intl: { formatMessage }, logo, account, dashboardCount, notificationCount, chatsCount } = this.props;
let links = []; let links = [];
if (logo) { if (logo) {
links.push( links.push(
@ -69,26 +70,23 @@ class TabsBar extends React.PureComponent {
if (account) { if (account) {
links.push( links.push(
<NavLink key='notifications' className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications'> <NavLink key='notifications' className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications'>
<Icon id='bell' /> <IconWithCounter icon='bell' count={notificationCount} />
<NotificationsCounterIcon />
<span><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></span> <span><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></span>
</NavLink>); </NavLink>);
} }
if (account) { if (account) {
links.push( links.push(
<NavLink key='chats' className='tabs-bar__link tabs-bar__link--chats' to='/chats' data-preview-title-id='column.chats'> <NavLink key='chats' className='tabs-bar__link tabs-bar__link--chats' to='/chats' data-preview-title-id='column.chats'>
<Icon id='comment' /> <IconWithCounter icon='comment' count={chatsCount} />
<ChatsCounterIcon />
<span><FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' /></span> <span><FormattedMessage id='tabs_bar.chats' defaultMessage='Chats' /></span>
</NavLink>); </NavLink>);
} }
if (account && isStaff(account)) { if (account && isStaff(account)) {
links.push( links.push(
<a key='reports' className='tabs-bar__link' href='/pleroma/admin/#/reports/index' target='_blank' data-preview-title-id='tabs_bar.reports'> <NavLink key='dashboard' className='tabs-bar__link' to='/admin' data-preview-title-id='tabs_bar.dashboard'>
<Icon id='gavel' /> <IconWithCounter icon='tachometer' count={dashboardCount} />
<ReportsCounterIcon /> <span><FormattedMessage id='tabs_bar.dashboard' defaultMessage='Dashboard' /></span>
<span><FormattedMessage id='tabs_bar.reports' defaultMessage='Reports' /></span> </NavLink>);
</a>);
} }
links.push( links.push(
<NavLink key='search' className='tabs-bar__link tabs-bar__link--search' to='/search' data-preview-title-id='tabs_bar.search'> <NavLink key='search' className='tabs-bar__link tabs-bar__link--search' to='/search' data-preview-title-id='tabs_bar.search'>
@ -156,9 +154,14 @@ class TabsBar extends React.PureComponent {
const mapStateToProps = state => { const mapStateToProps = state => {
const me = state.get('me'); const me = state.get('me');
const reportsCount = state.getIn(['admin', 'open_report_count']);
const approvalCount = state.getIn(['admin', 'awaitingApproval']).count();
return { return {
account: state.getIn(['accounts', me]), account: state.getIn(['accounts', me]),
logo: getSoapboxConfig(state).get('logo'), logo: getSoapboxConfig(state).get('logo'),
notificationCount: state.getIn(['notifications', 'unread']),
chatsCount: state.get('chats').reduce((acc, curr) => acc + Math.min(curr.get('unread', 0), 1), 0),
dashboardCount: reportsCount + approvalCount,
}; };
}; };

View file

@ -16,7 +16,7 @@ import { debounce } from 'lodash';
import { uploadCompose, resetCompose } from '../../actions/compose'; import { uploadCompose, resetCompose } from '../../actions/compose';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
import { expandNotifications } from '../../actions/notifications'; import { expandNotifications } from '../../actions/notifications';
import { fetchReports } from '../../actions/admin'; import { fetchReports, fetchUsers } from '../../actions/admin';
import { fetchFilters } from '../../actions/filters'; import { fetchFilters } from '../../actions/filters';
import { fetchChats } from 'soapbox/actions/chats'; import { fetchChats } from 'soapbox/actions/chats';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
@ -39,6 +39,7 @@ import Icon from 'soapbox/components/icon';
import { isStaff } from 'soapbox/utils/accounts'; import { isStaff } from 'soapbox/utils/accounts';
import ChatPanes from 'soapbox/features/chats/components/chat_panes'; import ChatPanes from 'soapbox/features/chats/components/chat_panes';
import ProfileHoverCard from 'soapbox/components/profile_hover_card'; import ProfileHoverCard from 'soapbox/components/profile_hover_card';
import AdminNav from 'soapbox/features/admin/components/admin_nav';
import { import {
Status, Status,
@ -86,6 +87,8 @@ import {
ChatIndex, ChatIndex,
ChatRoom, ChatRoom,
ServerInfo, ServerInfo,
Dashboard,
AwaitingApproval,
} from './util/async-components'; } from './util/async-components';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
@ -154,6 +157,14 @@ const LAYOUT = {
<FeaturesPanel key='0' />, <FeaturesPanel key='0' />,
], ],
}, },
ADMIN: {
LEFT: [
<AdminNav key='0' />,
],
RIGHT: [
<LinkFooter key='1' />,
],
},
STATUS: { STATUS: {
TOP: null, TOP: null,
LEFT: null, LEFT: null,
@ -274,6 +285,9 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/settings/import' layout={LAYOUT.DEFAULT} component={ImportData} content={children} /> <WrappedRoute path='/settings/import' layout={LAYOUT.DEFAULT} component={ImportData} content={children} />
<WrappedRoute path='/soapbox/config' layout={LAYOUT.DEFAULT} component={SoapboxConfig} content={children} /> <WrappedRoute path='/soapbox/config' layout={LAYOUT.DEFAULT} component={SoapboxConfig} content={children} />
<Redirect from='/admin/dashboard' to='/admin' exact />
<WrappedRoute path='/admin' layout={LAYOUT.ADMIN} component={Dashboard} content={children} exact />
<WrappedRoute path='/admin/approval' layout={LAYOUT.ADMIN} component={AwaitingApproval} content={children} exact />
<WrappedRoute path='/info' layout={LAYOUT.EMPTY} component={ServerInfo} content={children} /> <WrappedRoute path='/info' layout={LAYOUT.EMPTY} component={ServerInfo} content={children} />
<WrappedRoute layout={LAYOUT.EMPTY} component={GenericNotFound} content={children} /> <WrappedRoute layout={LAYOUT.EMPTY} component={GenericNotFound} content={children} />
@ -448,8 +462,10 @@ class UI extends React.PureComponent {
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());
this.props.dispatch(fetchChats()); this.props.dispatch(fetchChats());
// this.props.dispatch(fetchGroups('member')); // this.props.dispatch(fetchGroups('member'));
if (isStaff(account)) if (isStaff(account)) {
this.props.dispatch(fetchReports({ state: 'open' })); this.props.dispatch(fetchReports({ state: 'open' }));
this.props.dispatch(fetchUsers({ page: 1, filters: 'local,need_approval' }));
}
setTimeout(() => this.props.dispatch(fetchFilters()), 500); setTimeout(() => this.props.dispatch(fetchFilters()), 500);
} }

View file

@ -217,3 +217,11 @@ export function ChatRoom() {
export function ServerInfo() { export function ServerInfo() {
return import(/* webpackChunkName: "features/server_info" */'../../server_info'); return import(/* webpackChunkName: "features/server_info" */'../../server_info');
} }
export function Dashboard() {
return import(/* webpackChunkName: "features/admin" */'../../admin');
}
export function AwaitingApproval() {
return import(/* webpackChunkName: "features/admin/awaiting_approval" */'../../admin/awaiting_approval');
}

View file

@ -1,11 +1,13 @@
import reducer from '../admin'; import reducer from '../admin';
import { fromJS } from 'immutable'; import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
describe('admin reducer', () => { describe('admin reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual(fromJS({ expect(reducer(undefined, {})).toEqual(fromJS({
reports: [], reports: [],
open_report_count: 0, open_report_count: 0,
users: ImmutableMap(),
awaitingApproval: ImmutableOrderedSet(),
})); }));
}); });
}); });

View file

@ -1,11 +1,54 @@
import { ADMIN_REPORTS_FETCH_SUCCESS } from '../actions/admin'; import {
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; ADMIN_REPORTS_FETCH_SUCCESS,
ADMIN_USERS_FETCH_SUCCESS,
ADMIN_USERS_DELETE_REQUEST,
ADMIN_USERS_DELETE_SUCCESS,
ADMIN_USERS_APPROVE_REQUEST,
ADMIN_USERS_APPROVE_SUCCESS,
} from '../actions/admin';
import {
Map as ImmutableMap,
List as ImmutableList,
OrderedSet as ImmutableOrderedSet,
fromJS,
} from 'immutable';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
reports: ImmutableList(), reports: ImmutableList(),
users: ImmutableMap(),
open_report_count: 0, open_report_count: 0,
awaitingApproval: ImmutableOrderedSet(),
}); });
function importUsers(state, users) {
return state.withMutations(state => {
users.forEach(user => {
if (user.approval_pending) {
state.update('awaitingApproval', orderedSet => orderedSet.add(user.nickname));
}
state.setIn(['users', user.nickname], fromJS(user));
});
});
}
function deleteUsers(state, nicknames) {
return state.withMutations(state => {
nicknames.forEach(nickname => {
state.update('awaitingApproval', orderedSet => orderedSet.delete(nickname));
state.deleteIn(['users', nickname]);
});
});
}
function approveUsers(state, users) {
return state.withMutations(state => {
users.forEach(user => {
state.update('awaitingApproval', orderedSet => orderedSet.delete(user.nickname));
state.setIn(['users', user.nickname], fromJS(user));
});
});
}
export default function admin(state = initialState, action) { export default function admin(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ADMIN_REPORTS_FETCH_SUCCESS: case ADMIN_REPORTS_FETCH_SUCCESS:
@ -16,6 +59,15 @@ export default function admin(state = initialState, action) {
} else { } else {
return state.set('reports', fromJS(action.data.reports)); return state.set('reports', fromJS(action.data.reports));
} }
case ADMIN_USERS_FETCH_SUCCESS:
return importUsers(state, action.data.users);
case ADMIN_USERS_DELETE_REQUEST:
case ADMIN_USERS_DELETE_SUCCESS:
return deleteUsers(state, action.nicknames);
case ADMIN_USERS_APPROVE_REQUEST:
return state.update('awaitingApproval', set => set.subtract(action.nicknames));
case ADMIN_USERS_APPROVE_SUCCESS:
return approveUsers(state, action.users);
default: default:
return state; return state;
} }

View file

@ -79,6 +79,7 @@
@import 'components/snackbar'; @import 'components/snackbar';
@import 'components/accordion'; @import 'components/accordion';
@import 'components/server-info'; @import 'components/server-info';
@import 'components/admin';
// Holiday // Holiday
@import 'holiday/halloween'; @import 'holiday/halloween';

View file

@ -0,0 +1,96 @@
.dashcounters {
display: flex;
flex-wrap: wrap;
margin: 0 -5px 0;
padding: 20px;
}
.dashcounter {
box-sizing: border-box;
flex: 0 0 33.333%;
padding: 0 5px;
margin-bottom: 10px;
> a,
> div {
text-decoration: none;
color: inherit;
display: block;
padding: 20px;
background: var(--accent-color--faint);
border-radius: 4px;
transition: 0.2s;
}
> a:hover {
background: var(--accent-color--med);
transform: translateY(-2px);
}
&__num,
&__text {
text-align: center;
font-weight: 500;
font-size: 24px;
line-height: 30px;
color: var(--primary-text-color);
margin-bottom: 10px;
}
&__label {
font-size: 14px;
color: hsla(var(--primary-text-color_hsl), 0.6);
text-align: center;
font-weight: 500;
}
}
.dashwidgets {
display: flex;
flex-wrap: wrap;
margin: 0 -5px;
padding: 0 20px 20px 20px;
}
.dashwidget {
flex: 1;
margin-bottom: 20px;
padding: 0 5px;
h4 {
text-transform: uppercase;
font-size: 13px;
font-weight: 700;
color: hsla(var(--primary-text-color_hsl), 0.6);
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid var(--accent-color--med);
}
}
.unapproved-account {
padding: 15px 20px;
font-size: 14px;
display: flex;
&__nickname {
font-weight: bold;
}
&__reason {
padding: 5px 0 5px 15px;
border-left: 3px solid hsla(var(--primary-text-color_hsl), 0.4);
color: var(--primary-text-color--faint);
}
&__actions {
margin-left: auto;
padding-left: 20px;
display: flex;
flex-wrap: nowrap;
}
}
.slist .item-list article:nth-child(2n-1) .unapproved-account {
background-color: hsla(var(--accent-color_hsl), 0.07);
}

View file

@ -28,7 +28,8 @@
} }
} }
&__icon { &__icon,
.icon-with-counter {
margin-right: 12px; margin-right: 12px;
} }
} }

View file

@ -213,6 +213,12 @@
} }
} }
.icon-with-counter__counter {
@media screen and (min-width: 895px) {
left: 5px;
}
}
&.optional { &.optional {
display: none; display: none;
@media screen and (max-width: $nav-breakpoint-2) { @media screen and (max-width: $nav-breakpoint-2) {

View file

@ -699,3 +699,25 @@
text-align: center; text-align: center;
} }
} }
.icon-with-counter {
position: relative;
display: inline;
&__counter {
@include font-montserrat;
@include font-size(14);
@include line-height(14);
position: absolute;
box-sizing: border-box;
left: 8px;
top: -12px;
min-width: 16px;
height: 16px;
padding: 1px 3px 0;
border-radius: 8px;
text-align: center;
color: #fff;
background: var(--accent-color);
}
}