diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..cef4ec5ccdc1e0b70efc0642bdb7b6dfc26a4f5f --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,26 @@ +image: node:10 + +stages: + - lint + - build + - test + +lint: + stage: lint + script: + - yarn + - yarn lint + +test: + stage: test + variables: + APT_CACHE_DIR: apt-cache + script: + - yarn + - yarn test + +build: + stage: build + script: + - yarn + - npm run build:prod diff --git a/package.json b/package.json index 60f8edb49ae39a615500ed3da940223a2fde88fe..9adb8ba88bb0b54220ab835f5aecda0ecbeec3c8 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "driver.js": "0.8.1", "dropzone": "5.2.0", "echarts": "4.1.0", - "element-ui": "^2.7.0", + "element-ui": "^2.10.0", "file-saver": "1.3.8", "fuse.js": "3.4.2", "js-cookie": "2.2.0", diff --git a/src/api/__mocks__/reports.js b/src/api/__mocks__/reports.js index 43df97e71a4c690253ec2ba937998a10a264e2a2..a337df9a091bcc738ed972f614847e08d0c8c542 100644 --- a/src/api/__mocks__/reports.js +++ b/src/api/__mocks__/reports.js @@ -1,14 +1,14 @@ const reports = [ - { created_at: '2019-05-21T21:35:33.000Z', account: { acct: 'benj', display_name: 'Benjamin Fame' }, actor: { acct: 'admin' }, state: 'open', id: '2', content: 'This is a report', statuses: [] }, - { created_at: '2019-05-20T22:45:33.000Z', account: { acct: 'alice', display_name: 'Alice Pool' }, actor: { acct: 'admin2' }, state: 'resolved', id: '1', content: 'Please block this user', statuses: [] }, - { created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys' }, actor: { acct: 'admin' }, state: 'closed', id: '3', content: '', statuses: [] }, - { created_at: '2019-05-21T21:35:33.000Z', account: { acct: 'benj', display_name: 'Benjamin Fame' }, actor: { acct: 'admin' }, state: 'open', id: '5', content: 'This is a report', statuses: [] }, - { created_at: '2019-05-20T22:45:33.000Z', account: { acct: 'alice', display_name: 'Alice Pool' }, actor: { acct: 'admin2' }, state: 'resolved', id: '7', content: 'Please block this user', statuses: [ + { created_at: '2019-05-21T21:35:33.000Z', account: { acct: 'benj', display_name: 'Benjamin Fame', tags: [] }, actor: { acct: 'admin' }, state: 'open', id: '2', content: 'This is a report', statuses: [] }, + { created_at: '2019-05-20T22:45:33.000Z', account: { acct: 'alice', display_name: 'Alice Pool', tags: [] }, actor: { acct: 'admin2' }, state: 'resolved', id: '1', content: 'Please block this user', statuses: [] }, + { created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys', tags: [] }, actor: { acct: 'admin' }, state: 'closed', id: '3', content: '', statuses: [] }, + { created_at: '2019-05-21T21:35:33.000Z', account: { acct: 'benj', display_name: 'Benjamin Fame', tags: [] }, actor: { acct: 'admin' }, state: 'open', id: '5', content: 'This is a report', statuses: [] }, + { created_at: '2019-05-20T22:45:33.000Z', account: { acct: 'alice', display_name: 'Alice Pool', tags: [] }, actor: { acct: 'admin2' }, state: 'resolved', id: '7', content: 'Please block this user', statuses: [ { account: { display_name: 'Alice Pool', avatar: '' }, visibility: 'public', sensitive: false, id: '11', content: 'Hey!', url: '', created_at: '2019-05-10T21:35:33.000Z' }, { account: { display_name: 'Alice Pool', avatar: '' }, visibility: 'unlisted', sensitive: true, id: '10', content: 'Bye!', url: '', created_at: '2019-05-10T21:00:33.000Z' } ] }, - { created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys' }, actor: { acct: 'admin' }, state: 'closed', id: '6', content: '', statuses: [] }, - { created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys' }, actor: { acct: 'admin' }, state: 'closed', id: '4', content: '', statuses: [] } + { created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys', tags: [] }, actor: { acct: 'admin' }, state: 'closed', id: '6', content: '', statuses: [] }, + { created_at: '2019-05-18T13:01:33.000Z', account: { acct: 'nick', display_name: 'Nick Keys', tags: [] }, actor: { acct: 'admin' }, state: 'closed', id: '4', content: '', statuses: [] } ] export async function fetchReports(limit, max_id, authHost, token) { diff --git a/src/api/users.js b/src/api/users.js index a751302af80ed20a052ae0519e4e5c4e8c083bf1..dd75045f62ff7520780281a91d218d00b18b8af6 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -39,6 +39,15 @@ export async function deleteUser(nickname, authHost, token) { }) } +export async function fetchUser(id, authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/users/${id}`, + method: 'get', + headers: authHeaders(token) + }) +} + export async function fetchUsers(filters, authHost, token, page = 1) { return await request({ baseURL: baseName(authHost), @@ -86,4 +95,13 @@ export async function untagUser(nicknames, tags, authHost, token) { }) } +export async function fetchUserStatuses(id, authHost, godmode, token) { + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/users/${id}/statuses?godmode=${godmode}`, + method: 'get', + headers: authHeaders(token) + }) +} + const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {} diff --git a/src/lang/en.js b/src/lang/en.js index 0897740fc4a55d1be3dd304cc47324276049b0a6..b8cfa4c6d0d4a3682bed7457443de4dd33bff5ba 100644 --- a/src/lang/en.js +++ b/src/lang/en.js @@ -221,7 +221,16 @@ export default { emptyPasswordError: 'Please input the password', emptyNicknameError: 'Please input the username', invalidNicknameError: 'Username can include "a-z", "A-Z" and "0-9" characters' - + }, + userProfile: { + tags: 'Tags', + moderator: 'Moderator', + admin: 'Admin', + local: 'Local', + nickname: 'Nickname', + deactivated: 'Deactivated', + recentStatuses: 'Recent Statues', + showPrivateStatuses: 'Show private statuses' }, usersFilter: { inputPlaceholder: 'Select filter', @@ -245,8 +254,9 @@ export default { deleteCompleted: 'Delete comleted', deleteCanceled: 'Delete canceled', noNotes: 'No notes to display', - changeState: 'Change state', + changeState: 'Change report state', changeScope: 'Change scope', + moderateUser: 'Moderate user', resolve: 'Resolve', reopen: 'Reopen', close: 'Close', diff --git a/src/router/index.js b/src/router/index.js index 98f9e414e21a029661a2aab80422da4a17f4eb0a..77bb975873c2fae8b1cc5c671a7ac5281a4d5f0f 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -76,5 +76,17 @@ export const asyncRouterMap = [ } ] }, + { + path: '/users/:id', + component: Layout, + children: [ + { + path: '', + name: 'UsersShow', + component: () => import('@/views/users/show') + } + ], + hidden: true + }, { path: '*', redirect: '/404', hidden: true } ] diff --git a/src/store/index.js b/src/store/index.js index 119c4c976f6be09a3b849424bbbc2ddfc27d2b0e..84e93f949bda18a69d42aa4e0d9e1f252bbfc2fa 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -6,6 +6,7 @@ import permission from './modules/permission' import reports from './modules/reports' import tagsView from './modules/tagsView' import user from './modules/user' +import userProfile from './modules/userProfile' import users from './modules/users' import getters from './getters' @@ -19,6 +20,7 @@ const store = new Vuex.Store({ reports, tagsView, user, + userProfile, users }, getters diff --git a/src/store/modules/userProfile.js b/src/store/modules/userProfile.js new file mode 100644 index 0000000000000000000000000000000000000000..2dd656e688569514b4192a10d6c7b044059748fd --- /dev/null +++ b/src/store/modules/userProfile.js @@ -0,0 +1,36 @@ +import { fetchUser, fetchUserStatuses } from '@/api/users' + +const userProfile = { + state: { + user: {}, + loading: true, + statuses: [] + }, + mutations: { + SET_USER: (state, user) => { + state.user = user + }, + SET_LOADING: (state, status) => { + state.loading = status + }, + SET_STATUSES: (state, statuses) => { + state.statuses = statuses + } + }, + actions: { + async FetchData({ commit, getters }, { id, godmode }) { + commit('SET_LOADING', true) + + const [userResponse, statusesResponse] = await Promise.all([ + fetchUser(id, getters.authHost, getters.token), + fetchUserStatuses(id, getters.authHost, godmode, getters.token) + ]) + + commit('SET_USER', userResponse.data) + commit('SET_STATUSES', statusesResponse.data) + commit('SET_LOADING', false) + } + } +} + +export default userProfile diff --git a/src/store/modules/users.js b/src/store/modules/users.js index 80fb0c223ff2e14499d2cb2c17bcc54daa5d34d1..947159548da9ef642a3b39fcbc0d277bdba85582 100644 --- a/src/store/modules/users.js +++ b/src/store/modules/users.js @@ -48,6 +48,9 @@ const users = { }, SET_USERS_FILTERS: (state, filters) => { state.filters = filters + }, + SET_USER_PROFILE: (state, user) => { + state.userProfile = user } }, actions: { diff --git a/src/views/reports/components/ReportsFilter.vue b/src/views/reports/components/ReportsFilter.vue index 263d2eca773b5809abda607d1262cd0b87105b30..40c232165fba4fb8b8be9b1678bfe00cf02ab147 100644 --- a/src/views/reports/components/ReportsFilter.vue +++ b/src/views/reports/components/ReportsFilter.vue @@ -4,20 +4,42 @@ :placeholder="$t('reportsFilter.inputPlaceholder')" clearable class="select-field" + value-key="value" @change="toggleFilters"> - <el-option value="open">{{ $t('reportsFilter.open') }}</el-option> - <el-option value="closed">{{ $t('reportsFilter.closed') }}</el-option> - <el-option value="resolved">{{ $t('reportsFilter.resolved') }}</el-option> + <el-option + v-for="item in options" + :key="item.value" + :label="item.label" + :value="item.value">{{ item.label }}</el-option> </el-select> </template> <script> +import i18n from '@/lang' + export default { data() { return { - filter: [] + filter: 'open', + options: [ + { + value: 'open', + label: i18n.t('reportsFilter.open') + }, + { + value: 'closed', + label: i18n.t('reportsFilter.closed') + }, + { + value: 'resolved', + label: i18n.t('reportsFilter.resolved') + } + ] } }, + created() { + this.$store.dispatch('SetFilter', this.$data.filter) + }, methods: { toggleFilters() { this.$store.dispatch('SetFilter', this.$data.filter) diff --git a/src/views/reports/components/TimelineItem.vue b/src/views/reports/components/TimelineItem.vue index 63cc2c2698d3b84491731aab782639a94816df5e..771298aa7a807c0ec352a4582c699a419727231a 100644 --- a/src/views/reports/components/TimelineItem.vue +++ b/src/views/reports/components/TimelineItem.vue @@ -16,6 +16,60 @@ <el-dropdown-item v-if="report.state !== 'closed'" @click.native="changeReportState('closed', report.id)">{{ $t('reports.close') }}</el-dropdown-item> </el-dropdown-menu> </el-dropdown> + <el-dropdown trigger="click"> + <el-button plain size="small" icon="el-icon-files">{{ $t('reports.moderateUser') }}<i class="el-icon-arrow-down el-icon--right"/></el-button> + <el-dropdown-menu slot="dropdown"> + <el-dropdown-item + v-if="showDeactivatedButton(report.account)" + @click.native="handleDeactivation(report.account)"> + {{ report.account.deactivated ? $t('users.activateAccount') : $t('users.deactivateAccount') }} + </el-dropdown-item> + <el-dropdown-item + v-if="showDeactivatedButton(report.account.id)" + @click.native="handleDeletion(report.account.id)"> + {{ $t('users.deleteAccount') }} + </el-dropdown-item> + <el-dropdown-item + :divided="true" + :class="{ 'active-tag': report.account.tags.includes('force_nsfw') }" + @click.native="toggleTag(report.account, 'force_nsfw')"> + {{ $t('users.forceNsfw') }} + <i v-if="report.account.tags.includes('force_nsfw')" class="el-icon-check"/> + </el-dropdown-item> + <el-dropdown-item + :class="{ 'active-tag': report.account.tags.includes('strip_media') }" + @click.native="toggleTag(report.account, 'strip_media')"> + {{ $t('users.stripMedia') }} + <i v-if="report.account.tags.includes('strip_media')" class="el-icon-check"/> + </el-dropdown-item> + <el-dropdown-item + :class="{ 'active-tag': report.account.tags.includes('force_unlisted') }" + @click.native="toggleTag(report.account, 'force_unlisted')"> + {{ $t('users.forceUnlisted') }} + <i v-if="report.account.tags.includes('force_unlisted')" class="el-icon-check"/> + </el-dropdown-item> + <el-dropdown-item + :class="{ 'active-tag': report.account.tags.includes('sandbox') }" + @click.native="toggleTag(report.account, 'sandbox')"> + {{ $t('users.sandbox') }} + <i v-if="report.account.tags.includes('sandbox')" class="el-icon-check"/> + </el-dropdown-item> + <el-dropdown-item + v-if="report.account.local" + :class="{ 'active-tag': report.account.tags.includes('disable_remote_subscription') }" + @click.native="toggleTag(report.account, 'disable_remote_subscription')"> + {{ $t('users.disableRemoteSubscription') }} + <i v-if="report.account.tags.includes('disable_remote_subscription')" class="el-icon-check"/> + </el-dropdown-item> + <el-dropdown-item + v-if="report.account.local" + :class="{ 'active-tag': report.account.tags.includes('disable_any_subscription') }" + @click.native="toggleTag(report.account, 'disable_any_subscription')"> + {{ $t('users.disableAnySubscription') }} + <i v-if="report.account.tags.includes('disable_any_subscription')" class="el-icon-check"/> + </el-dropdown-item> + </el-dropdown-menu> + </el-dropdown> </div> </div> <div> @@ -86,7 +140,21 @@ export default { } }, parseTimestamp(timestamp) { - return moment(timestamp).format('YYYY-MM-DD HH:mm') + return moment(timestamp).format('L HH:mm') + }, + showDeactivatedButton(id) { + return this.$store.state.user.id !== id + }, + handleDeactivation({ nickname }) { + this.$store.dispatch('ToggleUserActivation', nickname) + }, + handleDeletion(user) { + this.$store.dispatch('DeleteUser', user) + }, + toggleTag(user, tag) { + user.tags.includes(tag) + ? this.$store.dispatch('RemoveTag', { users: [user], tag }) + : this.$store.dispatch('AddTag', { users: [user], tag }) } } } diff --git a/src/views/users/index.vue b/src/views/users/index.vue index c7f9ba9452f26907f74ee9d3fd936c9b7cb4b8f2..47856e9539a04e0238c58a4910a0958e8dfc8371 100644 --- a/src/views/users/index.vue +++ b/src/views/users/index.vue @@ -39,7 +39,7 @@ <el-table-column :min-width="width" :label="$t('users.id')" prop="id" /> <el-table-column :label="$t('users.name')" prop="nickname"> <template slot-scope="scope"> - {{ scope.row.nickname }} + <router-link :to="{ name: 'UsersShow', params: { id: scope.row.id }}">{{ scope.row.nickname }}</router-link> <el-tag v-if="isDesktop" type="info" size="mini"> <span>{{ scope.row.local ? $t('users.local') : $t('users.external') }}</span> </el-tag> diff --git a/src/views/users/show.vue b/src/views/users/show.vue new file mode 100644 index 0000000000000000000000000000000000000000..049ae02fd5f15838351ac55c9c25d4905ae9de8a --- /dev/null +++ b/src/views/users/show.vue @@ -0,0 +1,179 @@ +<template> + <main v-if="!loading"> + <header> + <el-avatar :src="user.avatar" size="large" /> + <h1>{{ user.display_name }}</h1> + </header> + <el-row> + <el-col :span="6"> + <div class="el-table el-table--fit el-table--enable-row-hover el-table--enable-row-transition el-table--medium"> + <table class="el-table__body"> + <tbody> + <tr class="el-table__row"> + <td class="name-col">ID</td> + <td class="value-col"> + {{ user.id }} + </td> + </tr> + <tr class="el-table__row"> + <td>{{ $t('userProfile.tags') }}</td> + <td> + <el-tag v-for="tag in user.tags" :key="tag">{{ tag }}</el-tag> + <span v-if="user.tags.length === 0">None</span> + </td> + </tr> + <tr class="el-table__row"> + <td>{{ $t('userProfile.moderator') }}</td> + <td> + <el-tag v-if="user.roles.moderator" type="success"><i class="el-icon-check" /></el-tag> + <el-tag v-if="!user.roles.moderator" type="danger"><i class="el-icon-error" /></el-tag> + </td> + </tr> + <tr class="el-table__row"> + <td>{{ $t('userProfile.admin') }}</td> + <td> + <el-tag v-if="user.roles.admin" type="success"><i class="el-icon-check" /></el-tag> + <el-tag v-if="!user.roles.admin" type="danger"><i class="el-icon-error" /></el-tag> + </td> + </tr> + <tr class="el-table__row"> + <td>{{ $t('userProfile.local') }}</td> + <td> + <el-tag v-if="user.local" type="success"><i class="el-icon-check" /></el-tag> + <el-tag v-if="!user.local" type="danger"><i class="el-icon-error" /></el-tag> + </td> + </tr> + <tr class="el-table__row"> + <td>{{ $t('userProfile.deactivated') }}</td> + <td> + <el-tag v-if="user.deactivated" type="success"><i class="el-icon-check" /></el-tag> + <el-tag v-if="!user.deactivated" type="danger"><i class="el-icon-error" /></el-tag> + </td> + </tr> + <tr class="el-table__row"> + <td>{{ $t('userProfile.nickname') }}</td> + <td> + {{ user.nickname }} + </td> + </tr> + </tbody> + </table> + </div> + </el-col> + <el-row type="flex" class="row-bg" justify="space-between"> + <el-col :span="18"><h2>{{ $t('userProfile.recentStatuses') }}</h2></el-col> + <el-col :span="6" class="show-private"> + <el-checkbox v-model="showPrivate" @change="onTogglePrivate"> + {{ $t('userProfile.showPrivateStatuses') }} + </el-checkbox> + </el-col> + </el-row> + <el-col :span="18"> + <el-timeline class="statuses"> + <el-timeline-item v-for="status in statuses" :timestamp="createdAtLocaleString(status.created_at)" :key="status.id"> + <el-card> + <strong v-if="status.spoiler_text">{{ status.spoiler_text }}</strong> + <p v-if="status.content" v-html="status.content" /> + <div v-if="status.poll" class="poll"> + <ul> + <li v-for="(option, index) in status.poll.options" :key="index"> + {{ option.title }} + <el-progress :percentage="optionPercent(status.poll, option)" /> + </li> + </ul> + </div> + <div v-for="(attachment, index) in status.media_attachments" :key="index" class="image"> + <img :src="attachment.preview_url"> + </div> + </el-card> + </el-timeline-item> + </el-timeline> + </el-col> + </el-row> + </main> +</template> + +<script> +export default { + name: 'UsersShow', + data() { + return { + showPrivate: false + } + }, + computed: { + loading() { + return this.$store.state.userProfile.loading + }, + user() { + return this.$store.state.userProfile.user + }, + statuses() { + return this.$store.state.userProfile.statuses + } + }, + mounted: function() { + this.$store.dispatch('FetchData', { id: this.$route.params.id, godmode: false }) + }, + methods: { + optionPercent(poll, pollOption) { + const allVotes = poll.options.reduce((acc, option) => (acc + option.votes_count), 0) + if (allVotes === 0) { + return 0 + } + + return +(pollOption.votes_count / allVotes * 100).toFixed(1) + }, + createdAtLocaleString(createdAt) { + const date = new Date(createdAt) + + return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}` + }, + onTogglePrivate() { + console.log(this.showPrivate) + + this.$store.dispatch('FetchData', { id: this.$route.params.id, godmode: this.showPrivate }) + } + } +} +</script> + +<style rel='stylesheet/scss' lang='scss' scoped> +header { + align-items: center; + display: flex; + margin: 22px 0; + padding-left: 15px; + h1 { + margin: 0 0 0 10px; + } +} +table { + margin: 10px 0 0 15px; + .name-col { + width: 150px; + } +} +.el-table--border::after, .el-table--group::after, .el-table::before { + background-color: transparent; +} +.poll ul { + list-style-type: none; + padding: 0; + width: 30%; +} +.image { + width: 20%; + img { + width: 100%; + } +} +.statuses { + padding-right: 20px; +} +.show-private { + text-align: right; + line-height: 67px; + padding-right: 20px; +} +</style> diff --git a/yarn.lock b/yarn.lock index fc08d937a5b02812f455dc5d29560bb908eb2c9f..c687a794e5374d70981a3d09ac2a5502348522a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3446,10 +3446,10 @@ elegant-spinner@^1.0.1: resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4= -element-ui@^2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/element-ui/-/element-ui-2.7.0.tgz#6bfcdfa5c75bfc4cda835186f2a1f98b93cd5d14" - integrity sha512-FalWzOmT/K4w4C/8tw2kGvzzQnRJ5MqEvSL5rEKNa081PFGIcUS9exyVpYrNPKF8ua/W6qaqrXPC6DQ8sNcmOQ== +element-ui@^2.10.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/element-ui/-/element-ui-2.10.0.tgz#e6129f6b6d6ffe0dbad125a4a8d17d447a5f639c" + integrity sha512-uthsnJ1CIdQvLWphr67uwFSfSYoRBjxFcEhXhy+2/EwKNsqO7MRN+mYqroNLz5WJuLqVy1aOpJ8Lv4B32qKthQ== dependencies: async-validator "~1.8.1" babel-helper-vue-jsx-merge-props "^2.0.0"