Merge branch 'develop' into 'feature/do-not-show-users-with-null-nicknames'

# Conflicts:
#   src/components/Status/index.vue
#   src/views/users/components/ModerationDropdown.vue
#   src/views/users/show.vue
This commit is contained in:
Angelina Filippova 2020-06-06 21:10:12 +00:00
commit 12bac96c9d
19 changed files with 757 additions and 191 deletions

View file

@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
### Added
- Create `/statuses/:id` route that shows single status
### Changed
- Statuses count changes when an instance is selected and shows the amount of statuses from an originating instance
@ -18,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Fixed
- Send `true` and `false` as booleans if they are values of single selects on the Settings page
- Fix sorting users on Users page if there is an acount with missing nickname or ID
## [2.0.3] - 2020-04-29

View file

@ -6,6 +6,29 @@ export async function deleteStatus(id, authHost, token) {
return Promise.resolve()
}
export async function fetchStatus(id, authHost, token) {
const data = {
account: {
id: '9n1bySks25olxWrku0',
avatar: 'http://localhost:4000/images/avi.png',
display_name: 'dolin',
tags: ['strip_media', 'sandbox', 'disable_any_subscription', 'force_nsfw'],
url: 'http://localhost:4000/users/dolin'
},
content: 'pizza makes everything better',
created_at: '2020-05-22T17:34:34.000Z',
id: '9vJOO3iFPyjNaEhJ5s',
media_attachments: [],
poll: null,
sensitive: false,
spoiler_text: '',
visibility: 'public',
url: 'http://localhost:4000/notice/9vJOO3iFPyjNaEhJ5s'
}
return Promise.resolve({ data })
}
export async function fetchStatusesByInstance({ instance, authHost, token, pageSize, page }) {
let data
if (pageSize === 1) {

View file

@ -6,7 +6,11 @@ export let users = [
const userProfile = { avatar: 'avatar.jpg', nickname: 'allis', id: '2', tags: [], roles: { admin: true, moderator: false }, local: true, external: false }
const userStatuses = []
const userStatuses = [
{ account: { id: '9n1bySks25olxWrku0', display_name: 'dolin' }, content: 'pizza makes everything better', id: '9vJOO3iFPyjNaEhJ5s', created_at: '2020-05-22T17:34:34.000Z', visibility: 'public' },
{ account: { id: '9n1bySks25olxWrku0', display_name: 'dolin' }, content: 'pizza time', id: '9vJPD5XKOdzQ0bvGLY', created_at: '2020-05-22T17:34:34.000Z', visibility: 'public' },
{ account: { id: '9n1bySks25olxWrku0', display_name: 'dolin' }, content: 'what is yout favorite pizza?', id: '9jop82OBXeFPYulVjM', created_at: '2020-05-22T17:34:34.000Z', visibility: 'public' }
]
const filterUsers = (str) => {
const filters = str.split(',').filter(item => item.length > 0)

View file

@ -21,6 +21,15 @@ export async function deleteStatus(id, authHost, token) {
})
}
export async function fetchStatus(id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'get',
headers: authHeaders(token)
})
}
export async function fetchStatuses({ godmode, localOnly, authHost, token, pageSize, page }) {
return await request({
baseURL: baseName(authHost),

View file

@ -1,86 +1,74 @@
<template>
<div>
<el-card v-if="!status.deleted" class="status-card">
<div slot="header">
<div class="status-header">
<div class="status-account-container">
<div class="status-account">
<el-checkbox v-if="showCheckbox" class="status-checkbox" @change="handleStatusSelection(account)"/>
<img v-if="propertyExists(account, 'avatar')" :src="account.avatar" class="status-avatar-img">
<a v-if="propertyExists(account, 'url', 'nickname')" :href="account.url" target="_blank" class="account">
<span class="status-account-name">{{ account.nickname }}</span>
</a>
<span v-else>
<span v-if="propertyExists(account, 'nickname')" class="status-account-name">
{{ account.nickname }}
<el-card v-if="!status.deleted" class="status-card" @click.native="handleRouteChange()">
<div slot="header">
<div class="status-header">
<div class="status-account-container">
<div class="status-account">
<el-checkbox v-if="showCheckbox" class="status-checkbox" @change="handleStatusSelection(account)"/>
<router-link v-if="propertyExists(account, 'id')" :to="{ name: 'UsersShow', params: { id: account.id }}" @click.native.stop>
<div class="status-card-header">
<img v-if="propertyExists(account, 'avatar')" :src="account.avatar" class="status-avatar-img">
<span v-if="propertyExists(account, 'nickname')" class="status-account-name">{{ account.nickname }}</span>
<span v-else>
<span v-if="propertyExists(account, 'nickname')" class="status-account-name">
{{ account.nickname }}
</span>
<span v-else class="status-account-name deactivated">({{ $t('users.invalidNickname') }})</span>
</span>
<span v-else class="status-account-name deactivated">({{ $t('users.invalidNickname') }})</span>
</span>
</div>
</div>
</router-link>
</div>
<div class="status-actions">
</div>
<div class="status-actions">
<div class="status-tags">
<el-tag v-if="status.sensitive" type="warning" size="large">{{ $t('reports.sensitive') }}</el-tag>
<el-tag size="large">{{ capitalizeFirstLetter(status.visibility) }}</el-tag>
<el-dropdown trigger="click">
<el-button plain size="small" icon="el-icon-edit" class="status-actions-button">
{{ $t('reports.changeScope') }}<i class="el-icon-arrow-down el-icon--right"/>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="!status.sensitive"
@click.native="changeStatus(status.id, true, status.visibility)">
{{ $t('reports.addSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.sensitive"
@click.native="changeStatus(status.id, false, status.visibility)">
{{ $t('reports.removeSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'public'"
@click.native="changeStatus(status.id, status.sensitive, 'public')">
{{ $t('reports.public') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'private'"
@click.native="changeStatus(status.id, status.sensitive, 'private')">
{{ $t('reports.private') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'unlisted'"
@click.native="changeStatus(status.id, status.sensitive, 'unlisted')">
{{ $t('reports.unlisted') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="deleteStatus(status.id)">
{{ $t('reports.deleteStatus') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
<el-dropdown trigger="click" @click.native.stop>
<el-button plain size="small" icon="el-icon-edit" class="status-actions-button">
{{ $t('reports.changeScope') }}<i class="el-icon-arrow-down el-icon--right"/>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="!status.sensitive"
@click.native="changeStatus(status.id, true, status.visibility)">
{{ $t('reports.addSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.sensitive"
@click.native="changeStatus(status.id, false, status.visibility)">
{{ $t('reports.removeSensitive') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'public'"
@click.native="changeStatus(status.id, status.sensitive, 'public')">
{{ $t('reports.public') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'private'"
@click.native="changeStatus(status.id, status.sensitive, 'private')">
{{ $t('reports.private') }}
</el-dropdown-item>
<el-dropdown-item
v-if="status.visibility !== 'unlisted'"
@click.native="changeStatus(status.id, status.sensitive, 'unlisted')">
{{ $t('reports.unlisted') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="deleteStatus(status.id)">
{{ $t('reports.deleteStatus') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
<div class="status-body">
<div v-if="status.spoiler_text">
<strong>{{ status.spoiler_text }}</strong>
<el-button v-if="!showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = true">Show more</el-button>
<el-button v-if="showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = false">Show less</el-button>
<div v-if="showHiddenStatus">
<span class="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>
</div>
</div>
<div v-if="!status.spoiler_text">
</div>
<div class="status-body">
<div v-if="status.spoiler_text">
<strong>{{ status.spoiler_text }}</strong>
<el-button v-if="!showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = true">Show more</el-button>
<el-button v-if="showHiddenStatus" size="mini" class="show-more-button" @click="showHiddenStatus = false">Show less</el-button>
<div v-if="showHiddenStatus">
<span class="status-content" v-html="status.content"/>
<div v-if="status.poll" class="poll">
<ul>
@ -94,30 +82,52 @@
<img :src="attachment.preview_url">
</div>
</div>
<a :href="status.url" target="_blank" class="account">
{{ parseTimestamp(status.created_at) }}
</div>
<div v-if="!status.spoiler_text">
<span class="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>
</div>
<div class="status-footer">
<span class="status-created-at">{{ parseTimestamp(status.created_at) }}</span>
<a v-if="status.url" :href="status.url" target="_blank" class="account" @click.stop>
Open status in instance
<i class="el-icon-top-right"/>
</a>
</div>
</el-card>
<el-card v-else class="status-card">
<div slot="header">
<div class="status-header">
<div class="status-account-container">
<div class="status-account">
<h4 class="status-deleted">{{ $t('reports.statusDeleted') }}</h4>
</div>
</div>
</el-card>
<el-card v-else class="status-card">
<div slot="header">
<div class="status-header">
<div class="status-account-container">
<div class="status-account">
<h4 class="status-deleted">{{ $t('reports.statusDeleted') }}</h4>
</div>
</div>
</div>
<div class="status-body">
<span v-if="status.content" class="status-content" v-html="status.content"/>
<span v-else class="status-without-content">no content</span>
</div>
<a v-if="status.created_at" :href="status.url" target="_blank" class="account">
{{ parseTimestamp(status.created_at) }}
</div>
<div class="status-body">
<span v-if="status.content" class="status-content" v-html="status.content"/>
<span v-else class="status-without-content">no content</span>
</div>
<div class="status-footer">
<span v-if="status.created_at" class="status-created-at">{{ parseTimestamp(status.created_at) }}</span>
<a v-if="status.url" :href="status.url" target="_blank" class="account" @click.stop>
Open status in instance
<i class="el-icon-top-right"/>
</a>
</el-card>
</div>
</div>
</el-card>
</template>
<script>
@ -208,6 +218,9 @@ export default {
handleStatusSelection(account) {
this.$emit('status-selection', account)
},
handleRouteChange() {
this.$router.push({ name: 'StatusShow', params: { id: this.status.id }})
},
optionPercent(poll, pollOption) {
const allVotes = poll.options.reduce((acc, option) => (acc + option.votes_count), 0)
if (allVotes === 0) {
@ -231,10 +244,14 @@ export default {
<style rel='stylesheet/scss' lang='scss'>
.status-card {
margin-bottom: 10px;
cursor: pointer;
.account {
text-decoration: underline;
line-height: 26px;
font-size: 13px;
color: #606266;
}
.account:hover {
text-decoration: underline;
}
.deactivated {
color: gray;
@ -271,6 +288,10 @@ export default {
display: flex;
flex-direction: column;
}
.status-card-header {
display: flex;
align-items: center;
}
.status-checkbox {
margin-right: 7px;
}
@ -278,13 +299,26 @@ export default {
font-size: 15px;
line-height: 26px;
}
.status-created-at {
font-size: 13px;
color: #606266;
}
.status-deleted {
font-style: italic;
margin-top: 3px;
}
.status-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.status-tags {
display: inline;
}
.status-without-content {
font-style: italic;
@ -303,7 +337,7 @@ export default {
padding: 10px 17px;
}
.el-tag {
margin: 3px 4px 3px 0;
margin: 3px 0;
}
.status-account-container {
margin-bottom: 5px;
@ -312,12 +346,20 @@ export default {
margin: 3px 0 3px;
}
.status-actions {
width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.status-footer {
flex-direction: column;
align-items: flex-start;
margin-top: 10px;
}
.status-header {
display: flex;
flex-direction: column;
align-items: flex-start;
}
}
}

View file

@ -171,16 +171,16 @@ export default {
id: 'ID',
name: 'Name',
status: 'Status',
local: 'local',
external: 'external',
deactivated: 'deactivated',
active: 'active',
unconfirmed: 'unconfirmed',
local: 'Local',
external: 'External',
deactivated: 'Deactivated',
active: 'Active',
unconfirmed: 'Unconfirmed',
actions: 'Actions',
activate: 'Activate',
deactivate: 'Deactivate',
admin: 'admin',
moderator: 'moderator',
admin: 'Admin',
moderator: 'Moderator',
moderation: 'Moderation',
revokeAdmin: 'Revoke Admin',
grantAdmin: 'Grant Admin',
@ -205,8 +205,8 @@ export default {
moderateUser: 'Moderate user',
moderateUsers: 'Moderate multiple users',
createAccount: 'Create new account',
apply: 'apply',
remove: 'remove',
apply: 'Apply',
remove: 'Remove',
grantRightConfirmation: 'Are you sure you want to grant {right} rights to all selected users?',
revokeRightConfirmation: 'Are you sure you want to revoke {right} rights from all selected users?',
activateMultipleUsersConfirmation: 'Are you sure you want to activate accounts of all selected users?',
@ -258,15 +258,15 @@ export default {
tags: 'Tags',
moderator: 'Moderator',
admin: 'Admin',
local: 'local',
external: 'external',
localUppercase: 'Local',
local: 'Local',
external: 'External',
accountType: 'Account type',
nickname: 'Nickname',
recentStatuses: 'Recent Statuses',
roles: 'Roles',
activeUppercase: 'Active',
active: 'active',
deactivated: 'deactivated',
active: 'Active',
status: 'Status',
deactivated: 'Deactivated',
noStatuses: 'No statuses to show',
securitySettings: {
email: 'Email',
@ -286,7 +286,7 @@ export default {
},
usersFilter: {
inputPlaceholder: 'Select filter',
byUserType: 'By user type',
byAccountType: 'By account type',
local: 'Local',
external: 'External',
byStatus: 'By status',

View file

@ -172,5 +172,17 @@ export const asyncRouterMap = [
],
hidden: true
},
{
path: '/statuses/:id',
component: Layout,
children: [
{
path: '',
name: 'StatusShow',
component: () => import('@/views/statuses/show')
}
],
hidden: true
},
{ path: '*', redirect: '/404', hidden: true }
]

View file

@ -1,9 +1,11 @@
import { changeStatusScope, deleteStatus, fetchStatuses, fetchStatusesCount, fetchStatusesByInstance } from '@/api/status'
import { changeStatusScope, deleteStatus, fetchStatus, fetchStatuses, fetchStatusesCount, fetchStatusesByInstance } from '@/api/status'
const status = {
state: {
fetchedStatus: {},
fetchedStatuses: [],
loading: false,
statusAuthor: {},
statusesByInstance: {
selectedInstance: '',
showLocal: false,
@ -28,6 +30,9 @@ const status = {
CHANGE_SELECTED_INSTANCE: (state, instance) => {
state.statusesByInstance.selectedInstance = instance
},
SET_STATUS: (state, status) => {
state.fetchedStatus = status
},
SET_STATUSES_BY_INSTANCE: (state, statuses) => {
state.fetchedStatuses = statuses
},
@ -45,6 +50,9 @@ const status = {
},
SET_STATUS_VISIBILITY: (state, visibility) => {
state.statusVisibility = visibility
},
SET_STATUS_AUTHOR: (state, user) => {
state.statusAuthor = user
}
},
actions: {
@ -56,6 +64,8 @@ const status = {
dispatch('FetchUserStatuses', { userId, godmode })
} else if (fetchStatusesByInstance) { // called from Statuses by Instance
dispatch('FetchStatusesByInstance')
} else { // called from Status show page
dispatch('FetchStatusAfterUserModeration', statusId)
}
},
ClearState({ commit }) {
@ -76,6 +86,21 @@ const status = {
dispatch('FetchStatusesByInstance')
}
},
async FetchStatus({ commit, dispatch, getters, state }, id) {
commit('SET_LOADING', true)
const status = await fetchStatus(id, getters.authHost, getters.token)
commit('SET_STATUS', status.data)
commit('SET_STATUS_AUTHOR', status.data.account)
commit('SET_LOADING', false)
dispatch('FetchUserStatuses', { userId: state.fetchedStatus.account.id, godmode: false })
},
FetchStatusAfterUserModeration({ commit, dispatch, getters, state }, id) {
commit('SET_LOADING', true)
fetchStatus(id, getters.authHost, getters.token)
.then(status => dispatch('SetStatus', status.data))
commit('SET_LOADING', false)
},
async FetchStatusesCount({ commit, getters }, instance) {
commit('SET_LOADING', true)
const { data } = await fetchStatusesCount(instance, getters.authHost, getters.token)
@ -159,6 +184,10 @@ const status = {
},
HandlePageChange({ commit }, page) {
commit('CHANGE_PAGE', page)
},
SetStatus({ commit }, status) {
commit('SET_STATUS', status)
commit('SET_STATUS_AUTHOR', status.account)
}
}
}

View file

@ -35,18 +35,21 @@ const userProfile = {
dispatch('FetchUserStatuses', { userId, godmode })
},
async FetchUserStatuses({ commit, getters }, { userId, godmode }) {
FetchUserStatuses({ commit, dispatch, getters }, { userId, godmode }) {
commit('SET_STATUSES_LOADING', true)
const statuses = await fetchUserStatuses(userId, getters.authHost, godmode, getters.token)
fetchUserStatuses(userId, getters.authHost, godmode, getters.token)
.then(statuses => dispatch('SetStatuses', statuses.data))
commit('SET_STATUSES', statuses.data)
commit('SET_STATUSES_LOADING', false)
},
async FetchUserCredentials({ commit, getters }, { nickname }) {
const userResponse = await fetchUserCredentials(nickname, getters.authHost, getters.token)
commit('SET_USER_CREDENTIALS', userResponse.data)
},
SetStatuses({ commit }, statuses) {
commit('SET_STATUSES', statuses)
},
async UpdateUserCredentials({ dispatch, getters }, { nickname, credentials }) {
await updateUserCredentials(nickname, credentials, getters.authHost, getters.token)
dispatch('FetchUserCredentials', { nickname })

View file

@ -24,6 +24,7 @@ const users = {
searchQuery: '',
totalUsersCount: 0,
currentPage: 1,
pageSize: 50,
filters: {
local: false,
external: false,
@ -75,9 +76,6 @@ const users = {
},
SET_USERS_FILTERS: (state, filters) => {
state.filters = filters
},
SET_USER_PROFILE: (state, user) => {
state.userProfile = user
}
},
actions: {
@ -90,7 +88,7 @@ const users = {
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId })
},
async ApplyChanges({ commit, dispatch, state }, { updatedUsers, callApiFn, userId }) {
async ApplyChanges({ commit, dispatch, state }, { updatedUsers, callApiFn, userId, statusId }) {
commit('SWAP_USERS', updatedUsers)
try {
@ -100,29 +98,30 @@ const users = {
} finally {
dispatch('SearchUsers', { query: state.searchQuery, page: state.currentPage })
}
if (userId) {
if (statusId) {
dispatch('FetchStatusAfterUserModeration', statusId)
} else if (userId) {
dispatch('FetchUserProfile', { userId, godmode: false })
}
dispatch('SuccessMessage')
},
async AddRight({ dispatch, getters }, { users, right, _userId }) {
async AddRight({ dispatch, getters }, { users, right, _userId, _statusId }) {
const updatedUsers = users.map(user => {
return user.local ? { ...user, roles: { ...user.roles, [right]: true }} : user
})
const nicknames = users.map(user => user.nickname)
const callApiFn = async() => await addRight(nicknames, right, getters.authHost, getters.token)
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId })
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId })
},
async AddTag({ dispatch, getters }, { users, tag, _userId }) {
async AddTag({ dispatch, getters }, { users, tag, _userId, _statusId }) {
const updatedUsers = users.map(user => {
return { ...user, tags: [...user.tags, tag] }
})
const nicknames = users.map(user => user.nickname)
const callApiFn = async() => await tagUser(nicknames, [tag], getters.authHost, getters.token)
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId })
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId })
},
ClearUsersState({ commit }) {
commit('SET_SEARCH_QUERY', '')
@ -151,14 +150,14 @@ const users = {
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId })
},
async ConfirmUsersEmail({ dispatch, getters }, { users, _userId }) {
async ConfirmUsersEmail({ dispatch, getters }, { users, _userId, _statusId }) {
const updatedUsers = users.map(user => {
return { ...user, confirmation_pending: false }
})
const nicknames = users.map(user => user.nickname)
const callApiFn = async() => await confirmUserEmail(nicknames, getters.authHost, getters.token)
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId })
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId })
},
async ResendConfirmationEmail({ dispatch, getters }, users) {
const usersNicknames = users.map(user => user.nickname)
@ -169,14 +168,14 @@ const users = {
}
dispatch('SuccessMessage')
},
async DeleteRight({ dispatch, getters }, { users, right, _userId }) {
async DeleteRight({ dispatch, getters }, { users, right, _userId, _statusId }) {
const updatedUsers = users.map(user => {
return user.local ? { ...user, roles: { ...user.roles, [right]: false }} : user
})
const nicknames = users.map(user => user.nickname)
const callApiFn = async() => await deleteRight(nicknames, right, getters.authHost, getters.token)
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId })
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId })
},
async DeleteUsers({ commit, dispatch, getters, state }, { users, _userId }) {
const usersNicknames = users.map(user => user.nickname)
@ -206,14 +205,14 @@ const users = {
RemovePasswordToken({ commit }) {
commit('SET_PASSWORD_RESET_TOKEN', { link: '', token: '' })
},
async RemoveTag({ dispatch, getters }, { users, tag, _userId }) {
async RemoveTag({ dispatch, getters }, { users, tag, _userId, _statusId }) {
const updatedUsers = users.map(user => {
return { ...user, tags: user.tags.filter(userTag => userTag !== tag) }
})
const nicknames = users.map(user => user.nickname)
const callApiFn = async() => await untagUser(nicknames, [tag], getters.authHost, getters.token)
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId })
dispatch('ApplyChanges', { updatedUsers, callApiFn, userId: _userId, statusId: _statusId })
},
async RequirePasswordReset({ dispatch, getters }, users) {
const nicknames = users.map(user => user.nickname)

273
src/views/statuses/show.vue Normal file
View file

@ -0,0 +1,273 @@
<template>
<div v-if="!loading" class="status-show-container">
<header v-if="isDesktop || isTablet" class="user-page-header">
<div class="avatar-name-container">
<router-link :to="{ name: 'UsersShow', params: { id: user.id }}">
<div class="avatar-name-header">
<el-avatar v-if="accountExists(user, 'avatar')" :src="user.avatar" size="large" />
<h1 v-if="accountExists(user, 'display_name')">{{ user.display_name }}</h1>
</div>
</router-link>
<a v-if="accountExists(user, 'url')" :href="user.url" target="_blank" class="account">
<i class="el-icon-top-right" title="Open user in instance"/>
</a>
</div>
<div class="left-header-container">
<moderation-dropdown
:user="user"
:page="'statusPage'"
:status-id="status.id"
@open-reset-token-dialog="openResetPasswordDialog"/>
<reboot-button/>
</div>
</header>
<div v-if="isMobile" class="status-page-header-container">
<header class="user-page-header">
<div class="avatar-name-container">
<el-avatar v-if="accountExists(user, 'avatar')" :src="user.avatar" size="large" />
<h1 v-if="accountExists(user, 'display_name')">{{ user.display_name }}</h1>
</div>
<reboot-button/>
</header>
<moderation-dropdown
:user="user"
:page="'userPage'"
@open-reset-token-dialog="openResetPasswordDialog"/>
</div>
<reset-password-dialog
:reset-password-dialog-open="resetPasswordDialogOpen"
@close-reset-token-dialog="closeResetPasswordDialog"/>
<div class="status-container">
<status :status="status" :account="user" :show-checkbox="false" :godmode="showPrivate"/>
</div>
<div class="recent-statuses-container-show">
<h2 class="recent-statuses">{{ $t('userProfile.recentStatuses') }} by {{ user.display_name }}</h2>
<el-checkbox v-model="showPrivate" class="show-private-statuses" @change="onTogglePrivate">
{{ $t('statuses.showPrivateStatuses') }}
</el-checkbox>
<el-timeline v-if="!statusesLoading" class="statuses">
<el-timeline-item v-for="status in statuses" :key="status.id">
<status :status="status" :account="status.account" :show-checkbox="false" :user-id="user.id" :godmode="showPrivate"/>
</el-timeline-item>
<p v-if="statuses.length === 0" class="no-statuses">{{ $t('userProfile.noStatuses') }}</p>
</el-timeline>
</div>
</div>
</template>
<script>
import Status from '@/components/Status'
import ModerationDropdown from '../users/components/ModerationDropdown'
import RebootButton from '@/components/RebootButton'
import ResetPasswordDialog from '@/views/users/components/ResetPasswordDialog'
export default {
name: 'StatusShow',
components: { ModerationDropdown, RebootButton, ResetPasswordDialog, Status },
data() {
return {
showPrivate: false,
resetPasswordDialogOpen: false
}
},
computed: {
isDesktop() {
return this.$store.state.app.device === 'desktop'
},
isMobile() {
return this.$store.state.app.device === 'mobile'
},
isTablet() {
return this.$store.state.app.device === 'tablet'
},
loading() {
return this.$store.state.status.loading
},
status() {
return this.$store.state.status.fetchedStatus
},
statuses() {
return this.$store.state.userProfile.statuses
},
statusesLoading() {
return this.$store.state.userProfile.statusesLoading
},
user() {
return this.$store.state.status.statusAuthor
}
},
beforeMount: function() {
this.$store.dispatch('NeedReboot')
this.$store.dispatch('GetNodeInfo')
this.$store.dispatch('FetchStatus', this.$route.params.id)
},
methods: {
accountExists(account, key) {
return account[key]
},
closeResetPasswordDialog() {
this.resetPasswordDialogOpen = false
this.$store.dispatch('RemovePasswordToken')
},
onTogglePrivate() {
this.$store.dispatch('FetchUserStatuses', { userId: this.user.id, godmode: this.showPrivate })
},
openResetPasswordDialog() {
this.resetPasswordDialogOpen = true
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.avatar-name-container {
display: flex;
align-items: center;
.el-icon-top-right {
font-size: 2em;
line-height: 36px;
color: #606266;
}
}
.avatar-name-header {
display: flex;
height: 40px;
align-items: center;
}
.no-statuses {
margin-left: 28px;
color: #606266;
}
.password-reset-token {
margin: 0 0 14px 0;
}
.password-reset-token-dialog {
width: 50%
}
.reboot-button {
padding: 10px;
margin-left: 6px;
}
.recent-statuses-container-show {
display: flex;
flex-direction: column;
.el-timeline-item {
margin-left: 20px;
}
.recent-statuses {
margin-left: 20px;
}
.show-private-statuses {
margin-left: 20px;
margin-bottom: 20px;
}
}
.reset-password-link {
text-decoration: underline;
}
.status-container {
margin: 0 15px 0 20px;
}
.statuses {
padding: 0 20px 0 0;
}
.user-page-header {
display: flex;
justify-content: space-between;
margin: 22px 15px 22px 20px;
align-items: center;
h1 {
display: inline;
margin: 0 0 0 10px;
}
}
@media only screen and (min-width: 1824px) {
.status-show-container {
max-width: 1824px;
margin: auto;
}
}
@media only screen and (max-width:480px) {
.avatar-name-container {
margin-bottom: 10px;
}
.el-timeline-item__wrapper {
padding-left: 18px;
}
.left-header-container {
align-items: center;
display: flex;
justify-content: space-between;
}
.password-reset-token-dialog {
width: 85%
}
.recent-statuses {
margin: 20px 10px 15px 10px;
}
.recent-statuses-container-show {
width: 100%;
margin: 0 0 0 10px;
.el-timeline-item {
margin-left: 0;
}
.recent-statuses {
margin-left: 0;
}
.show-private-statuses {
margin: 0 10px 20px 0;
}
}
.status-card {
.el-card__body {
padding: 15px;
}
}
.status-container {
margin: 0 10px;
}
.statuses {
padding-right: 10px;
margin-left: 0;
.el-timeline-item__wrapper {
margin-right: 10px;
}
}
.user-page-header {
padding: 0;
margin: 7px 15px 5px 10px;
}
.status-page-header-container {
width: 100%;
.el-dropdown {
width: stretch;
margin: 0 10px 15px 10px;
}
}
}
@media only screen and (max-width:801px) and (min-width: 481px) {
.recent-statuses-container-show {
width: 97%;
margin: 0 20px;
.el-timeline-item {
margin-left: 2px;
}
.recent-statuses {
margin: 20px 10px 15px 0;
}
.show-private-statuses {
margin: 0 10px 20px 0;
}
}
.show-private-statuses {
margin: 0 10px 20px 0;
}
.user-page-header {
padding: 0;
margin: 7px 15px 20px 20px;
}
}
</style>

View file

@ -1,11 +1,11 @@
<template>
<el-dropdown :hide-on-click="false" size="small" trigger="click" @click.native.stop>
<el-dropdown :hide-on-click="false" size="small" trigger="click" placement="top-start" @click.native.stop>
<div>
<el-button v-if="page === 'users'" type="text" class="el-dropdown-link">
{{ $t('users.moderation') }}
<i v-if="isDesktop" class="el-icon-arrow-down el-icon--right"/>
</el-button>
<el-button v-if="page === 'userPage'" class="moderate-user-button">
<el-button v-if="page === 'userPage' || page === 'statusPage'" class="moderate-user-button">
<span class="moderate-user-button-container">
<span>
<i class="el-icon-edit" />
@ -27,13 +27,13 @@
{{ user.roles.moderator ? $t('users.revokeModerator') : $t('users.grantModerator') }}
</el-dropdown-item>
<el-dropdown-item
v-if="showDeactivatedButton(user.id)"
v-if="showDeactivatedButton(user.id) && page !== 'statusPage'"
:divided="showAdminAction(user)"
@click.native="toggleActivation(user)">
{{ user.deactivated ? $t('users.activateAccount') : $t('users.deactivateAccount') }}
</el-dropdown-item>
<el-dropdown-item
v-if="showDeactivatedButton(user.id)"
v-if="showDeactivatedButton(user.id) && page !== 'statusPage'"
@click.native="handleDeletion(user)">
{{ $t('users.deleteAccount') }}
</el-dropdown-item>
@ -115,6 +115,10 @@ export default {
page: {
type: String,
default: 'users'
},
statusId: {
type: String,
default: ''
}
},
computed: {
@ -134,7 +138,7 @@ export default {
this.$store.dispatch('DeleteUsers', { users: [user], _userId: user.id })
},
handleEmailConfirmation(user) {
this.$store.dispatch('ConfirmUsersEmail', { users: [user], _userId: user.id })
this.$store.dispatch('ConfirmUsersEmail', { users: [user], _userId: user.id, _statusId: this.statusId })
},
requirePasswordReset(user) {
const mailerEnabled = this.$store.state.user.nodeInfo.metadata.mailerEnabled
@ -157,13 +161,13 @@ export default {
},
toggleTag(user, tag) {
user.tags.includes(tag)
? this.$store.dispatch('RemoveTag', { users: [user], tag, _userId: user.id })
: this.$store.dispatch('AddTag', { users: [user], tag, _userId: user.id })
? this.$store.dispatch('RemoveTag', { users: [user], tag, _userId: user.id, _statusId: this.statusId })
: this.$store.dispatch('AddTag', { users: [user], tag, _userId: user.id, _statusId: this.statusId })
},
toggleUserRight(user, right) {
user.roles[right]
? this.$store.dispatch('DeleteRight', { users: [user], right, _userId: user.id })
: this.$store.dispatch('AddRight', { users: [user], right, _userId: user.id })
? this.$store.dispatch('DeleteRight', { users: [user], right, _userId: user.id, _statusId: this.statusId })
: this.$store.dispatch('AddRight', { users: [user], right, _userId: user.id, _statusId: this.statusId })
}
}
}

View file

@ -0,0 +1,47 @@
<template>
<el-dialog
v-loading="loading"
:visible="dialogOpen"
:title="$t('users.passwordResetTokenCreated')"
custom-class="password-reset-token-dialog"
@close="closeResetPasswordDialog">
<div>
<p class="password-reset-token">Password reset token was generated: {{ passwordResetToken }}</p>
<p>You can also use this link to reset password:
<a :href="passwordResetLink" target="_blank" class="reset-password-link">{{ passwordResetLink }}</a>
</p>
</div>
</el-dialog>
</template>
<script>
export default {
name: 'ResetPasswordDialog',
props: {
resetPasswordDialogOpen: {
type: Boolean,
default: false
}
},
computed: {
dialogOpen() {
return this.resetPasswordDialogOpen
},
loading() {
return this.$store.state.users.loading
},
passwordResetLink() {
return this.$store.state.users.passwordResetToken.link
},
passwordResetToken() {
return this.$store.state.users.passwordResetToken.token
}
},
methods: {
closeResetPasswordDialog() {
this.$emit('close-reset-token-dialog')
}
}
}
</script>

View file

@ -6,7 +6,7 @@
multiple
class="select-field"
@change="toggleFilters">
<el-option-group :label="$t('usersFilter.byUserType')">
<el-option-group :label="$t('usersFilter.byAccountType')">
<el-option value="local">{{ $t('usersFilter.local') }}</el-option>
<el-option value="external">{{ $t('usersFilter.external') }}</el-option>
</el-option-group>

View file

@ -87,19 +87,9 @@
</template>
</el-table-column>
</el-table>
<el-dialog
v-loading="loading"
:visible.sync="resetPasswordDialogOpen"
:title="$t('users.passwordResetTokenCreated')"
custom-class="password-reset-token-dialog"
@close="closeResetPasswordDialog">
<div>
<p class="password-reset-token">Password reset token was generated: {{ passwordResetToken }}</p>
<p>You can also use this link to reset password:
<a :href="passwordResetLink" target="_blank" class="reset-password-link">{{ passwordResetLink }}</a>
</p>
</div>
</el-dialog>
<reset-password-dialog
:reset-password-dialog-open="resetPasswordDialogOpen"
@close-reset-token-dialog="closeResetPasswordDialog"/>
<div v-if="!loading" class="pagination">
<el-pagination
:total="usersCount"
@ -121,6 +111,7 @@ import MultipleUsersMenu from './components/MultipleUsersMenu'
import NewAccountDialog from './components/NewAccountDialog'
import ModerationDropdown from './components/ModerationDropdown'
import RebootButton from '@/components/RebootButton'
import ResetPasswordDialog from './components/ResetPasswordDialog'
export default {
name: 'Users',
@ -129,6 +120,7 @@ export default {
ModerationDropdown,
MultipleUsersMenu,
RebootButton,
ResetPasswordDialog,
UsersFilter
},
data() {
@ -149,12 +141,6 @@ export default {
pageSize() {
return this.$store.state.users.pageSize
},
passwordResetLink() {
return this.$store.state.users.passwordResetToken.link
},
passwordResetToken() {
return this.$store.state.users.passwordResetToken.token
},
currentPage() {
return this.$store.state.users.currentPage
},
@ -265,11 +251,6 @@ export default {
.create-account > .el-icon-plus {
margin-right: 5px;
}
.users-header-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.password-reset-token {
margin: 0 0 14px 0;
}
@ -279,6 +260,11 @@ export default {
.reset-password-link {
text-decoration: underline;
}
.users-header-container {
display: flex;
align-items: center;
justify-content: space-between;
}
.users-container {
h1 {
margin: 10px 0 0 15px;

View file

@ -30,19 +30,9 @@
:page="'userPage'"
@open-reset-token-dialog="openResetPasswordDialog"/>
</div>
<el-dialog
v-loading="loading"
:visible.sync="resetPasswordDialogOpen"
:title="$t('users.passwordResetTokenCreated')"
custom-class="password-reset-token-dialog"
@close="closeResetPasswordDialog">
<div>
<p class="password-reset-token">Password reset token was generated: {{ passwordResetToken }}</p>
<p>You can also use this link to reset password:
<a :href="passwordResetLink" target="_blank" class="reset-password-link">{{ passwordResetLink }}</a>
</p>
</div>
</el-dialog>
<reset-password-dialog
:reset-password-dialog-open="resetPasswordDialogOpen"
@close-reset-token-dialog="closeResetPasswordDialog"/>
<div class="user-profile-container">
<el-card class="user-profile-card">
<div class="el-table el-table--fit el-table--enable-row-hover el-table--enable-row-transition el-table--medium">
@ -77,14 +67,14 @@
</td>
</tr>
<tr class="el-table__row">
<td>{{ $t('userProfile.localUppercase') }}</td>
<td>{{ $t('userProfile.accountType') }}</td>
<td>
<el-tag v-if="user.local" type="info">{{ $t('userProfile.local') }}</el-tag>
<el-tag v-if="!user.local" type="info">{{ $t('userProfile.external') }}</el-tag>
</td>
</tr>
<tr class="el-table__row">
<td>{{ $t('userProfile.activeUppercase') }}</td>
<td>{{ $t('userProfile.status') }}</td>
<td>
<el-tag v-if="!user.deactivated" type="success">{{ $t('userProfile.active') }}</el-tag>
<el-tag v-if="user.deactivated" type="danger">{{ $t('userProfile.deactivated') }}</el-tag>
@ -123,10 +113,11 @@ import Status from '@/components/Status'
import ModerationDropdown from './components/ModerationDropdown'
import SecuritySettingsModal from './components/SecuritySettingsModal'
import RebootButton from '@/components/RebootButton'
import ResetPasswordDialog from './components/ResetPasswordDialog'
export default {
name: 'UsersShow',
components: { ModerationDropdown, RebootButton, Status, SecuritySettingsModal },
components: { ModerationDropdown, RebootButton, ResetPasswordDialog, Status, SecuritySettingsModal },
data() {
return {
showPrivate: false,
@ -147,12 +138,6 @@ export default {
loading() {
return this.$store.state.users.loading
},
passwordResetLink() {
return this.$store.state.users.passwordResetToken.link
},
passwordResetToken() {
return this.$store.state.users.passwordResetToken.token
},
statuses() {
return this.$store.state.userProfile.statuses
},
@ -179,6 +164,17 @@ export default {
this.resetPasswordDialogOpen = false
this.$store.dispatch('RemovePasswordToken')
},
humanizeTag(tag) {
const mapTags = {
'force_nsfw': 'Force NSFW',
'strip_media': 'Strip Media',
'force_unlisted': 'Force Unlisted',
'sandbox': 'Sandbox',
'disable_remote_subscription': 'Disable remote subscription',
'disable_any_subscription': 'Disable any subscription'
}
return mapTags[tag]
},
onTogglePrivate() {
this.$store.dispatch('FetchUserProfile', { userId: this.$route.params.id, godmode: this.showPrivate })
},
@ -192,7 +188,7 @@ export default {
}
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
<style rel='stylesheet/scss' lang='scss'>
header {
align-items: center;
display: flex;
@ -241,6 +237,12 @@ table {
margin-left: 28px;
color: #606266;
}
.password-reset-token {
margin: 0 0 14px 0;
}
.password-reset-token-dialog {
width: 50%
}
.poll ul {
list-style-type: none;
padding: 0;
@ -258,6 +260,9 @@ table {
.recent-statuses-header {
margin-top: 10px;
}
.reset-password-link {
text-decoration: underline;
}
.security-setting-button {
margin-top: 20px;
width: 100%;
@ -307,16 +312,29 @@ table {
.avatar-name-container {
margin-bottom: 10px;
}
.el-timeline-item__wrapper {
padding-left: 18px;
}
.password-reset-token-dialog {
width: 85%
}
.recent-statuses {
margin: 20px 10px 15px 10px;
}
.recent-statuses-container {
width: 100%;
margin: 0 10px;
margin: 0;
}
.show-private-statuses {
margin: 0 10px 20px 10px;
}
.status-container {
margin: 0 10px;
}
.statuses {
padding-right: 10px;
margin-left: 8px;
}
.user-page-header {
padding: 0;
margin: 7px 15px 15px 10px;

View file

@ -0,0 +1,91 @@
import Vuex from 'vuex'
import { mount, createLocalVue, config } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import Element from 'element-ui'
import StatusShow from '@/views/statuses/show'
import storeConfig from './statusShowStore.conf'
import { cloneDeep } from 'lodash'
config.mocks["$t"] = () => {}
const localVue = createLocalVue()
localVue.use(Vuex)
localVue.use(Element)
const $route = {
params: {
id: '9vJOO3iFPyjNaEhJ5s'
}
}
jest.mock('@/api/app')
jest.mock('@/api/status')
jest.mock('@/api/peers')
jest.mock('@/api/nodeInfo')
jest.mock('@/api/users')
describe('Status show page', () => {
let store
beforeEach(() => {
store = new Vuex.Store(cloneDeep(storeConfig))
})
it(`fetches single status and user's statuses`, async (done) => {
const wrapper = mount(StatusShow, {
store,
localVue,
sync: false,
stubs: ['router-link'],
mocks: {
$route
}
})
await flushPromises()
expect(wrapper.find('.status-container').isVisible()).toBe(true)
expect(store.state.status.fetchedStatus.id).toBe('9vJOO3iFPyjNaEhJ5s')
expect(store.state.status.fetchedStatus.account.display_name).toBe('dolin')
expect(store.state.userProfile.statuses.length).toEqual(3)
done()
})
it(`renders links and user's moderation menu`, async (done) => {
const wrapper = mount(StatusShow, {
store,
localVue,
sync: false,
stubs: ['router-link'],
mocks: {
$route
}
})
await flushPromises()
expect(wrapper.find('router-link-stub h1').text()).toBe('dolin')
expect(wrapper.find('button.moderate-user-button').exists()).toBe(true)
expect(wrapper.find('.el-dropdown-menu').exists()).toBe(true)
done()
})
it(`renders status card`, async (done) => {
const wrapper = mount(StatusShow, {
store,
localVue,
sync: false,
stubs: ['router-link'],
mocks: {
$route
}
})
await flushPromises()
expect(wrapper.find('.status-card').exists()).toBe(true)
expect(wrapper.find('router-link-stub h3').text()).toBe('dolin')
expect(wrapper.find('span.el-tag').text()).not.toBe('Sensitive')
expect(wrapper.find('span.el-tag').text()).toBe('Public')
expect(wrapper.find('button.status-actions-button').exists()).toBe(true)
expect(wrapper.find('.status-body .status-content').text()).toBe('pizza makes everything better')
done()
})
})

View file

@ -0,0 +1,21 @@
import app from '@/store/modules/app'
import peers from '@/store/modules/peers'
import user from '@/store/modules/user'
import userProfile from '@/store/modules/userProfile'
import users from '@/store/modules/users'
import settings from '@/store/modules/settings'
import status from '@/store/modules/status'
import getters from '@/store/getters'
export default {
modules: {
app,
peers,
settings,
status,
user,
userProfile,
users
},
getters
}

View file

@ -21,7 +21,7 @@ const $route = {
jest.mock('@/api/nodeInfo')
jest.mock('@/api/users')
describe('Search and filter users', () => {
describe('User profile', () => {
let store
beforeEach(() => {