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

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>
<div class="upload-container">
<el-button :style="{background:color,borderColor:color}" icon="el-icon-upload" size="mini" type="primary" @click=" dialogVisible=true">上传图片
</el-button>
<el-dialog :visible.sync="dialogVisible">
<el-upload
:multiple="true"
:file-list="fileList"
:show-file-list="true"
:on-remove="handleRemove"
:on-success="handleSuccess"
:before-upload="beforeUpload"
class="editor-slide-upload"
action="https://httpbin.org/post"
list-type="picture-card">
<el-button size="small" type="primary">点击上传</el-button>
</el-upload>
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="handleSubmit">确 定</el-button>
</el-dialog>
</div>
</template>
<script>
// import { getToken } from 'api/qiniu'
export default {
name: 'EditorSlideUpload',
props: {
color: {
type: String,
default: '#1890ff'
}
},
data: function() {
return {
dialogVisible: false,
listObj: {},
fileList: []
}
},
methods: {
checkAllSuccess() {
return Object.keys(this.listObj).every(item => this.listObj[item].hasSuccess)
},
handleSubmit() {
const arr = Object.keys(this.listObj).map(v => this.listObj[v])
if (!this.checkAllSuccess()) {
this.$message('请等待所有图片上传成功 或 出现了网络问题,请刷新页面重新上传!')
return
}
this.$emit('successCBK', arr)
this.listObj = {}
this.fileList = []
this.dialogVisible = false
},
handleSuccess(response, file) {
const uid = file.uid
const objKeyArr = Object.keys(this.listObj)
for (let i = 0, len = objKeyArr.length; i < len; i++) {
if (this.listObj[objKeyArr[i]].uid === uid) {
this.listObj[objKeyArr[i]].url = response.files.file
this.listObj[objKeyArr[i]].hasSuccess = true
return
}
}
},
handleRemove(file) {
const uid = file.uid
const objKeyArr = Object.keys(this.listObj)
for (let i = 0, len = objKeyArr.length; i < len; i++) {
if (this.listObj[objKeyArr[i]].uid === uid) {
delete this.listObj[objKeyArr[i]]
return
}
}
},
beforeUpload(file) {
const _self = this
const _URL = window.URL || window.webkitURL
const fileName = file.uid
this.listObj[fileName] = {}
return new Promise((resolve, reject) => {
const img = new Image()
img.src = _URL.createObjectURL(file)
img.onload = function() {
_self.listObj[fileName] = { hasSuccess: false, uid: file.uid, width: this.width, height: this.height }
}
resolve(true)
})
}
}
}
</script>
<style rel="stylesheet/scss" lang="scss" scoped>
.editor-slide-upload {
margin-bottom: 20px;
/deep/ .el-upload--picture-card {
width: 100%;
}
}
</style>
<template>
<div :class="{fullscreen:fullscreen}" class="tinymce-container editor-container">
<textarea :id="tinymceId" class="tinymce-textarea"/>
<div class="editor-custom-btn-container">
<editorImage color="#1890ff" class="editor-upload-btn" @successCBK="imageSuccessCBK"/>
</div>
</div>
</template>
<script>
import editorImage from './components/editorImage'
import plugins from './plugins'
import toolbar from './toolbar'
export default {
name: 'Tinymce',
components: { editorImage },
props: {
id: {
type: String,
default: function() {
return 'vue-tinymce-' + +new Date() + ((Math.random() * 1000).toFixed(0) + '')
}
},
value: {
type: String,
default: ''
},
toolbar: {
type: Array,
required: false,
default() {
return []
}
},
menubar: {
type: String,
default: 'file edit insert view format table'
},
height: {
type: Number,
required: false,
default: 360
}
},
data: function() {
return {
hasChange: false,
hasInit: false,
tinymceId: this.id,
fullscreen: false,
languageTypeList: {
'en': 'en',
'zh': 'zh_CN'
}
}
},
computed: {
language() {
return this.languageTypeList[this.$store.getters.language]
}
},
watch: {
value(val) {
if (!this.hasChange && this.hasInit) {
this.$nextTick(() =>
window.tinymce.get(this.tinymceId).setContent(val || ''))
}
},
language() {
this.destroyTinymce()
this.$nextTick(() => this.initTinymce())
}
},
mounted() {
this.initTinymce()
},
activated() {
this.initTinymce()
},
deactivated() {
this.destroyTinymce()
},
destroyed() {
this.destroyTinymce()
},
methods: {
initTinymce() {
const _this = this
window.tinymce.init({
language: this.language,
selector: `#${this.tinymceId}`,
height: this.height,
body_class: 'panel-body ',
object_resizing: false,
toolbar: this.toolbar.length > 0 ? this.toolbar : toolbar,
menubar: this.menubar,
plugins: plugins,
end_container_on_empty_block: true,
powerpaste_word_import: 'clean',
code_dialog_height: 450,
code_dialog_width: 1000,
advlist_bullet_styles: 'square',
advlist_number_styles: 'default',
imagetools_cors_hosts: ['www.tinymce.com', 'codepen.io'],
default_link_target: '_blank',
link_title: false,
nonbreaking_force_tab: true, // inserting nonbreaking space &nbsp; need Nonbreaking Space Plugin
init_instance_callback: editor => {
if (_this.value) {
editor.setContent(_this.value)
}
_this.hasInit = true
editor.on('NodeChange Change KeyUp SetContent', () => {
this.hasChange = true
this.$emit('input', editor.getContent())
})
},
setup(editor) {
editor.on('FullscreenStateChanged', (e) => {
_this.fullscreen = e.state
})
}
// 整合七牛上传
// images_dataimg_filter(img) {
// setTimeout(() => {
// const $image = $(img);
// $image.removeAttr('width');
// $image.removeAttr('height');
// if ($image[0].height && $image[0].width) {
// $image.attr('data-wscntype', 'image');
// $image.attr('data-wscnh', $image[0].height);
// $image.attr('data-wscnw', $image[0].width);
// $image.addClass('wscnph');
// }
// }, 0);
// return img
// },
// images_upload_handler(blobInfo, success, failure, progress) {
// progress(0);
// const token = _this.$store.getters.token;