Merge branch 'feature/add-users-actions' into 'master'

Add actions to moderation menu

Closes #4

See merge request pleroma/admin-fe!7
This commit is contained in:
Maxim Filippov 2019-03-22 21:18:57 +00:00
commit 76736c795b
7 changed files with 356 additions and 113 deletions

View file

@ -1,7 +1,7 @@
const users = [ const users = [
{ deactivated: false, id: '1', nickname: 'john', local: true }, { deactivated: false, id: '2', nickname: 'allis', local: true, roles: { admin: true, moderator: false }, tags: [] },
{ deactivated: false, id: '2', nickname: 'bob', local: false }, { deactivated: false, id: '10', nickname: 'bob', local: false, roles: { admin: false, moderator: true }, tags: ['sandbox'] },
{ deactivated: true, id: '3', nickname: 'allis', local: true } { deactivated: true, id: 'abc', nickname: 'john', local: true, roles: { admin: false, moderator: false }, tags: ['strip_media'] }
] ]
export async function fetchUsers(showLocalUsersOnly, token, page = 1) { export async function fetchUsers(showLocalUsersOnly, token, page = 1) {
@ -27,3 +27,29 @@ export async function searchUsers(query, showLocalUsersOnly, token, page = 1) {
page_size: 50 page_size: 50
}}) }})
} }
export async function addRight(nickname, right, token) {
return Promise.resolve({ data:
{ [`is_${right}`]: true }
})
}
export async function deleteRight(nickname, right, token) {
return Promise.resolve({ data:
{ [`is_${right}`]: false }
})
}
export async function deleteUser(nickname, token) {
return Promise.resolve({ data:
nickname
})
}
export async function tagUser(nickname, tag, token) {
return Promise.resolve()
}
export async function untagUser(nickname, tag, token) {
return Promise.resolve()
}

View file

@ -5,7 +5,7 @@ export async function fetchUsers(showLocalUsersOnly, token, page = 1) {
return await request({ return await request({
url: `/api/pleroma/admin/users?page=${page}&local_only=${showLocalUsersOnly}`, url: `/api/pleroma/admin/users?page=${page}&local_only=${showLocalUsersOnly}`,
method: 'get', method: 'get',
headers: token ? { 'Authorization': `Bearer ${getToken()}` } : {} headers: authHeaders(token)
}) })
} }
@ -13,7 +13,7 @@ export async function toggleUserActivation(nickname, token) {
return await request({ return await request({
url: `/api/pleroma/admin/users/${nickname}/toggle_activation`, url: `/api/pleroma/admin/users/${nickname}/toggle_activation`,
method: 'patch', method: 'patch',
headers: token ? { 'Authorization': `Bearer ${getToken()}` } : {} headers: authHeaders(token)
}) })
} }
@ -21,7 +21,50 @@ export async function searchUsers(query, showLocalUsersOnly, token, page = 1) {
return await request({ return await request({
url: `/api/pleroma/admin/users?query=${query}&page=${page}&local_only=${showLocalUsersOnly}`, url: `/api/pleroma/admin/users?query=${query}&page=${page}&local_only=${showLocalUsersOnly}`,
method: 'get', method: 'get',
headers: token ? { 'Authorization': `Bearer ${getToken()}` } : {} headers: authHeaders(token)
}) })
} }
export async function addRight(nickname, right, token) {
return await request({
url: `/api/pleroma/admin/permission_group/${nickname}/${right}`,
method: 'post',
headers: authHeaders(token)
})
}
export async function deleteRight(nickname, right, token) {
return await request({
url: `/api/pleroma/admin/permission_group/${nickname}/${right}`,
method: 'delete',
headers: authHeaders(token)
})
}
export async function deleteUser(nickname, token) {
return await request({
url: `/api/pleroma/admin/user.json?nickname=${nickname}`,
method: 'delete',
headers: authHeaders(token)
})
}
export async function tagUser(nickname, tag, token) {
return await request({
url: '/api/pleroma/admin/users/tag',
method: 'put',
headers: authHeaders(token),
data: { nicknames: [nickname], tags: [tag] }
})
}
export async function untagUser(nickname, tag, token) {
return await request({
url: '/api/pleroma/admin/users/tag',
method: 'delete',
headers: authHeaders(token),
data: { nicknames: [nickname], tags: [tag] }
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}

View file

@ -87,29 +87,11 @@ const user = {
}) })
}) })
}, },
// 第三方验证登录
// LoginByThirdparty({ commit, state }, code) {
// return new Promise((resolve, reject) => {
// commit('SET_CODE', code)
// loginByThirdparty(state.status, state.email, state.code).then(response => {
// commit('SET_TOKEN', response.data.token)
// setToken(response.data.token)
// resolve()
// }).catch(error => {
// reject(error)
// })
// })
// },
// 登出
LogOut({ commit }) { LogOut({ commit }) {
commit('SET_TOKEN', '') commit('SET_TOKEN', '')
commit('SET_ROLES', []) commit('SET_ROLES', [])
removeToken() removeToken()
}, },
// 前端 登出
FedLogOut({ commit }) { FedLogOut({ commit }) {
return new Promise(resolve => { return new Promise(resolve => {
commit('SET_TOKEN', '') commit('SET_TOKEN', '')
@ -117,24 +99,6 @@ const user = {
resolve() resolve()
}) })
} }
// 动态修改权限
// ChangeRoles({ commit, dispatch }, role) {
// return new Promise(resolve => {
// commit('SET_TOKEN', role)
// setToken(role)
// getUserInfo(role).then(response => {
// const data = response.data
// commit('SET_ROLES', data.roles)
// commit('SET_NAME', data.name)
// commit('SET_ID', data.id)
// commit('SET_AVATAR', data.avatar)
// commit('SET_INTRODUCTION', data.introduction)
// dispatch('GenerateRoutes', data) // 动态修改权限后 重绘侧边菜单
// resolve()
// })
// })
// }
} }
} }

