Commit 306c79ea authored by Angelina Filippova's avatar Angelina Filippova
Browse files

Merge branch 'master' into feature/update-server-configuration

parents 58760ac4 d8fdf187
Pipeline #21623 passed with stages
in 8 minutes and 40 seconds
......@@ -8,26 +8,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- moves emoji pack configuration from the main menu to settings tab, redesigns it and fixes bugs
- **breaking** PleromaFE login feature relies on `admin` scope presence in PleromaFE token (older versions of PleromaFE don't support it)
- Moves emoji pack configuration from the main menu to settings tab, redesigns it and fixes bugs
- `mailerEnabled` must be set to `true` in order to require password reset (password reset currently only works via email)
- remove fetching initial data for configuring server settings
- Remove fetching initial data for configuring server settings
- Actions in users module (ActivateUsers, AddRight, DeactivateUsers, DeleteRight, DeleteUsers) now accept an array of users instead of one user
- Leave dropdown menu open after clicking an action
- Move current try/catch error handling from view files to module, add it where necessary
### Added
- Optimistic update for actions in users module and fetching users after api function finished its execution
- Relay management
- Ability to fetch all statuses from a given instance
- Grouped reports: now you can view reports, which are grouped by status (pagination is not implemented yet, though)
- Ability to confirm users' emails and resend confirmation emails
- Report notes
- Ability to moderate users on the statuses page
### Fixed
- Show checkmarks when tag is applied
- Reports update (also, now it's optimistic)
- Remove duplicated success message
## [1.2.0] - 2019-09-27
### Added
- Emoji pack configuration
- Statuses page: fetch all statuses from a given instance
- Ability to require user's password reset
– Ability to track admin/moderator actions, a.k.a. "the moderation log"
......
......@@ -6,6 +6,13 @@
Admin UI for pleroma instance owners.
### Branches
There are two main branches here:
- `develop`: ongoing work and all merge requests go here, *unstable*
- `master`: after `develop` is stabilized it is merged to `master`, `master` is *stable*, allegedly
### Features
1. User administration: grant roles to users (admin/moderator), deactivate/delete as well as force their statuses to have NSFW tag, strip media and many more
......@@ -18,13 +25,17 @@ You can have any combination of these features (i.e. you can disable anything, b
## Usage
### Bundled
AdminFE is bundled with Pleroma, i.e. you can just visit `https://your.instance/pleroma/admin/` to try it out.
### Development
To run AdminFE locally execute `yarn dev`
### Build
To compile everything for production run `yarn build:prod`.
To compile everything for production run `yarn build:prod`, this will build admin-fe into `dist` folder, which you will need to upload to your server and/or point your webserver of choice to.
#### Disabling features
......
......@@ -8,7 +8,6 @@
<title>Admin FE</title>
</head>
<body>
<script src=<%= BASE_URL %>/tinymce4.7.5/tinymce.min.js></script>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
......
......@@ -8,7 +8,7 @@ export async function loginByUsername(username, password, authHost) {
const verifyHost = user.authHost === authHost
const data = {
'token_type': 'Bearer',
'scope': 'read write follow',
'scope': 'read write follow push admin',
'refresh_token': 'foo123',
'me': 'bob',
'expires_in': 600,
......
......@@ -11,20 +11,42 @@ const reports = [
{ 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) {
const paginatedReports = max_id.length > 0 ? reports.slice(5) : reports.slice(0, 5)
return Promise.resolve({ data: { reports: paginatedReports }})
const groupedReports = [
{ account: { avatar: 'http://localhost:4000/images/avi.png', confirmation_pending: false, deactivated: false, display_name: 'leo', id: '9oG0YghgBi94EATI9I', local: true, nickname: 'leo', roles: { admin: false, moderator: false }, tags: [] },
actors: [{ acct: 'admin', avatar: 'http://localhost:4000/images/avi.png', deactivated: false, display_name: 'admin', id: '9oFz4pTauG0cnJ581w', local: true, nickname: 'admin', roles: { admin: false, moderator: false }, tags: [], url: 'http://localhost:4000/users/admin', username: 'admin' }],
date: '2019-11-23T12:56:11.969772Z',
reports: [
{ 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: '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' }
] }
],
status: {
account: { acct: 'leo' },
content: 'At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis',
created_at: '2019-11-23T12:55:20.000Z',
id: '9pFoQO69piu7cUDnJg',
url: 'http://localhost:4000/notice/9pFoQO69piu7cUDnJg',
visibility: 'unlisted',
sensitive: true
},
status_deleted: false
}
]
export async function fetchReports(filter, page, pageSize, authHost, token) {
return filter.length > 0
? Promise.resolve({ data: { reports: reports.filter(report => report.state === filter) }})
: Promise.resolve({ data: { reports }})
}
export async function filterReports(filter, limit, max_id, authHost, token) {
const filteredReports = reports.filter(report => report.state === filter)
const paginatedReports = max_id.length > 0 ? filteredReports.slice(5) : filteredReports.slice(0, 5)
return Promise.resolve({ data: { reports: paginatedReports }})
export async function fetchGroupedReports(authHost, token) {
return Promise.resolve({ data: { reports: groupedReports }})
}
export async function changeState(state, id, authHost, token) {
const report = reports.find(report => report.id === id)
return Promise.resolve({ data: { ...report, state }})
export async function changeState(reportsData, authHost, token) {
return Promise.resolve({ data: '' })
}
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
......
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
return Promise.resolve()
}
export async function deleteStatus(id, authHost, token) {
return Promise.resolve()
}
......@@ -4,6 +4,10 @@ export let users = [
{ active: false, deactivated: true, id: 'abc', nickname: 'john', local: true, external: false, roles: { admin: false, moderator: false }, tags: ['strip_media'] }
]
const userProfile = { avatar: 'avatar.jpg', display_name: 'Allis', nickname: 'allis', id: '2', tags: [], roles: { admin: true, moderator: false }, local: true, external: false }
const userStatuses = []
const filterUsers = (str) => {
const filters = str.split(',').filter(item => item.length > 0)
if (filters.length === 0) {
......@@ -20,6 +24,10 @@ const filterUsers = (str) => {
return applyFilters([], filters, users)
}
export async function fetchUser(id, authHost, token) {
return Promise.resolve({ data: userProfile })
}
export async function fetchUsers(filters, authHost, token, page = 1) {
const filteredUsers = filterUsers(filters)
return Promise.resolve({ data: {
......@@ -29,6 +37,10 @@ export async function fetchUsers(filters, authHost, token, page = 1) {
}})
}
export async function fetchUserStatuses(id, authHost, godmode, token) {
return Promise.resolve({ data: userStatuses })
}
export async function getPasswordResetToken(nickname, authHost, token) {
return Promise.resolve({ data: { token: 'g05lxnBJQnL', link: 'http://url/api/pleroma/password_reset/g05lxnBJQnL' }})
}
......
......@@ -9,7 +9,7 @@ export async function loginByUsername(username, password, authHost) {
data: {
client_name: `AdminFE_${Math.random()}`,
redirect_uris: `${window.location.origin}/oauth-callback`,
scopes: 'read write follow'
scopes: 'read write follow push admin'
}
})
......
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function fetchPeers(authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/v1/instance/peers`,
method: 'get',
headers: authHeaders(token)
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}
......@@ -2,49 +2,52 @@ import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function changeState(state, id, authHost, token) {
export async function changeState(reports, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports/${id}`,
method: 'put',
url: `/api/pleroma/admin/reports`,
method: 'patch',
headers: authHeaders(token),
data: { state }
data: { reports }
})
}
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
export async function fetchReports(filter, page, pageSize, authHost, token) {
const url = filter.length > 0
? `/api/pleroma/admin/reports?state=${filter}&page=${page}&page_size=${pageSize}`
: `/api/pleroma/admin/reports?page=${page}&page_size=${pageSize}`
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'put',
headers: authHeaders(token),
data: { sensitive, visibility }
url,
method: 'get',
headers: authHeaders(token)
})
}
export async function deleteStatus(id, authHost, token) {
export async function fetchGroupedReports(authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'delete',
url: `/api/pleroma/admin/grouped_reports`,
method: 'get',
headers: authHeaders(token)
})
}
export async function fetchReports(limit, max_id, authHost, token) {
export async function createNote(content, reportID, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports?limit=${limit}&max_id=${max_id}`,
method: 'get',
headers: authHeaders(token)
url: `/api/pleroma/admin/reports/${reportID}/notes`,
method: `post`,
headers: authHeaders(token),
data: { content }
})
}
export async function filterReports(filter, limit, max_id, authHost, token) {
export async function deleteNote(noteID, reportID, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/reports?state=${filter}&limit=${limit}&max_id=${max_id}`,
method: 'get',
url: `/api/pleroma/admin/reports/${reportID}/notes/${noteID}`,
method: `delete`,
headers: authHeaders(token)
})
}
......
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function changeStatusScope(id, sensitive, visibility, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'put',
headers: authHeaders(token),
data: { sensitive, visibility }
})
}
export async function deleteStatus(id, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/statuses/${id}`,
method: 'delete',
headers: authHeaders(token)
})
}
export async function fetchStatusesByInstance(instance, authHost, token, pageSize, page = 1) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/instances/${instance}/statuses?page=${page}&page_size=${pageSize}`,
method: 'get',
headers: authHeaders(token)
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}
......@@ -136,4 +136,24 @@ export async function fetchUserStatuses(id, authHost, godmode, token) {
})
}
export async function confirmUserEmail(nicknames, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: '/api/pleroma/admin/users/confirm_email',
method: 'patch',
headers: authHeaders(token),
data: { nicknames }
})
}
export async function resendConfirmationEmail(nicknames, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: '/api/pleroma/admin/users/resend_confirmation_email',
method: 'patch',
headers: authHeaders(token),
data: { nicknames }
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}
<template>
<el-collapse-item :title="getStatusesTitle(report.statuses)">
<el-card v-for="status in report.statuses" :key="status.id" class="status-card">
<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">
<img :src="status.account.avatar" class="status-avatar-img">
<h3 class="status-account-name">{{ status.account.display_name }}</h3>
<el-checkbox @change="handleStatusSelection(status.account)">
<img :src="status.account.avatar" class="status-avatar-img">
<h3 class="status-account-name">{{ status.account.display_name }}</h3>
</el-checkbox>
</div>
<a :href="status.account.url" target="_blank" class="account">
@{{ status.account.acct }}
......@@ -22,31 +24,31 @@
<el-dropdown-menu slot="dropdown">
<el-dropdown-item
v-if="!status.sensitive"
@click.native="changeStatus(status.id, true, status.visibility, report.id)">
@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, report.id)">
@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', report.id)">
@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', report.id)">
@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', report.id)">
@click.native="changeStatus(status.id, status.sensitive, 'unlisted')">
{{ $t('reports.unlisted') }}
</el-dropdown-item>
<el-dropdown-item
@click.native="deleteStatus(status.id, report.id)">
@click.native="deleteStatus(status.id)">
{{ $t('reports.deleteStatus') }}
</el-dropdown-item>
</el-dropdown-menu>
......@@ -55,40 +57,110 @@
</div>
</div>
<div class="status-body">
<span class="status-content" v-html="status.content"/>
<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">
<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>
<a :href="status.url" target="_blank" class="account">
{{ parseTimestamp(status.created_at) }}
</a>
</div>
</el-card>
</el-collapse-item>
<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>
<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) }}
</a>
</el-card>
</div>
</template>
<script>
import moment from 'moment'
export default {
name: 'Statuses',
name: 'Status',
props: {
report: {
status: {
type: Object,
required: true
},
page: {
type: Number,
required: false,
default: 0
},
userId: {
type: String,
required: false,
default: ''
},
godmode: {
type: Boolean,
required: false,
default: false
}
},
data() {
return {
showHiddenStatus: false
}
},
methods: {
capitalizeFirstLetter(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
},
changeStatus(statusId, isSensitive, visibility, reportId) {
this.$store.dispatch('ChangeStatusScope', { statusId, isSensitive, visibility, reportId })
changeStatus(statusId, isSensitive, visibility) {
this.$store.dispatch('ChangeStatusScope', { statusId, isSensitive, visibility, reportCurrentPage: this.page, userId: this.userId, godmode: this.godmode })
},
deleteStatus(statusId, reportId) {
deleteStatus(statusId) {
this.$confirm('Are you sure you want to delete this status?', 'Warning', {
confirmButtonText: 'OK',
cancelButtonText: 'Cancel',
type: 'warning'
}).then(() => {
this.$store.dispatch('DeleteStatus', { statusId, reportId })
this.$store.dispatch('DeleteStatus', { statusId, reportCurrentPage: this.page, userId: this.userId, godmode: this.godmode })
this.$message({
type: 'success',
message: 'Delete completed'
......@@ -100,30 +172,51 @@ export default {
})
})
},
getStatusesTitle(statuses) {
return `Reported statuses: ${statuses.length} item(s)`
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)
},
parseTimestamp(timestamp) {
return moment(timestamp).format('YYYY-MM-DD HH:mm')
},
handleStatusSelection(account) {
this.$emit('status-selection', account)
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
.status-card {
.account {
text-decoration: underline;
line-height: 26px;
font-size: 13px;
}
.image {
width: 20%;
img {
width: 100%;
}
}
.show-more-button {
margin-left: 5px;
}
.status-account {
display: flex;
align-items: center;
}
.status-avatar-img {
display: inline-block;
width: 15px;
height: 15px;
margin-right: 5px;
}
.status-account-name {
display: inline-block;
margin: 0;
height: 22px;
}
......@@ -133,44 +226,53 @@ export default {
}
.status-content {
font-size: 15px;
line-height: 26px;
}
.status-card {
margin-bottom: 15px;
}
.status-deleted {
font-style: italic;
margin-top: 3px;