Merge branch 'feature/moderation-log' into 'master'

Add moderation log

* [x]  filtering by specific admin/moderator
* [x]  filtering by date range
* [x]  searching by log message
* [x]  pagination

See merge request pleroma/admin-fe!38
This commit is contained in:
Maxim Filippov 2019-09-27 12:41:54 +00:00
commit 6290d06e20
8 changed files with 279 additions and 4 deletions

View file

@ -6,10 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased ## Unreleased
## [1.2.0] - 2019-09-27
### Added ### Added
- Emoji pack configuration - Emoji pack configuration
- Ability to require user's password reset - Ability to require user's password reset
Ability to track admin/moderator actions, a.k.a. "the moderation log"
## [1.1.0] - 2019-09-15 ## [1.1.0] - 2019-09-15

View file

@ -18,8 +18,19 @@ To compile everything for production run `yarn build:prod`.
#### Disabling features #### Disabling features
You can disable certain AdminFE features, like reports or settings by modifying `config/prod.env.js` env variable `DISABLED_FEATURES`, e.g. if you want to compile AdminFE without "Settings" you'll need to set it to: `DISABLED_FEATURES: '["settings"]'`, You can disable certain AdminFE features, like reports or settings by modifying `config/prod.env.js` env variable `DISABLED_FEATURES`, e.g. if you want to compile AdminFE without "Settings" you'll need to set it to: `DISABLED_FEATURES: '["settings"]'`.
to disable emoji pack settings add `"emoji-packs"` to the list.
Features, that can be disabled:
- reports: `DISABLED_FEATURES: '["reports"]'`
- invites: `DISABLED_FEATURES: '["invites"]'`
- moderation log: `DISABLED_FEATURES: '["moderationLog"]'`
- settings: `DISABLED_FEATURES: '["settings"]'`
- emoji packs: `DISABLED_FEATURES: '["emojiPacks"]'`
Of course, you can disable multiple features just by adding to the array, e.g. `DISABLED_FEATURES: '["emojiPacks", "settings"]'` will have both emoji packs and settings disabled.
Users administration cannot be disabled.
## Changelog ## Changelog

38
src/api/moderationLog.js Normal file
View file