View file

@ -1,4 +1,4 @@
import { fetchUsers, toggleUserActivation, searchUsers } from '@/api/users' import { addRight, fetchUsers, deleteRight, deleteUser, searchUsers, tagUser, toggleUserActivation, untagUser } from '@/api/users'
const users = { const users = {
state: { state: {
@ -22,7 +22,7 @@ const users = {
}) })
state.fetchedUsers = [...usersWithoutSwapped, user].sort((a, b) => state.fetchedUsers = [...usersWithoutSwapped, user].sort((a, b) =>
a.id.localeCompare(b.id) a.nickname.localeCompare(b.nickname)
) )
}, },
SET_COUNT: (state, count) => { SET_COUNT: (state, count) => {
@ -70,6 +70,30 @@ const users = {
async ToggleLocalUsersFilter({ commit, dispatch, state }, value) { async ToggleLocalUsersFilter({ commit, dispatch, state }, value) {
commit('SET_LOCAL_USERS_FILTER', value) commit('SET_LOCAL_USERS_FILTER', value)
dispatch('SearchUsers', { query: state.searchQuery, page: 1 }) dispatch('SearchUsers', { query: state.searchQuery, page: 1 })
},
async ToggleRight({ commit, getters }, { user, right }) {
user.roles[right]
? await deleteRight(user.nickname, right, getters.token)
: await addRight(user.nickname, right, getters.token)
const updatedUser = { ...user, roles: { ...user.roles, [right]: !user.roles[right] }}
commit('SWAP_USER', updatedUser)
},
async DeleteUser({ commit, getters }, user) {
await deleteUser(user.nickname, getters.token)
const updatedUser = { ...user, deactivated: true }
commit('SWAP_USER', updatedUser)
},
async ToggleTag({ commit, getters }, { user, tag }) {
if (user.tags.includes(tag)) {
await untagUser(user.nickname, tag, getters.token)
const updatedUser = { ...user, tags: user.tags.filter(userTag => userTag !== tag) }
commit('SWAP_USER', updatedUser)
} else {
await tagUser(user.nickname, tag, getters.token)
const updatedUser = { ...user, tags: [...user.tags, tag] }
commit('SWAP_USER', updatedUser)
}
} }
} }
} }

View file

@ -10,41 +10,8 @@ const service = axios.create({
// response interceptor // response interceptor
service.interceptors.response.use( service.interceptors.response.use(
response => response, response => response,
/**
* 下面的注释为通过在response里自定义code来标示请求状态
* 当code返回如下情况则说明权限有问题登出并返回到登录页
* 如想通过 xmlhttprequest 来状态码标识 逻辑可写在下面error中
* 以下代码均为样例请结合自生需求加以修改若不需要则可删除
*/
// response => {
// const res = response.data
// if (res.code !== 20000) {
// Message({
// message: res.message,
// type: 'error',
// duration: 5 * 1000
// })
// // 50008:非法的token; 50012:其他客户端登录了; 50014:Token 过期了;
// if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
// // 请自行在引入 MessageBox
// // import { Message, MessageBox } from 'element-ui'
// MessageBox.confirm('你已被登出,可以取消继续留在该页面,或者重新登录', '确定登出', {
// confirmButtonText: '重新登录',
// cancelButtonText: '取消',
// type: 'warning'
// }).then(() => {
// store.dispatch('FedLogOut').then(() => {
// location.reload() // 为了重新实例化vue-router对象 避免bug
// })
// })
// }
// return Promise.reject('error')
// } else {
// return response.data
// }
// },
error => { error => {
console.log('err' + error) // for debug console.log('err' + error)
Message({ Message({
message: error.message, message: error.message,
type: 'error', type: 'error',

View file

@ -7,24 +7,74 @@
</div> </div>
<el-table v-loading="loading" :data="users" style="width: 100%"> <el-table v-loading="loading" :data="users" style="width: 100%">
<el-table-column :min-width="width" prop="id" label="ID"/> <el-table-column :min-width="width" prop="id" label="ID"/>
<el-table-column prop="nickname" label="Name"/> <el-table-column prop="nickname" label="Name">
<template slot-scope="scope">
{{ scope.row.nickname }}
<el-tag v-if="isDesktop" type="info" size="mini">
<span>{{ scope.row.local ? 'local' : 'external' }}</span>
</el-tag>
</template>
</el-table-column>
<el-table-column :min-width="width" label="Status"> <el-table-column :min-width="width" label="Status">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag :type="scope.row.deactivated ? 'danger' : 'success'"> <el-tag :type="scope.row.deactivated ? 'danger' : 'success'">
<span v-if="isDesktop">{{ scope.row.deactivated ? 'deactivated' : 'active' }}</span> <span v-if="isDesktop">{{ scope.row.deactivated ? 'deactivated' : 'active' }}</span>
<i v-else :class="activationIcon(scope.row.deactivated)"/> <i v-else :class="activationIcon(scope.row.deactivated)"/>
</el-tag> </el-tag>
<el-tag v-if="scope.row.roles.admin">
<span>{{ isDesktop ? 'admin' : 'A' }}</span>
</el-tag>
<el-tag v-if="scope.row.roles.moderator">
<span>{{ isDesktop ? 'moderator' : 'M' }}</span>
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column fixed="right" label="Actions"> <el-table-column fixed="right" label="Actions">
<template slot-scope="scope"> <template slot-scope="scope">
<el-button <el-dropdown size="small">
v-if="showDeactivatedButton(scope.row.id)" <span class="el-dropdown-link">
class="toggle-activation" Moderation
type="text" <i v-if="isDesktop" class="el-icon-arrow-down el-icon--right"/>
size="small" </span>
@click="handleDeactivate(scope.row)" <el-dropdown-menu slot="dropdown">
>{{ scope.row.deactivated ? 'Activate' : 'Deactivate' }}</el-button> <el-dropdown-item v-if="showAdminAction(scope.row)" @click.native="toggleUserRight(scope.row, 'admin')">
{{ scope.row.roles.admin ? 'Revoke Admin' : 'Grant Admin' }}
</el-dropdown-item>
<el-dropdown-item v-if="showAdminAction(scope.row)" @click.native="toggleUserRight(scope.row, 'moderator')">
{{ scope.row.roles.moderator ? 'Revoke Moderator' : 'Grant Moderator' }}
</el-dropdown-item>
<el-dropdown-item v-if="showDeactivatedButton(scope.row.id)" :divided="showAdminAction(scope.row)" @click.native="handleDeactivation(scope.row)">
{{ scope.row.deactivated ? 'Activate account' : 'Deactivate account' }}
</el-dropdown-item>
<el-dropdown-item v-if="showDeactivatedButton(scope.row.id)" @click.native="handleDeletion(scope.row)">
Delete Account
</el-dropdown-item>
<el-dropdown-item :divided="showAdminAction(scope.row)" @click.native="toggleTag(scope.row, 'force_nsfw')">
Force posts to be NSFW
<i v-if="scope.row.tags.includes('force_nsfw')" class="el-icon-circle-check"/>
</el-dropdown-item>
<el-dropdown-item @click.native="toggleTag(scope.row, 'strip_media')">
Force posts not to have media
<i v-if="scope.row.tags.includes('strip_media')" class="el-icon-circle-check"/>
</el-dropdown-item>
<el-dropdown-item @click.native="toggleTag(scope.row, 'force_unlisted')">
Force posts to be unlisted
<i v-if="scope.row.tags.includes('force_unlisted')" class="el-icon-circle-check"/>
</el-dropdown-item>
<el-dropdown-item @click.native="toggleTag(scope.row, 'sandbox')">
Force posts to be followers-only
<i v-if="scope.row.tags.includes('sandbox')" class="el-icon-circle-check"/>
</el-dropdown-item>
<el-dropdown-item v-if="scope.row.local" @click.native="toggleTag(scope.row, 'disable_remote_subscription')">
Disallow following user from remote instances
<i v-if="scope.row.tags.includes('disable_remote_subscription')" class="el-icon-circle-check"/>
</el-dropdown-item>
<el-dropdown-item v-if="scope.row.local" @click.native="toggleTag(scope.row, 'disable_any_subscription')">
Disallow following user at all
<i v-if="scope.row.tags.includes('disable_any_subscription')" class="el-icon-circle-check"/>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -72,12 +122,7 @@ export default {
return this.$store.state.app.device === 'mobile' return this.$store.state.app.device === 'mobile'
}, },
width() { width() {
return this.isMobile ? 60 : false return this.isMobile ? 55 : false
},
rowStyle(id) {
return {
'data-user-id': id
}
} }
}, },
created() { created() {
@ -89,7 +134,7 @@ export default {
this.$store.dispatch('FetchUsers', { page: 1 }) this.$store.dispatch('FetchUsers', { page: 1 })
}, },
methods: { methods: {
handleDeactivate({ nickname }) { handleDeactivation({ nickname }) {
this.$store.dispatch('ToggleUserActivation', nickname) this.$store.dispatch('ToggleUserActivation', nickname)
}, },
handlePageChange(page) { handlePageChange(page) {
@ -103,11 +148,23 @@ export default {
showDeactivatedButton(id) { showDeactivatedButton(id) {
return this.$store.state.user.id !== id return this.$store.state.user.id !== id
}, },
showAdminAction({ local, id }) {
return local && this.showDeactivatedButton(id)
},
handleLocalUsersCheckbox(e) { handleLocalUsersCheckbox(e) {
this.$store.dispatch('ToggleLocalUsersFilter', e) this.$store.dispatch('ToggleLocalUsersFilter', e)
}, },
activationIcon(status) { activationIcon(status) {
return status ? 'el-icon-error' : 'el-icon-success' return status ? 'el-icon-error' : 'el-icon-success'
},
toggleUserRight(user, right) {
this.$store.dispatch('ToggleRight', { user, right })
},
handleDeletion(user) {
this.$store.dispatch('DeleteUser', user)
},
toggleTag(user, tag) {
this.$store.dispatch('ToggleTag', { user, tag })
} }
} }
} }
@ -144,6 +201,13 @@ only screen and (max-width: 760px),
h1 { h1 {
margin-left: 7px; margin-left: 7px;
} }
.el-dropdown-link {
cursor: pointer;
color: #409EFF;
}
.el-icon-arrow-down {
font-size: 12px;
}
.search { .search {
width: 50%; width: 50%;
margin-bottom: 21.5px; margin-bottom: 21.5px;
@ -156,6 +220,18 @@ only screen and (max-width: 760px),
align-items: baseline; align-items: baseline;
margin-left: 7px; margin-left: 7px;
} }
.el-tag {
width: 30px;
display: inline-block;
margin-bottom: 4px;
font-weight: bold;
&.el-tag--success {
padding-left: 8px;
}
&.el-tag--danger {
padding-left: 8px;
}
}
} }
} }
</style> </style>

View file

@ -11,7 +11,7 @@ localVue.use(Element)
jest.mock('@/api/users') jest.mock('@/api/users')
describe('Users', () => { describe('Search and filter users', () => {
let store let store
beforeEach(() => { beforeEach(() => {
@ -29,24 +29,6 @@ describe('Users', () => {
done() done()
}) })
it('toggles activation status on button click', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
const user = store.state.users.fetchedUsers[1]
expect(user.deactivated).toBe(false)
wrapper.find('.el-table__fixed-body-wrapper table tr:nth-child(2) button').trigger('click')
await wrapper.vm.$nextTick()
const updatedUser = store.state.users.fetchedUsers[1]
expect(updatedUser.deactivated).toBe(true)
done()
})
it('starts a search on input change', async (done) => { it('starts a search on input change', async (done) => {
const wrapper = mount(Users, { const wrapper = mount(Users, {
store, store,
@ -95,7 +77,7 @@ describe('Users', () => {
done() done()
}) })
it('shows local users with query search', async (done) => { it('shows local users with search query', async (done) => {
const wrapper = mount(Users, { const wrapper = mount(Users, {
store, store,
localVue localVue
@ -134,3 +116,164 @@ describe('Users', () => {
done() done()
}) })
}) })
describe('Users actions', () => {
let store
const htmlElement = (trChild, liChild) =>
`.el-table__fixed-body-wrapper table tr:nth-child(${trChild}) ul.el-dropdown-menu li:nth-child(${liChild})`
beforeEach(() => {
store = new Vuex.Store(cloneDeep(storeConfig))
})
it('grants admin and moderator rights to a local user', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
const user = store.state.users.fetchedUsers[2]
expect(user.roles.admin).toBe(false)
expect(user.roles.moderator).toBe(false)
wrapper.find(htmlElement(3, 1)).trigger('click')
await wrapper.vm.$nextTick()
wrapper.find(htmlElement(3, 2)).trigger('click')
await wrapper.vm.$nextTick()
const updatedUser = store.state.users.fetchedUsers[2]
expect(updatedUser.roles.admin).toBe(true)
expect(updatedUser.roles.moderator).toBe(true)
done()
})
it('does not show actions that grant admin and moderator rights to external users', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
const dropdownMenuItem = wrapper.find(htmlElement(2, 1))
expect(dropdownMenuItem.text()).toBe('Deactivate account')
done()
})
it('toggles activation status', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
const user = store.state.users.fetchedUsers[1]
expect(user.deactivated).toBe(false)
wrapper.find(htmlElement(2, 1)).trigger('click')
await wrapper.vm.$nextTick()
const updatedUser = store.state.users.fetchedUsers[1]
expect(updatedUser.deactivated).toBe(true)
done()
})
it('deactivates user when Delete action is called', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
const user = store.state.users.fetchedUsers[1]
expect(user.deactivated).toBe(false)
wrapper.find(htmlElement(2, 2)).trigger('click')
await wrapper.vm.$nextTick()
const updatedUser = store.state.users.fetchedUsers[1]
expect(updatedUser.deactivated).toBe(true)
done()
})
it('adds tags', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
const user1 = store.state.users.fetchedUsers[0]
const user2 = store.state.users.fetchedUsers[1]
expect(user1.tags.length).toBe(0)
expect(user2.tags.length).toBe(1)
wrapper.find(htmlElement(1, 5)).trigger('click')
await wrapper.vm.$nextTick()
wrapper.find(htmlElement(2, 5)).trigger('click')
await wrapper.vm.$nextTick()
const updatedUser1 = store.state.users.fetchedUsers[0]
const updatedUser2 = store.state.users.fetchedUsers[1]
expect(updatedUser1.tags.length).toBe(1)
expect(updatedUser2.tags.length).toBe(2)
done()
})
it('deletes tags', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
const user = store.state.users.fetchedUsers[1]
expect(user.tags.length).toBe(1)
wrapper.find(htmlElement(2, 6)).trigger('click')
await wrapper.vm.$nextTick()
const updatedUser = store.state.users.fetchedUsers[1]
expect(updatedUser.tags.length).toBe(0)
done()
})
it('shows check icon when tag is added', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
expect(wrapper.find(`${htmlElement(1, 5)} i`).exists()).toBe(false)
wrapper.find(htmlElement(1, 5)).trigger('click')
await wrapper.vm.$nextTick()
expect(wrapper.find(`${htmlElement(1, 5)} i`).exists()).toBe(true)
done()
})
it('does not change user index in array when tag is added', async (done) => {
const wrapper = mount(Users, {
store,
localVue
})
await wrapper.vm.$nextTick()
const firstUserNickname = store.state.users.fetchedUsers[0].nickname
const secondUserNickname = store.state.users.fetchedUsers[1].nickname
expect(firstUserNickname).toBe('allis')
expect(secondUserNickname).toBe('bob')
wrapper.find(htmlElement(2, 5)).trigger('click')
await wrapper.vm.$nextTick()
const firstUserNicknameAfterToggle = store.state.users.fetchedUsers[0].nickname
const secondUserNicknameAfterToggle = store.state.users.fetchedUsers[1].nickname
expect(firstUserNicknameAfterToggle).toEqual('allis')
expect(secondUserNicknameAfterToggle).toEqual('bob')
done()
})
})