From 5eafa25ea95eef978cc01e176a89b5054495aa18 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 13 Jul 2021 12:21:12 -0500 Subject: [PATCH] Allow promotion/demotion of users to staff roles --- app/soapbox/actions/admin.js | 69 ++++++++++++++++++- .../features/account/components/header.js | 36 +++++++--- .../account_timeline/components/header.js | 15 ++++ .../containers/header_container.js | 40 ++++++++++- app/soapbox/reducers/accounts.js | 37 ++++++++++ 5 files changed, 183 insertions(+), 14 deletions(-) diff --git a/app/soapbox/actions/admin.js b/app/soapbox/actions/admin.js index 85910af9d..7f4098a65 100644 --- a/app/soapbox/actions/admin.js +++ b/app/soapbox/actions/admin.js @@ -53,6 +53,16 @@ export const ADMIN_USERS_UNTAG_REQUEST = 'ADMIN_USERS_UNTAG_REQUEST'; export const ADMIN_USERS_UNTAG_SUCCESS = 'ADMIN_USERS_UNTAG_SUCCESS'; export const ADMIN_USERS_UNTAG_FAIL = 'ADMIN_USERS_UNTAG_FAIL'; +export const ADMIN_ADD_PERMISSION_REQUEST = 'ADMIN_ADD_PERMISSION_REQUEST'; +export const ADMIN_ADD_PERMISSION_SUCCESS = 'ADMIN_ADD_PERMISSION_SUCCESS'; +export const ADMIN_ADD_PERMISSION_FAIL = 'ADMIN_ADD_PERMISSION_FAIL'; + +export const ADMIN_REMOVE_PERMISSION_REQUEST = 'ADMIN_REMOVE_PERMISSION_REQUEST'; +export const ADMIN_REMOVE_PERMISSION_SUCCESS = 'ADMIN_REMOVE_PERMISSION_SUCCESS'; +export const ADMIN_REMOVE_PERMISSION_FAIL = 'ADMIN_REMOVE_PERMISSION_FAIL'; + +const nicknamesFromIds = (getState, ids) => ids.map(id => getState().getIn(['accounts', id, 'acct'])); + export function fetchConfig() { return (dispatch, getState) => { dispatch({ type: ADMIN_CONFIG_FETCH_REQUEST }); @@ -208,7 +218,7 @@ export function fetchModerationLog(params) { export function tagUsers(accountIds, tags) { return (dispatch, getState) => { - const nicknames = accountIds.map(id => getState().getIn(['accounts', id, 'acct'])); + const nicknames = nicknamesFromIds(getState, accountIds); dispatch({ type: ADMIN_USERS_TAG_REQUEST, accountIds, tags }); return api(getState) .put('/api/v1/pleroma/admin/users/tag', { nicknames, tags }) @@ -222,7 +232,7 @@ export function tagUsers(accountIds, tags) { export function untagUsers(accountIds, tags) { return (dispatch, getState) => { - const nicknames = accountIds.map(id => getState().getIn(['accounts', id, 'acct'])); + const nicknames = nicknamesFromIds(getState, accountIds); dispatch({ type: ADMIN_USERS_UNTAG_REQUEST, accountIds, tags }); return api(getState) .delete('/api/v1/pleroma/admin/users/tag', { data: { nicknames, tags } }) @@ -233,3 +243,58 @@ export function untagUsers(accountIds, tags) { }); }; } + +export function addPermission(accountIds, permissionGroup) { + return (dispatch, getState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_ADD_PERMISSION_REQUEST, accountIds, permissionGroup }); + return api(getState) + .post(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { nicknames }) + .then(({ data }) => { + dispatch({ type: ADMIN_ADD_PERMISSION_SUCCESS, accountIds, permissionGroup, data }); + }).catch(error => { + dispatch({ type: ADMIN_ADD_PERMISSION_FAIL, error, accountIds, permissionGroup }); + }); + }; +} + +export function removePermission(accountIds, permissionGroup) { + return (dispatch, getState) => { + const nicknames = nicknamesFromIds(getState, accountIds); + dispatch({ type: ADMIN_REMOVE_PERMISSION_REQUEST, accountIds, permissionGroup }); + return api(getState) + .delete(`/api/v1/pleroma/admin/users/permission_group/${permissionGroup}`, { data: { nicknames } }) + .then(({ data }) => { + dispatch({ type: ADMIN_REMOVE_PERMISSION_SUCCESS, accountIds, permissionGroup, data }); + }).catch(error => { + dispatch({ type: ADMIN_REMOVE_PERMISSION_FAIL, error, accountIds, permissionGroup }); + }); + }; +} + +export function promoteToAdmin(accountId) { + return (dispatch, getState) => { + return Promise.all([ + dispatch(addPermission([accountId], 'admin')), + dispatch(removePermission([accountId], 'moderator')), + ]); + }; +} + +export function promoteToModerator(accountId) { + return (dispatch, getState) => { + return Promise.all([ + dispatch(removePermission([accountId], 'admin')), + dispatch(addPermission([accountId], 'moderator')), + ]); + }; +} + +export function demoteToUser(accountId) { + return (dispatch, getState) => { + return Promise.all([ + dispatch(removePermission([accountId], 'admin')), + dispatch(removePermission([accountId], 'moderator')), + ]); + }; +} diff --git a/app/soapbox/features/account/components/header.js b/app/soapbox/features/account/components/header.js index aac0186c4..f7be3173d 100644 --- a/app/soapbox/features/account/components/header.js +++ b/app/soapbox/features/account/components/header.js @@ -8,7 +8,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Icon from 'soapbox/components/icon'; import Button from 'soapbox/components/button'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { isStaff } from 'soapbox/utils/accounts'; +import { isStaff, isAdmin, isModerator } from 'soapbox/utils/accounts'; import { parseVersion } from 'soapbox/utils/features'; import classNames from 'classnames'; import Avatar from 'soapbox/components/avatar'; @@ -54,15 +54,21 @@ const messages = defineMessages({ deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' }, verifyUser: { id: 'admin.users.actions.verify_user', defaultMessage: 'Verify @{name}' }, unverifyUser: { id: 'admin.users.actions.unverify_user', defaultMessage: 'Unverify @{name}' }, + promoteToAdmin: { id: 'admin.users.actions.promote_to_admin', defaultMessage: 'Promote @{name} to an admin' }, + promoteToModerator: { id: 'admin.users.actions.promote_to_moderator', defaultMessage: 'Promote @{name} to a moderator' }, + demoteToModerator: { id: 'admin.users.actions.demote_to_moderator', defaultMessage: 'Demote @{name} to a moderator' }, + demoteToUser: { id: 'admin.users.actions.demote_to_user', defaultMessage: 'Demote @{name} to a regular user' }, subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe to notifications from @{name}' }, unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe to notifications from @{name}' }, }); const mapStateToProps = state => { const me = state.get('me'); + const account = state.getIn(['accounts', me]); + return { me, - isStaff: isStaff(state.getIn(['accounts', me])), + meAccount: account, version: parseVersion(state.getIn(['instance', 'version'])), }; }; @@ -73,17 +79,13 @@ class Header extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map, + meAccount: ImmutablePropTypes.map, identity_props: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, username: PropTypes.string, - isStaff: PropTypes.bool.isRequired, version: PropTypes.object, }; - static defaultProps = { - isStaff: false, - } - state = { isSmallScreen: (window.innerWidth <= 895), } @@ -129,7 +131,7 @@ class Header extends ImmutablePureComponent { } makeMenu() { - const { account, intl, me, isStaff, version } = this.props; + const { account, intl, me, meAccount, version } = this.props; let menu = []; @@ -200,9 +202,23 @@ class Header extends ImmutablePureComponent { } } - if (isStaff) { + if (isStaff(meAccount)) { menu.push(null); - menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/pleroma/admin/#/users/${account.get('id')}/`, newTab: true }); + + if (isAdmin(meAccount)) { + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/pleroma/admin/#/users/${account.get('id')}/`, newTab: true }); + } + + if (isAdmin(account)) { + menu.push({ text: intl.formatMessage(messages.demoteToModerator, { name: account.get('username') }), action: this.props.onPromoteToModerator }); + menu.push({ text: intl.formatMessage(messages.demoteToUser, { name: account.get('username') }), action: this.props.onDemoteToUser }); + } else if (isModerator(account)) { + menu.push({ text: intl.formatMessage(messages.promoteToAdmin, { name: account.get('username') }), action: this.props.onPromoteToAdmin }); + menu.push({ text: intl.formatMessage(messages.demoteToUser, { name: account.get('username') }), action: this.props.onDemoteToUser }); + } else { + menu.push({ text: intl.formatMessage(messages.promoteToAdmin, { name: account.get('username') }), action: this.props.onPromoteToAdmin }); + menu.push({ text: intl.formatMessage(messages.promoteToModerator, { name: account.get('username') }), action: this.props.onPromoteToModerator }); + } if (isVerified(account)) { menu.push({ text: intl.formatMessage(messages.unverifyUser, { name: account.get('username') }), action: this.props.onUnverifyUser }); diff --git a/app/soapbox/features/account_timeline/components/header.js b/app/soapbox/features/account_timeline/components/header.js index 8e38d9fb5..b322125fd 100644 --- a/app/soapbox/features/account_timeline/components/header.js +++ b/app/soapbox/features/account_timeline/components/header.js @@ -104,6 +104,18 @@ export default class Header extends ImmutablePureComponent { this.props.onUnverifyUser(this.props.account); } + handlePromoteToAdmin = () => { + this.props.onPromoteToAdmin(this.props.account); + } + + handlePromoteToModerator = () => { + this.props.onPromoteToModerator(this.props.account); + } + + handleDemoteToUser = () => { + this.props.onDemoteToUser(this.props.account); + } + render() { const { account, identity_proofs } = this.props; const moved = (account) ? account.get('moved') : false; @@ -132,6 +144,9 @@ export default class Header extends ImmutablePureComponent { onDeleteUser={this.handleDeleteUser} onVerifyUser={this.handleVerifyUser} onUnverifyUser={this.handleUnverifyUser} + onPromoteToAdmin={this.handlePromoteToAdmin} + onPromoteToModerator={this.handlePromoteToModerator} + onDemoteToUser={this.handleDemoteToUser} username={this.props.username} /> diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js index c5689d158..cc078b517 100644 --- a/app/soapbox/features/account_timeline/containers/header_container.js +++ b/app/soapbox/features/account_timeline/containers/header_container.js @@ -12,7 +12,6 @@ import { // unpinAccount, subscribeAccount, unsubscribeAccount, - } from '../../../actions/accounts'; import { mentionCompose, @@ -27,7 +26,14 @@ import { List as ImmutableList } from 'immutable'; import { getSettings } from 'soapbox/actions/settings'; import { startChat, openChat } from 'soapbox/actions/chats'; import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; -import { tagUsers, untagUsers } from 'soapbox/actions/admin'; +import { + tagUsers, + untagUsers, + promoteToAdmin, + promoteToModerator, + demoteToUser, +} from 'soapbox/actions/admin'; +import { isAdmin } from 'soapbox/utils/accounts'; import snackbar from 'soapbox/actions/snackbar'; const messages = defineMessages({ @@ -37,6 +43,11 @@ const messages = defineMessages({ blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' }, userVerified: { id: 'admin.users.user_verified_message', defaultMessage: '@{acct} was verified' }, userUnverified: { id: 'admin.users.user_unverified_message', defaultMessage: '@{acct} was unverified' }, + promotedToAdmin: { id: 'admin.users.actions.promote_to_admin_message', defaultMessage: '@{acct} was promoted to an admin' }, + promotedToModerator: { id: 'admin.users.actions.promote_to_moderator_message', defaultMessage: '@{acct} was promoted to a moderator' }, + demotedToModerator: { id: 'admin.users.actions.demote_to_moderator_message', defaultMessage: '@{acct} was demoted to a moderator' }, + demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' }, + }); const isMobile = width => width <= 1190; @@ -184,6 +195,31 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(snackbar.info(message)); }).catch(() => {}); }, + + onPromoteToAdmin(account) { + const message = intl.formatMessage(messages.promotedToAdmin, { acct: account.get('acct') }); + + dispatch(promoteToAdmin(account.get('id'))) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }, + + onPromoteToModerator(account) { + const messageType = isAdmin(account) ? messages.demotedToModerator : messages.promotedToModerator; + const message = intl.formatMessage(messageType, { acct: account.get('acct') }); + + dispatch(promoteToModerator(account.get('id'))) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }, + + onDemoteToUser(account) { + const message = intl.formatMessage(messages.demotedToUser, { acct: account.get('acct') }); + + dispatch(demoteToUser(account.get('id'))) + .then(() => dispatch(snackbar.success(message))) + .catch(() => {}); + }, }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/soapbox/reducers/accounts.js b/app/soapbox/reducers/accounts.js index dfaff67d5..dd52247c4 100644 --- a/app/soapbox/reducers/accounts.js +++ b/app/soapbox/reducers/accounts.js @@ -17,6 +17,10 @@ import { ADMIN_USERS_TAG_FAIL, ADMIN_USERS_UNTAG_REQUEST, ADMIN_USERS_UNTAG_FAIL, + ADMIN_ADD_PERMISSION_REQUEST, + ADMIN_ADD_PERMISSION_FAIL, + ADMIN_REMOVE_PERMISSION_REQUEST, + ADMIN_REMOVE_PERMISSION_FAIL, } from 'soapbox/actions/admin'; import { ADMIN_USERS_DELETE_REQUEST } from 'soapbox/actions/admin'; @@ -90,6 +94,33 @@ const setDeactivated = (state, nicknames) => { }); }; +const permissionGroupFields = { + admin: 'is_admin', + moderator: 'is_moderator', +}; + +const addPermission = (state, accountIds, permissionGroup) => { + const field = permissionGroupFields[permissionGroup]; + if (!field) return state; + + return state.withMutations(state => { + accountIds.forEach(id => { + state.setIn([id, 'pleroma', field], true); + }); + }); +}; + +const removePermission = (state, accountIds, permissionGroup) => { + const field = permissionGroupFields[permissionGroup]; + if (!field) return state; + + return state.withMutations(state => { + accountIds.forEach(id => { + state.setIn([id, 'pleroma', field], false); + }); + }); +}; + export default function accounts(state = initialState, action) { switch(action.type) { case ACCOUNT_IMPORT: @@ -111,6 +142,12 @@ export default function accounts(state = initialState, action) { case ADMIN_USERS_UNTAG_REQUEST: case ADMIN_USERS_TAG_FAIL: return removeTags(state, action.accountIds, action.tags); + case ADMIN_ADD_PERMISSION_REQUEST: + case ADMIN_REMOVE_PERMISSION_FAIL: + return addPermission(state, action.accountIds, action.permissionGroup); + case ADMIN_REMOVE_PERMISSION_REQUEST: + case ADMIN_ADD_PERMISSION_FAIL: + return removePermission(state, action.accountIds, action.permissionGroup); case ADMIN_USERS_DELETE_REQUEST: return setDeactivated(state, action.nicknames); default: