diff --git a/src/components/alert.style.js b/src/components/alert.style.js index 8a6f842ed..6876faca8 100644 --- a/src/components/alert.style.js +++ b/src/components/alert.style.js @@ -4,6 +4,7 @@ export default { validInnerComponents: ['Text', 'Icon', 'Link', 'Border', 'ButtonUnstyled'], variants: { normal: '.neutral', + info: '.info', error: '.error', warning: '.warning', success: '.success', @@ -47,5 +48,11 @@ export default { background: '--cGreen', }, }, + { + variant: 'info', + directives: { + background: '--cBlue', + }, + }, ], } diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js index 7f559f8fe..ee427533f 100644 --- a/src/components/announcement/announcement.js +++ b/src/components/announcement/announcement.js @@ -31,9 +31,7 @@ const Announcement = { canEditAnnouncement() { return ( this.currentUser && - this.currentUser.privileges.includes( - 'announcements_manage_announcements', - ) + this.currentUser.privileges.has('announcements_manage_announcements') ) }, content() { diff --git a/src/components/interactions/interactions.js b/src/components/interactions/interactions.js index 142a5fc8a..5dc2a8714 100644 --- a/src/components/interactions/interactions.js +++ b/src/components/interactions/interactions.js @@ -17,7 +17,7 @@ const Interactions = { allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, filterMode: tabModeDict.mentions, - canSeeReports: this.$store.state.users.currentUser.privileges.includes( + canSeeReports: this.$store.state.users.currentUser.has.has( 'reports_manage_reports', ), } diff --git a/src/components/list/list.js b/src/components/list/list.js index d61f9694a..4ddc645e2 100644 --- a/src/components/list/list.js +++ b/src/components/list/list.js @@ -14,7 +14,7 @@ const List = { }, getKey: { type: Function, - default: (item) => item, + default: (item) => item.id, }, getClass: { type: Function, @@ -37,7 +37,7 @@ const List = { default: null, }, }, - emits: ['fetchRequested'], + emits: ['fetchRequested', 'select'], components: { Checkbox, }, @@ -56,11 +56,14 @@ const List = { allKeys() { return new Set(this.finalItems.map(this.getKey)) }, - filteredSelected() { - return [...this.allKeys.values().filter((key) => this.selected.has(key))] + selectedItems() { + return this.items.filter((item) => this.selected.has(this.getKey(item))) }, allSelected() { - return this.selected.size === this.finalItems.length + return ( + this.selected.size !== 0 && + this.selected.size === this.finalItems.length + ) }, noneSelected() { return this.selected.size === 0 @@ -101,6 +104,7 @@ const List = { .catch((error) => { this.loading = false this.error = error + console.error('Error loading list data:', error) }) }, reset() { @@ -124,19 +128,17 @@ const List = { } }, isSelected(item) { - return this.filteredSelected.indexOf(this.getKey(item)) !== -1 + return this.selected.has(this.getKey(item)) }, toggle(checked, item) { const key = this.getKey(item) - const oldChecked = this.isSelected(key) - if (checked !== oldChecked) { - if (checked) { - this.selected.add(key) - } else { - this.selected.delete(key) - } + if (checked) { + this.selected.add(key) + } else { + this.selected.delete(key) } - this.$emit('selected', this.selected) + + this.$emit('select', this.selected) }, toggleAll(value) { if (value) { @@ -144,7 +146,7 @@ const List = { } else { this.selected = new Set([]) } - this.$emit('selected', this.selected) + this.$emit('select', this.selected) }, }, } diff --git a/src/components/list/list.vue b/src/components/list/list.vue index ff6259422..317eb293f 100644 --- a/src/components/list/list.vue +++ b/src/components/list/list.vue @@ -19,7 +19,7 @@
diff --git a/src/components/moderation_tools/moderation_tools.js b/src/components/moderation_tools/moderation_tools.js index d31002fad..a9fd4ec4a 100644 --- a/src/components/moderation_tools/moderation_tools.js +++ b/src/components/moderation_tools/moderation_tools.js @@ -1,6 +1,9 @@ -import DialogModal from 'src/components/dialog_modal/dialog_modal.vue' +import { last } from 'lodash' + +import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue' import Popover from 'src/components/popover/popover.vue' +import { useAdminSettingsStore } from 'src/stores/admin_settings.js' import { useInstanceStore } from 'src/stores/instance.js' import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' @@ -16,41 +19,372 @@ const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription' const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription' const SANDBOX = 'mrf_tag:sandbox' const QUARANTINE = 'mrf_tag:quarantine' +const TAGS = new Set([ + FORCE_NSFW, + STRIP_MEDIA, + FORCE_UNLISTED, + DISABLE_REMOTE_SUBSCRIPTION, + DISABLE_ANY_SUBSCRIPTION, + SANDBOX, + QUARANTINE, +]) + +const ENTRIES = [ + { + check: '!state:activated', + label: 'user_card.admin_menu.activate_account', + }, + { + check: 'state:activated', + label: 'user_card.admin_menu.deactivate_account', + }, + { + separator: true, + }, + { + check: '!state:confirmed', + label: 'user_card.admin_menu.confirm_account', + }, + { + check: 'action:resend_confirmation', + conditions: ['!state:confirmed'], + label: 'user_card.admin_menu.resend_confirmation', + }, + // No API for revocation + // { + // check: 'state:confirmed', + // label: 'user_card.admin_menu.unconfirm_account', + // }, + { + check: '!state:approved', + conditions: ['property:local'], + label: 'user_card.admin_menu.approve_account', + }, + // No API for revocation + // { + // check: 'state:approved', + // label: 'user_card.admin_menu.unapprove_account', + // }, + { + check: '!state:suggested', + // conditions: ['property:local'], // TODO Should we allow non-local users in suggested? + label: 'user_card.admin_menu.suggest_account', + }, + { + check: 'state:suggested', + label: 'user_card.admin_menu.remove_suggested_account', + }, + { + separator: true, + }, + { + check: '!state:disable_mfa', + label: 'user_card.admin_menu.disable_mfa', + }, + { + check: '!state:require_password_change', + label: 'user_card.admin_menu.require_password_change', + }, + { + separator: true, + }, + { + check: '!rights:moderator', + label: 'user_card.admin_menu.grant_moderator', + conditions: ['property:local', 'state:activated'], + }, + { + check: 'rights:moderator', + label: 'user_card.admin_menu.revoke_moderator', + conditions: ['property:local', 'state:activated'], + }, + { + check: '!rights:admin', + label: 'user_card.admin_menu.grant_admin', + conditions: ['property:local', 'state:activated'], + }, + { + check: 'rights:admin', + label: 'user_card.admin_menu.revoke_admin', + conditions: ['property:local', 'state:activated'], + }, + { + separator: true, + }, + { + check: FORCE_NSFW, + label: 'user_card.admin_menu.force_nsfw', + }, + { + check: STRIP_MEDIA, + label: 'user_card.admin_menu.strip_media', + }, + { + check: FORCE_UNLISTED, + label: 'user_card.admin_menu.force_unlisted', + }, + { + check: SANDBOX, + label: 'user_card.admin_menu.sandbox', + }, + { + check: DISABLE_ANY_SUBSCRIPTION, + conditions: ['property:local'], + label: 'user_card.admin_menu.disable_any_subscription', + }, + { + check: DISABLE_REMOTE_SUBSCRIPTION, + conditions: ['property:local'], + label: 'user_card.admin_menu.disable_remote_subscription', + }, + { + check: QUARANTINE, + conditions: ['property:local'], + label: 'user_card.admin_menu.quarantine', + }, + { + separator: true, + }, + { + check: 'action:delete', + label: 'user_card.admin_menu.delete_account', + }, +] const ModerationTools = { - props: ['user'], + props: { + users: { + type: Array, + required: true, + }, + }, + created() { + if (this.users.length !== 1) return + useAdminSettingsStore().getUserData({ user: this.users[0] }) + }, data() { return { - tags: { - FORCE_NSFW, - STRIP_MEDIA, - FORCE_UNLISTED, - DISABLE_REMOTE_SUBSCRIPTION, - DISABLE_ANY_SUBSCRIPTION, - SANDBOX, - QUARANTINE, - }, - showDeleteUserDialog: false, - toggled: false, + open: false, + confirmDialogShow: false, + confirmDialogTitle: null, + confirmDialogContent: null, + confirmDialogConfirm: null, + confirmDialogAction: null, + confirmDialogGroup: null, + confirmDialogName: null, } }, components: { - DialogModal, + ConfirmModal, Popover, }, computed: { + ready() { + return this.users.every((u) => u.adminData) + }, + entries() { + return ENTRIES.map(({ check, label, separator, conditions }) => { + if (separator) return 'separator' + const [, negateToken, group, name] = /^([!~]?)([a-z-_]+):([a-z-_]+)$/.exec( + check, + ) + + const hasTag = this.tagsSet.has(`${group}:${name}`) + const noTag = this.tagsSet.has(`!${group}:${name}`) + const maybeTag = this.tagsSet.has(`~${group}:${name}`) + + // We are checking for condition to show element, i.e. only show "activate" if user is "deactivated" + const checkNegated = (negateToken === '!' || negateToken === '~') + + // Naturally, new value should also be the same + const value = checkNegated + + const action = (() => { + switch (group) { + case 'rights': + return () => this.setRight(name, value) + case 'state': + return () => this.setStatus(name, value) + case 'mrf_tag': + return () => this.setTag(`${group}:${name}`, noTag) + case 'action': { + switch (name) { + case 'delete': { + return () => this.deleteUsers() + } + case 'resend_confirmation': { + return () => this.resendConfirmationEmail() + } + case 'disable_mfa': { + return () => this.disableMFA() + } + case 'require_password_change': { + return () => this.requirePasswordChange() + } + default: + throw new Error(`Unknown action group: ${name}`) + } + } + default: + throw new Error(`Unknown moderation group: ${group}`) + } + })() + + let checkboxClass = '' + if (maybeTag) { + checkboxClass = 'menu-checkbox-indeterminate' + } else if (hasTag) { + checkboxClass = 'menu-checkbox-checked' + } + + return { + check, + negateToken, + checkbox: group === 'mrf_tag', + checkboxClass, + conditions, + group, + name, + action, + label, + value, + } + }) + .filter((entry) => { + if (entry === 'separator') return true + const { group, name, value, conditions } = entry + + if (conditions) { + // Checking that all items match positive criteria + const positive = conditions.every((condition) => + this.totalSet.has(condition), + ) + // Checking that there are no items that don't match criteria + const negative = conditions.some((condition) => + this.totalSet.has('!' + condition), + ) + if (!(positive && !negative)) return false + } + + switch (group) { + case 'action': { + return true + } + case 'rights': { + return this.canGrantRole(name, value) + } + case 'state': { + return this.canChangeState(name, value) + } + case 'mrf_tag': { + return this.canUseTagPolicy + } + default: { + throw new Error(`Unknown moderation group: ${group}`) + } + } + }) + .reduce((acc, entry, index) => { + if (entry === 'separator') { + if ( + acc.length === 0 || + last(acc) === 'separator' || + index === ENTRIES.length - 1 + ) { + return acc + } + } + return [...acc, entry] + }, []) + }, + rightsSet() { + return this.users.reduce((acc, user) => { + if (user.rights.admin) { + acc.add('rights:admin') + } else { + acc.add('!rights:admin') + } + if (user.rights.moderator) { + acc.add('rights:moderator') + } else { + acc.add('!rights:moderator') + } + return acc + }, new Set()) + }, + stateSet() { + return this.users.reduce((acc, user) => { + if (!user.deactivated) { + acc.add('state:activated') + } else { + acc.add('!state:activated') + } + if (user.adminData?.is_confirmed) { + acc.add('state:confirmed') + } else { + acc.add('!state:confirmed') + } + if (user.adminData?.is_approved) { + acc.add('state:approved') + } else { + acc.add('!state:approved') + } + if (user.adminData?.is_suggested) { + acc.add('state:suggested') + } else { + acc.add('!state:suggested') + } + return acc + }, new Set()) + }, tagsSet() { - return new Set(this.user.tags) + const present = new Set() + const missing = new Set() + + this.users.forEach((user) => { + TAGS.forEach((tag) => { + if (user.tags.has(tag)) { + present.add(tag) + } else { + missing.add(tag) + } + }) + }) + + const result = new Set() + + TAGS.forEach((tag) => { + if (present.has(tag) && missing.has(tag)) { + result.add(`~${tag}`) + } else if (missing.has(tag)) { + result.add(`!${tag}`) + } else { + result.add(tag) + } + }) + + return result }, - canGrantRole() { - return ( - this.user.is_local && - !this.user.deactivated && - this.$store.state.users.currentUser.role === 'admin' - ) + propertySet() { + return this.users.reduce((acc, user) => { + if (user.is_local) { + acc.add('property:local') + } else { + acc.add('!property:local') + } + return acc + }, new Set()) }, - canChangeActivationState() { - return this.privileged('users_manage_activation_state') + disabled() { + return !this.ready || this.users.length === 0 + }, + totalSet() { + return new Set([ + ...this.rightsSet, + ...this.stateSet, + ...this.tagsSet, + ...this.propertySet, + ]) }, canDeleteAccount() { return this.privileged('users_delete') @@ -63,87 +397,221 @@ const ModerationTools = { }, }, methods: { - hasTag(tagName) { - return this.tagsSet.has(tagName) + canGrantRole(name, value) { + const setEntry = `${value ? '!' : ''}rights:${name}` + + return ( + this.$store.state.users.currentUser.role === 'admin' && + this.totalSet.has(setEntry) + ) + }, + canChangeState(name, value) { + let privilege + + switch (name) { + // TODO detailed privileges + default: { + privilege = 'users_manage_activation_state' + } + } + + const setEntry = `${value ? '!' : ''}state:${name}` + + return this.privileged(privilege) && this.totalSet.has(setEntry) + }, + doConfirmDialogAction() { + if (typeof this.confirmDialogAction !== 'function') { + console.error('Confirm Dialog action is not a function!!') + } + + this.confirmDialogAction() + this.clearConfirmDialog() + }, + clearConfirmDialog() { + this.confirmDialogShow = false + this.confirmDialogTitle = null + this.confirmDialogContent = null + this.confirmDialogContent2 = null + this.confirmDialogDanger = false + this.confirmDialogConfirm = null + this.confirmDialogAction = null + this.confirmDialogGroup = null + this.confirmDialogName = null }, privileged(privilege) { - return this.$store.state.users.currentUser.privileges.includes(privilege) + return this.$store.state.users.currentUser.privileges.has(privilege) }, - toggleTag(tag) { - const store = this.$store - if (this.tagsSet.has(tag)) { - store.state.api.backendInteractor - .untagUser({ user: this.user, tag }) - .then((response) => { - if (!response.ok) { - return - } - store.commit('untagUser', { user: this.user, tag }) - }) - } else { - store.state.api.backendInteractor - .tagUser({ user: this.user, tag }) - .then((response) => { - if (!response.ok) { - return - } - store.commit('tagUser', { user: this.user, tag }) - }) - } + setTag(tag, value) { + useAdminSettingsStore().setUsersTags({ users: this.users, value, tags: [tag] }) }, - toggleRight(right) { - const store = this.$store - if (this.user.rights[right]) { - store.state.api.backendInteractor - .deleteRight({ user: this.user, right }) - .then((response) => { - if (!response.ok) { - return - } - store.commit('updateRight', { - user: this.user, - right, - value: false, - }) - }) - } else { - store.state.api.backendInteractor - .addRight({ user: this.user, right }) - .then((response) => { - if (!response.ok) { - return - } - store.commit('updateRight', { user: this.user, right, value: true }) - }) - } + setRight(right, value) { + useAdminSettingsStore().setUsersRight({ users: this.users, value, right }) }, - toggleActivationStatus() { - this.$store.dispatch('toggleActivationStatus', { user: this.user }) - }, - deleteUserDialog(show) { - this.showDeleteUserDialog = show - }, - deleteUser() { - const store = this.$store - const user = this.user - const { id, name } = user - store.state.api.backendInteractor.deleteUser({ user }).then(() => { - this.$store.dispatch( - 'markStatusesAsDeleted', - (status) => user.id === status.user.id, - ) - const isProfile = - this.$route.name === 'external-user-profile' || - this.$route.name === 'user-profile' - const isTargetUser = - this.$route.params.name === name || this.$route.params.id === id - if (isProfile && isTargetUser) { - window.history.back() + setStatus(name, value) { + const noun = (() => { + switch (name) { + case 'activated': + return 'Activation' + case 'confirmed': + return 'Confirmation' + case 'approved': + return 'Approval' + case 'suggested': + return 'Suggestion' } + })() + + useAdminSettingsStore()[`setUsers${noun}Status`]({ + users: this.users, + value, }) }, - setToggled(value) { - this.toggled = value + resendConfirmationEmail() { + useAdminSettingsStore().resendConfirmationEmail({ users: this.users }) + }, + requirePasswordChange() { + useAdminSettingsStore().requirePasswordChange({ users: this.users }) + }, + disableMFA() { + this.users.forEach((user) => { + useAdminSettingsStore().disableMFA({ user }) + }) + }, + deleteUsers() { + const { id, name } = this.users[0] + + useAdminSettingsStore() + .deleteUsers({ users: this.users }) + .then((userIds) => { + if (userIds.length > 1) return + + const isProfile = + this.$route.name === 'external-user-profile' || + this.$route.name === 'user-profile' + const isTargetUser = + this.$route.params.name === name || this.$route.params.id === id + + if (isProfile && isTargetUser) { + window.history.back() + } + }) + }, + setOpen(value) { + this.open = value + }, + maybeShowConfirm(close, { group, name, action, value }) { + close() + this.confirmDialogName = name + this.confirmDialogGroup = group + this.confirmDialogAction = () => action() + + switch (group) { + case 'action': { + if (name === 'delete') { + this.confirmDialogShow = true + this.confirmDialogTitle = this.$t( + 'user_card.admin_menu.confirm_modal.delete_title', + ) + this.confirmDialogDanger = true + this.confirmDialogContent = + 'user_card.admin_menu.confirm_modal.delete_content' + this.confirmDialogContent2 = + 'user_card.admin_menu.confirm_modal.delete_content_2' + this.confirmDialogConfirm = this.$t( + 'user_card.admin_menu.confirm_modal.delete', + ) + } + break + } + case 'state': { + switch (name) { + case 'activated': { + this.confirmDialogShow = true + + this.confirmDialogTitle = this.$t( + 'user_card.admin_menu.confirm_modal.activate_title', + ) + this.confirmDialogContent = value + ? 'user_card.admin_menu.confirm_modal.activate_content' + : 'user_card.admin_menu.confirm_modal.deactivate_content' + this.confirmDialogConfirm = value + ? this.$t('user_card.admin_menu.confirm_modal.activate') + : this.$t('user_card.admin_menu.confirm_modal.deactivate') + break + } + // Confirmation and Approval statuses cannot be revokedn(no API) + case 'confirmed': { + this.confirmDialogTitle = this.$t( + 'user_card.admin_menu.confirm_modal.confirm_title', + ) + this.confirmDialogContent = //value + /*?*/ 'user_card.admin_menu.confirm_modal.confirm_content' + //: 'user_card.admin_menu.confirm_modal.confirm_revoke_content' + this.confirmDialogConfirm = value + /*?*/ this.$t('user_card.admin_menu.confirm_modal.confirm') + //: this.$t('user_card.admin_menu.confirm_modal.revoke') + break + } + case 'approved': { + this.confirmDialogTitle = this.$t( + 'user_card.admin_menu.confirm_modal.approval_title', + ) + this.confirmDialogContent = //value + /*?*/ 'user_card.admin_menu.confirm_modal.approval_content' + //: 'user_card.admin_menu.confirm_modal.approval_revoke_content' + this.confirmDialogConfirm = value + /*?*/ this.$t('user_card.admin_menu.confirm_modal.approve') + //: this.$t('user_card.admin_menu.confirm_modal.revoke') + break + } + case 'suggest': { + this.confirmDialogTitle = this.$t( + 'user_card.admin_menu.confirm_modal.suggest_title', + ) + this.confirmDialogContent = value + ? 'user_card.admin_menu.confirm_modal.add_suggest_content' + : 'user_card.admin_menu.confirm_modal.remove_suggest_content' + this.confirmDialogConfirm = value + ? this.$t('user_card.admin_menu.confirm_modal.add') + : this.$t('user_card.admin_menu.confirm_modal.remove') + break + } + } + break + } + case 'rights': { + this.confirmDialogTitle = this.$t( + 'user_card.admin_menu.confirm_modal.user_rights_title', + ) + this.confirmDialogContent = value + ? 'user_card.admin_menu.confirm_modal.grant_role_content' + : 'user_card.admin_menu.confirm_modal.revoke_role_content' + this.confirmDialogConfirm = value + ? this.$t('user_card.admin_menu.confirm_modal.grant') + : this.$t('user_card.admin_menu.confirm_modal.revoke') + break + } + case 'mrf_tag': { + this.confirmDialogTitle = this.$t( + 'user_card.admin_menu.user_tag_title', + ) + this.confirmDialogContent = value + ? 'user_card.admin_menu.confirm_modal.assign_tag_content' + : 'user_card.admin_menu.confirm_modal.unassign_tag_content' + this.confirmDialogConfirm = value + ? this.$t('user_card.admin_menu.confirm_modal.assign') + : this.$t('user_card.admin_menu.confirm_modal.unassign') + break + } + } + + if (this.users.length > 1) { + this.confirmDialogShow = true + } + + if (!this.confirmDialogShow) { + this.doConfirmDialogAction() + } }, }, } diff --git a/src/components/moderation_tools/moderation_tools.vue b/src/components/moderation_tools/moderation_tools.vue index da33b89dc..43612c693 100644 --- a/src/components/moderation_tools/moderation_tools.vue +++ b/src/components/moderation_tools/moderation_tools.vue @@ -3,154 +3,37 @@ -