@ -0,0 +1,38 @@
import _ from 'lodash'
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function fetchLog(authHost, token, params, page = 1) {
const normalizedParams = new URLSearchParams(
_.omitBy({ ...params, page }, _.isUndefined)
).toString()
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/moderation_log?${normalizedParams}`,
method: 'get',
headers: authHeaders(token)
})
}
export async function fetchAdmins(authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/users?filters=is_admin`,
method: 'get',
headers: authHeaders(token)
})
}
export async function fetchModerators(authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/users?filters=is_moderator`,
method: 'get',
headers: authHeaders(token)
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}

View file

@ -67,6 +67,7 @@ export default {
users: 'Users', users: 'Users',
reports: 'Reports', reports: 'Reports',
settings: 'Settings', settings: 'Settings',
moderationLog: 'Moderation Log',
'emoji-packs': 'Emoji packs' 'emoji-packs': 'Emoji packs'
}, },
navbar: { navbar: {
@ -284,6 +285,9 @@ export default {
closed: 'Closed', closed: 'Closed',
resolved: 'Resolved' resolved: 'Resolved'
}, },
moderationLog: {
moderationLog: 'Moderation Log'
},
settings: { settings: {
settings: 'Settings', settings: 'Settings',
instance: 'Instance', instance: 'Instance',

View file

@ -49,6 +49,20 @@ const invites = {
] ]
} }
const moderationLogDisabled = disabledFeatures.includes('moderation-log')
const moderationLog = {
path: '/moderation_log',
component: Layout,
children: [
{
path: 'index',
component: () => import('@/views/moderation_log/index'),
name: 'Moderation Log',
meta: { title: 'moderationLog', icon: 'list', noCache: true }
}
]
}
const emojiPacksDisabled = disabledFeatures.includes('emoji-packs') const emojiPacksDisabled = disabledFeatures.includes('emoji-packs')
const emojiPacks = { const emojiPacks = {
path: '/emoji-packs', path: '/emoji-packs',
@ -122,13 +136,14 @@ export const asyncRouterMap = [
path: 'index', path: 'index',
component: () => import('@/views/users/index'), component: () => import('@/views/users/index'),
name: 'Users', name: 'Users',
meta: { title: 'Users', icon: 'peoples', noCache: true } meta: { title: 'users', icon: 'peoples', noCache: true }
} }
] ]
}, },
...(settingsDisabled ? [] : [settings]),
...(reportsDisabled ? [] : [reports]), ...(reportsDisabled ? [] : [reports]),
...(invitesDisabled ? [] : [invites]), ...(invitesDisabled ? [] : [invites]),
...(moderationLogDisabled ? [] : [moderationLog]),
...(settingsDisabled ? [] : [settings]),
...(emojiPacksDisabled ? [] : [emojiPacks]), ...(emojiPacksDisabled ? [] : [emojiPacks]),
{ {
path: '/users/:id', path: '/users/:id',

View file

@ -2,6 +2,7 @@ import Vue from 'vue'
import Vuex from 'vuex' import Vuex from 'vuex'
import app from './modules/app' import app from './modules/app'
import errorLog from './modules/errorLog' import errorLog from './modules/errorLog'
import moderationLog from './modules/moderationLog'
import invites from './modules/invites' import invites from './modules/invites'
import permission from './modules/permission' import permission from './modules/permission'
import reports from './modules/reports' import reports from './modules/reports'
@ -19,6 +20,7 @@ const store = new Vuex.Store({
modules: { modules: {
app, app,
errorLog, errorLog,
moderationLog,
invites, invites,
permission, permission,
reports, reports,

View file

@ -0,0 +1,51 @@
import { fetchLog, fetchAdmins, fetchModerators } from '@/api/moderationLog'
const moderationLog = {
state: {
fetchedLog: [],
logItemsCount: 0,
admins: [],
moderators: [],
logLoading: true,
adminsLoading: true
},
mutations: {
SET_LOG_LOADING: (state, status) => {
state.logLoading = status
},
SET_ADMINS_LOADING: (state, status) => {
state.adminsLoading = status
},
SET_MODERATION_LOG: (state, log) => {
state.fetchedLog = log
},
SET_MODERATION_LOG_COUNT: (state, count) => {
state.logItemsCount = count
},
SET_ADMINS: (state, admins) => {
state.admins = admins
},
SET_MODERATORS: (state, moderators) => {
state.moderators = moderators
}
},
actions: {
async FetchModerationLog({ commit, getters }, opts = {}) {
const response = await fetchLog(getters.authHost, getters.token, opts)
commit('SET_MODERATION_LOG', response.data.items)
commit('SET_MODERATION_LOG_COUNT', response.data.total)
commit('SET_LOG_LOADING', false)
},
async FetchAdmins({ commit, getters }) {
const adminsResponse = await fetchAdmins(getters.authHost, getters.token)
const moderatorsResponse = await fetchModerators(getters.authHost, getters.token)
commit('SET_ADMINS', adminsResponse.data)
commit('SET_MODERATORS', moderatorsResponse.data)
commit('SET_ADMINS_LOADING', false)
}
}
}
export default moderationLog

View file

@ -0,0 +1,151 @@
<template>
<div v-if="!loading" class="moderation-log-container">
<h1>{{ $t('moderationLog.moderationLog') }}</h1>
<el-row type="flex" class="row-bg" justify="space-between">
<el-col :span="9">
<el-select
v-model="user"
class="user-select"
clearable
placeholder="Filter by admin/moderator"
@change="fetchLogWithFilters">
<el-option-group
v-for="group in users"
:key="group.label"
:label="group.label">
<el-option
v-for="item in group.options"
:key="item.id"
:label="item.nickname"
:value="item.id" />
</el-option-group>
</el-select>
</el-col>
<el-col :span="6" class="search-container">
<el-input
v-model="search"
placeholder="Search logs"
clearable
@input="handleDebounceSearchInput" />
</el-col>
</el-row>
<el-row type="flex" class="row-bg" justify="space-between">
<el-col :span="9" class="date-container">
<el-date-picker
:default-time="['00:00:00', '23:59:59']"
v-model="dateRange"
type="daterange"
start-placeholder="Start date"
end-placeholder="End date"
unlink-panels
@change="fetchLogWithFilters" />
</el-col>
</el-row>
<el-timeline>
<el-timeline-item
v-for="(logEntry, index) in log"
:key="index"
:timestamp="normalizeTimestamp(logEntry.time)">
{{ logEntry.message }}
</el-timeline-item>
</el-timeline>
<div class="pagination">
<el-pagination
:current-page.sync="currentPage"
:hide-on-single-page="true"
:page-size="50"
:total="total"
layout="prev, pager, next"
@current-change="fetchLogWithFilters" />
</div>
</div>
</template>
<script>
import moment from 'moment'
import _ from 'lodash'
import debounce from 'lodash.debounce'
export default {
data() {
return {
dateRange: '',
search: '',
user: '',
currentPage: 1
}
},
computed: {
loading() {
return this.$store.state.moderationLog.logLoading &&
this.$store.state.moderationLog.adminsLoading
},
log() {
return this.$store.state.moderationLog.fetchedLog
},
total() {
return this.$store.state.moderationLog.logItemsCount
},
users() {
return [
{
label: 'Admins',
options: this.$store.state.moderationLog.admins.users
},
{
label: 'Moderators',
options: this.$store.state.moderationLog.moderators.users
}
]
}
},
created() {
this.handleDebounceSearchInput = debounce((query) => {
this.fetchLogWithFilters()
}, 500)
},
mounted() {
this.$store.dispatch('FetchModerationLog')
this.$store.dispatch('FetchAdmins')
},
methods: {
normalizeTimestamp(timestamp) {
return moment(timestamp * 1000).format('YYYY-MM-DD HH:mm')
},
fetchLogWithFilters() {
const filters = _.omitBy({
start_date: this.dateRange ? this.dateRange[0].toISOString() : null,
end_date: this.dateRange ? this.dateRange[1].toISOString() : null,
user_id: this.user,
search: this.search,
page: this.currentPage
}, val => val === '' || val === null)
this.$store.dispatch('FetchModerationLog', filters)
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
.moderation-log-container {
margin: 0 15px;
}
h1 {
margin: 22px 0 20px 0;
}
.el-timeline {
margin: 25px 45px 0 0;
padding: 0px;
}
.user-select {
margin: 0 0 20px;
width: 350px;
}
.search-container {
text-align: right;
}
.pagination {
text-align: center;
}
</style>