diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue index 0feef27b..0042d057 100644 --- a/src/components/dialog_modal/dialog_modal.vue +++ b/src/components/dialog_modal/dialog_modal.vue @@ -69,7 +69,7 @@ padding: 1rem 1rem; background-color: $fallback--bg; background-color: var(--bg, $fallback--bg); - white-space: normal; + white-space: pre-wrap; } .dialog-modal-footer { diff --git a/src/components/mod_modal/mod_modal.scss b/src/components/mod_modal/mod_modal.scss index 4821df74..f0882f97 100644 --- a/src/components/mod_modal/mod_modal.scss +++ b/src/components/mod_modal/mod_modal.scss @@ -24,7 +24,6 @@ } .mod-modal-panel { - overflow: hidden; transition: transform; transition-timing-function: ease-in-out; transition-duration: 300ms; @@ -39,6 +38,16 @@ .panel-body { height: inherit; + overflow-y: hidden; + } + + .pagination-container { + display: flex; + justify-content: right; + + .btn { + margin-left: 0.6em; + } } } } diff --git a/src/components/mod_modal/mod_modal.vue b/src/components/mod_modal/mod_modal.vue index 64bbf021..f6b529a0 100644 --- a/src/components/mod_modal/mod_modal.vue +++ b/src/components/mod_modal/mod_modal.vue @@ -35,6 +35,9 @@
+ diff --git a/src/components/mod_modal/mod_modal_content.js b/src/components/mod_modal/mod_modal_content.js index e0ba6259..f1637e73 100644 --- a/src/components/mod_modal/mod_modal_content.js +++ b/src/components/mod_modal/mod_modal_content.js @@ -2,7 +2,7 @@ import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import ReportsTab from './tabs/reports_tab/reports_tab.vue' // import StatusesTab from './tabs/statuses_tab.vue' -// import UsersTab from './tabs/users_tab.vue' +import UsersTab from './tabs/users_tab/users_tab.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -21,9 +21,9 @@ const ModModalContent = { components: { TabSwitcher, - ReportsTab + ReportsTab, // StatusesTab, - // UsersTab + UsersTab }, computed: { open () { diff --git a/src/components/mod_modal/mod_modal_content.vue b/src/components/mod_modal/mod_modal_content.vue index 6fa32be1..c4b964b9 100644 --- a/src/components/mod_modal/mod_modal_content.vue +++ b/src/components/mod_modal/mod_modal_content.vue @@ -13,6 +13,13 @@ > +
+ +
diff --git a/src/components/mod_modal/tabs/users_tab/users_tab.js b/src/components/mod_modal/tabs/users_tab/users_tab.js new file mode 100644 index 00000000..ca7dd924 --- /dev/null +++ b/src/components/mod_modal/tabs/users_tab/users_tab.js @@ -0,0 +1,116 @@ +import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue' +import ModerationTools from 'src/components/moderation_tools/moderation_tools.vue' +import Popover from 'src/components/popover/popover.vue' + +import { forEach, every, findKey } from 'lodash' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { + faFilter, + faSearch +} from '@fortawesome/free-solid-svg-icons' + +library.add( + faFilter, + faSearch +) + +const UsersTab = { + data () { + return { + searchTerm: null, + page: 1, + accountType: { + local: true, + external: false + }, + status: { + active: true, + deactivated: false, + need_approval: false, + unconfirmed: false + }, + actorType: { + Person: false, + Service: false, + Application: false + } + } + }, + components: { + BasicUserCard, + ModerationTools, + Popover + }, + created () { + this.query() + }, + computed: { + users () { return this.$store.state.users.adminUsers }, + isActive () { + const tabSwitcher = this.$parent + return tabSwitcher ? tabSwitcher.isActive('users') : false + } + }, + methods: { + all (filter) { return every(filter, _ => !_) }, + setAccountType (type = false) { + forEach(this.accountType, (k, v) => { this.accountType[v] = false }) + if (type) { + this.accountType[type] = true + } + + this.page = 1 + this.query() + }, + setStatus (status = false) { + forEach(this.status, (k, v) => { this.status[v] = false }) + if (status) { + this.status[status] = true + } + + this.page = 1 + this.query() + }, + setActorType (type = false) { + forEach(this.actorType, (k, v) => { this.actorType[v] = false }) + if (type) { + this.actorType[type] = true + } + + this.page = 1 + this.query() + }, + search () { + this.page = 1 + this.query() + }, + prevPage () { + this.page-- + this.query() + }, + nextPage () { + this.page++ + this.query() + }, + query () { + const params = {} + params.actorTypes = [findKey(this.actorType, _ => _)].filter(Boolean) + params.filters = [ + findKey(this.status, _ => _), + findKey(this.accountType, _ => _) + ].filter(Boolean) + + if (this.searchTerm) { + params.name = this.searchTerm + } + if (this.page > 1) { + params.page = this.page + } + + this.$store.dispatch('fetchUsers', params) + } + } +} + +export default UsersTab diff --git a/src/components/mod_modal/tabs/users_tab/users_tab.scss b/src/components/mod_modal/tabs/users_tab/users_tab.scss new file mode 100644 index 00000000..d11c241e --- /dev/null +++ b/src/components/mod_modal/tabs/users_tab/users_tab.scss @@ -0,0 +1,60 @@ +@import '../../../../_variables.scss'; + +.users-header { + display: flex; + align-items: center; + justify-content: space-between; + + .right-side { + display: flex; + align-items: center; + + .search-input-container { + padding: 0.8rem; + display: flex; + justify-content: center; + align-items: center; + + .search-input { + width: 100%; + line-height: 1.125rem; + font-size: 1rem; + padding: 0.5rem; + box-sizing: border-box; + } + + .search-icon { + margin-right: 0.3em; + } + } + } +} + +.users .user { + display: flex; + flex-direction: row; + + .user-moderation { + padding-top: 0.6em; + position: relative; + + .popover { + right: 0; + } + } + + .user-information { + display: flex; + flex-direction: column; + padding-bottom: 0.6em; + flex: 1; + + .basic-user-card { + padding-bottom: 0; + } + + .registration-reason { + padding-left: 5.2em; + } + } +} diff --git a/src/components/mod_modal/tabs/users_tab/users_tab.vue b/src/components/mod_modal/tabs/users_tab/users_tab.vue new file mode 100644 index 00000000..647dc4ec --- /dev/null +++ b/src/components/mod_modal/tabs/users_tab/users_tab.vue @@ -0,0 +1,211 @@ + + + + diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js index 2469327a..51f084df 100644 --- a/src/components/moderation_tools/moderation_tools.js +++ b/src/components/moderation_tools/moderation_tools.js @@ -16,7 +16,8 @@ const QUARANTINE = 'mrf_tag:quarantine' const ModerationTools = { props: [ - 'user' + 'user', + 'extended' ], data () { return { @@ -30,7 +31,10 @@ const ModerationTools = { QUARANTINE }, showDeleteUserDialog: false, - toggled: false + showPasswordTokenDialog: false, + toggled: false, + passwordResetToken: {}, + bot: this.user.bot } }, components: { @@ -83,6 +87,9 @@ const ModerationTools = { deleteUserDialog (show) { this.showDeleteUserDialog = show }, + passwordTokenDialog (show) { + this.showPasswordTokenDialog = show + }, deleteUser () { const store = this.$store const user = this.user @@ -97,6 +104,32 @@ const ModerationTools = { } }) }, + approveAccount () { + this.$store.state.api.backendInteractor.approveAccount({ nickname: this.user.screen_name }) + }, + getPasswordResetToken () { + this.$store.state.api.backendInteractor.getPasswordResetToken({ nickname: this.user.screen_name }) + .then(data => { + this.passwordResetToken = data + this.passwordTokenDialog(true) + }) + }, + forcePasswordReset () { + this.$store.state.api.backendInteractor.forcePasswordReset({ nickname: this.user.screen_name }) + }, + forceDisableMFA () { + this.$store.state.api.backendInteractor.forceDisableMFA({ nickname: this.user.screen_name }) + }, + toggleBot () { + const params = { bot: !this.bot } + + this.$store.state.api.backendInteractor + .updateProfile({ params }) + .then((user) => { + this.$store.commit('addNewUsers', [user]) + this.bot = !this.bot + }) + }, setToggled (value) { this.toggled = value } diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue index 75ea08cc..513f2c7b 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -5,6 +5,7 @@ class="moderation-tools-popover" placement="bottom" :offset="{ y: 5 }" + :noCenter="extended" @show="setToggled(true)" @close="setToggled(false)" > @@ -28,6 +29,25 @@ class="dropdown-divider" /> + + + + + diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js index a30a37c9..f100b3b5 100644 --- a/src/components/popover/popover.js +++ b/src/components/popover/popover.js @@ -31,7 +31,11 @@ const Popover = { // If true, subtract padding when calculating position for the popover, // use it when popover offset looks to be different on top vs bottom. - removePadding: Boolean + removePadding: Boolean, + + // If true, do not center the popover under the button that called it, + // instead aligning it to the right. + noCenter: Boolean }, data () { return { @@ -121,7 +125,10 @@ const Popover = { : yOffset const xOffset = (this.offset && this.offset.x) || 0 - const translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset + let translateX = translateX = anchorWidth * 0.5 - content.offsetWidth * 0.5 + horizOffset + xOffset + if (this.noCenter) { + translateX = 0 + } // Note, separate translateX and translateY avoids blurry text on chromium, // single translate or translate3d resulted in blurry text. diff --git a/src/i18n/en.json b/src/i18n/en.json index 534f1c1c..b4525d6e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -294,7 +294,24 @@ "tags": "Set post restrictions" }, "statuses": "Posts", - "users": "Users" + "next": "Next page", + "previous": "Previous page", + "users": { + "filter": { + "active": "Active", + "all": "All accounts", + "application": "Application", + "bot": "Bot", + "deactivated": "Deactivated", + "external": "External", + "local": "Local", + "pending": "Pending Approval", + "person": "Person", + "unconfirmed": "Unconfirmed" + }, + "no_results": "No accounts found", + "users": "Users" + } }, "nav": { "about": "About", @@ -1094,18 +1111,26 @@ "user_card": { "admin_menu": { "activate_account": "Activate account", + "approve_account": "Approve account", + "convert_to": "Convert to { type }", "deactivate_account": "Deactivate account", "delete_account": "Delete account", "delete_user": "Delete user", "delete_user_data_and_deactivate_confirmation": "This will permanently delete the data from this account and deactivate it. Are you absolutely sure?", "disable_any_subscription": "Disallow following user at all", + "disable_mfa": "Disable multi-factor authentication", "disable_remote_subscription": "Disallow following user from remote instances", "force_nsfw": "Mark all posts as NSFW", + "force_password_reset": "Require password reset on next login", "force_unlisted": "Force posts to be unlisted", + "get_password_reset_token": "Get password reset token", "grant_admin": "Grant Admin", "grant_moderator": "Grant Moderator", "moderation": "Moderation", + "password_reset_token": "Password reset token", + "password_reset_token_content": "Password reset token has been generated: { token }\nYou can also use this link to reset the password: ", "quarantine": "Disallow user posts from federating", + "reject_account": "Reject account", "revoke_admin": "Revoke Admin", "revoke_moderator": "Revoke Moderator", "sandbox": "Force posts to be followers-only", diff --git a/src/modules/users.js b/src/modules/users.js index c63b93de..c236d600 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -185,6 +185,9 @@ export const mutations = { user['followedTagIds'] = [] } }, + adminSetUsers (state, users) { + state.adminUsers = users + }, addNewUsers (state, users) { each(users, (user) => { if (user.relationship) { @@ -294,6 +297,7 @@ export const defaultState = { currentUser: false, users: [], usersObject: {}, + adminUsers: [], signUpPending: false, signUpErrors: [], relationships: {}, @@ -306,6 +310,14 @@ const users = { mutations, getters, actions: { + fetchUsers (store, params = false) { + return store.rootState.api.backendInteractor.fetchUsers(params) + .then((users) => { + store.commit('adminSetUsers', users) + users.forEach(user => store.dispatch('fetchUserIfMissing', user.id)) + return users + }) + }, fetchUserIfMissing (store, id) { if (!store.getters.findUser(id)) { store.dispatch('fetchUser', id) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 0f8b75a4..f6c17edf 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -23,6 +23,10 @@ const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read' const ADMIN_REPORTS_URL = '/api/v1/pleroma/admin/reports' const ADMIN_REPORT_NOTES_URL = id => `/api/v1/pleroma/admin/reports/${id}/notes` const ADMIN_REPORT_NOTE_URL = (report, note) => `/api/v1/pleroma/admin/reports/${report}/notes/${note}` +const ADMIN_PASSWORD_RESET_TOKEN_URL = nickname => `/api/v1/pleroma/admin/users/${nickname}/password_reset` +const ADMIN_FORCE_PASSWORD_RESET_URL = '/api/v1/pleroma/admin/users/force_password_reset' +const ADMIN_DISABLE_MFA_URL = '/api/v1/pleroma/admin/users/disable_mfa' +const ADMIN_APPROVE_ACCOUNT_URL = '/api/v1/pleroma/admin/users/approve' const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa' const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes' @@ -557,6 +561,29 @@ const fetchStatusHistory = ({ status, credentials }) => { }) } +const fetchUsers = ({ filters, name, page, actorTypes, credentials }) => { + const url = ADMIN_USERS_URL + const params = [] + + if (filters) { + params.push(['filters', filters.join(',')]) + } + if (name) { + params.push(['name', name]) + } + if (page) { + params.push(['page', page]) + } + if (actorTypes) { + actorTypes.forEach(type => (params.push(['actor_types[]', type]))) + } + + const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') + return promisedRequest({ url: url + `?${queryString}`, credentials }) + .then((data) => data.users) + .then((data) => data.map(parseUser)) +} + const tagUser = ({ tag, credentials, user }) => { const screenName = user.screen_name const form = { @@ -694,6 +721,42 @@ const deleteNoteFromReport = ({ report, note, credentials }) => { }) } +const getPasswordResetToken = ({ nickname, credentials }) => { + const url = ADMIN_PASSWORD_RESET_TOKEN_URL(nickname) + + return fetch(url, { headers: authHeaders(credentials) }) + .then(data => data.json()) +} + +const forcePasswordReset = ({ nickname, credentials }) => { + return promisedRequest({ + url: ADMIN_FORCE_PASSWORD_RESET_URL, + method: 'PATCH', + payload: { nicknames: [nickname] }, + credentials + }) +} + +const forceDisableMFA = ({ nickname, credentials }) => { + return promisedRequest({ + url: ADMIN_DISABLE_MFA_URL, + method: 'PUT', + payload: { nickname }, + credentials + }) +} + +const approveAccount = ({ nickname, credentials }) => { + return promisedRequest({ + url: ADMIN_APPROVE_ACCOUNT_URL, + method: 'PATCH', + payload: { + nicknames: [nickname] + }, + credentials + }) +} + const fetchTimeline = ({ timeline, credentials, @@ -1762,6 +1825,7 @@ const apiService = { fetchBlocks, fetchOAuthTokens, revokeOAuthToken, + fetchUsers, tagUser, untagUser, deleteUser, @@ -1769,6 +1833,10 @@ const apiService = { deleteRight, activateUser, deactivateUser, + getPasswordResetToken, + forcePasswordReset, + forceDisableMFA, + approveAccount, register, getCaptcha, updateProfileImages, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index c54ce3e2..4a71f0dc 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -43,6 +43,9 @@ export const parseUser = (data) => { // case for users in "mentions" property for statuses in MastoAPI const mastoShort = masto && !data.hasOwnProperty('avatar') + // account format from the admin API + const admin = data.hasOwnProperty('actor_type') + output.id = String(data.id) output._original = data // used for server-side settings @@ -139,6 +142,16 @@ export const parseUser = (data) => { // TODO: handle is_local output.is_local = !output.screen_name.includes('@') + } else if (admin) { + output.bot = data.actor_type === 'Service' + output.screen_name = data.nickname + output.name = data.display_name + output.profile_image_url = data.avatar + output.profile_image_url_original = data.avatar + output.is_local = data.local + output.approved = data.is_approved + output.registration_reason = data.registration_reason + output.emoji = [] } else { output.screen_name = data.screen_name