diff --git a/CHANGELOG.md b/CHANGELOG.md index 017191a2..d6b547f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased +### Changed + +- 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 +- 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 + +### Fixed + +- Show checkmarks when tag is applied +- Reports update (also, now it's optimistic) + +## [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" ## [1.1.0] - 2019-09-15 diff --git a/README.md b/README.md index 72c81829..08196552 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,24 @@ ## About -Admin UI for pleroma instance owners +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 +1. Invites management: generate invite tokens & send invites via email +1. Moderation log: track moderator/admin actions +1. Settings: configure your pleroma instance via friendly (hopefully) UI +1. Emoji packs: configure your emoji packs + +You can have any combination of these features (i.e. you can disable anything, but user administration, see "Disabling features" section below). ## Usage @@ -18,8 +35,19 @@ To compile everything for production run `yarn build:prod`. #### Disabling features -You can disable certain AdminFE features, like reports or settings by modifying `config/prod.env.js` env variable `DISABLED_FEATURES`, e.g. if you want to compile AdminFE without "Settings" you'll need to set it to: `DISABLED_FEATURES: '["settings"]'`, -to disable emoji pack settings add `"emoji-packs"` to the list. +You can disable certain AdminFE features, like reports or settings by modifying `config/prod.env.js` env variable `DISABLED_FEATURES`, e.g. if you want to compile AdminFE without "Settings" you'll need to set it to: `DISABLED_FEATURES: '["settings"]'`. + +Features, that can be disabled: + +- reports: `DISABLED_FEATURES: '["reports"]'` +- invites: `DISABLED_FEATURES: '["invites"]'` +- moderation log: `DISABLED_FEATURES: '["moderationLog"]'` +- settings: `DISABLED_FEATURES: '["settings"]'` +- emoji packs: `DISABLED_FEATURES: '["emojiPacks"]'` + +Of course, you can disable multiple features just by adding to the array, e.g. `DISABLED_FEATURES: '["emojiPacks", "settings"]'` will have both emoji packs and settings disabled. + +Users administration cannot be disabled. ## Changelog diff --git a/index.html b/index.html index 2f5a3a50..20e31863 100644 --- a/index.html +++ b/index.html @@ -8,7 +8,6 @@ Admin FE -
diff --git a/src/api/__mocks__/nodeInfo.js b/src/api/__mocks__/nodeInfo.js new file mode 100644 index 00000000..6daf074e --- /dev/null +++ b/src/api/__mocks__/nodeInfo.js @@ -0,0 +1,9 @@ +export async function getNodeInfo(authHost) { + const data = { + metadata: { + mailerEnabled: true + } + } + + return Promise.resolve({ data }) +} diff --git a/src/api/__mocks__/reports.js b/src/api/__mocks__/reports.js index a337df9a..ba4e412e 100644 --- a/src/api/__mocks__/reports.js +++ b/src/api/__mocks__/reports.js @@ -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) { diff --git a/src/api/__mocks__/status.js b/src/api/__mocks__/status.js new file mode 100644 index 00000000..0e7bc9fb --- /dev/null +++ b/src/api/__mocks__/status.js @@ -0,0 +1,7 @@ +export async function changeStatusScope(id, sensitive, visibility, authHost, token) { + return Promise.resolve() +} + +export async function deleteStatus(id, authHost, token) { + return Promise.resolve() +} diff --git a/src/api/__mocks__/users.js b/src/api/__mocks__/users.js index 016772a7..31657293 100644 --- a/src/api/__mocks__/users.js +++ b/src/api/__mocks__/users.js @@ -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,13 +37,12 @@ export async function fetchUsers(filters, authHost, token, page = 1) { }}) } -export async function getPasswordResetToken(nickname, authHost, token) { - return Promise.resolve({ data: { token: 'g05lxnBJQnL', link: 'http://url/api/pleroma/password_reset/g05lxnBJQnL' }}) +export async function fetchUserStatuses(id, authHost, godmode, token) { + return Promise.resolve({ data: userStatuses }) } -export async function toggleUserActivation(nickname, authHost, token) { - const response = users.find(user => user.nickname === nickname) - return Promise.resolve({ data: { ...response, deactivated: !response.deactivated }}) +export async function getPasswordResetToken(nickname, authHost, token) { + return Promise.resolve({ data: { token: 'g05lxnBJQnL', link: 'http://url/api/pleroma/password_reset/g05lxnBJQnL' }}) } export async function searchUsers(query, filters, authHost, token, page = 1) { @@ -48,21 +55,37 @@ export async function searchUsers(query, filters, authHost, token, page = 1) { }}) } -export async function addRight(nickname, right, authHost, token) { +export async function activateUsers(nicknames, authHost, token) { + const response = nicknames.map(nickname => { + const currentUser = users.find(user => user.nickname === nickname) + return { ...currentUser, deactivated: false } + }) + return Promise.resolve({ data: response }) +} + +export async function addRight(nicknames, right, authHost, token) { return Promise.resolve({ data: { [`is_${right}`]: true } }) } +export async function deactivateUsers(nicknames, authHost, token) { + const response = nicknames.map(nickname => { + const currentUser = users.find(user => user.nickname === nickname) + return { ...currentUser, deactivated: true } + }) + return Promise.resolve({ data: response }) +} + export async function deleteRight(nickname, right, authHost, token) { return Promise.resolve({ data: { [`is_${right}`]: false } }) } -export async function deleteUser(nickname, authHost, token) { +export async function deleteUsers(nicknames, authHost, token) { return Promise.resolve({ data: - nickname + nicknames }) } diff --git a/src/api/emoji_packs.js b/src/api/emojiPacks.js similarity index 90% rename from src/api/emoji_packs.js rename to src/api/emojiPacks.js index ce261fdb..cc07b1b6 100644 --- a/src/api/emoji_packs.js +++ b/src/api/emojiPacks.js @@ -48,6 +48,16 @@ export async function listPacks(host) { }) } +export async function listRemotePacks(host, token, instance) { + return await request({ + baseURL: baseName(host), + url: `/api/pleroma/emoji/packs/list_from`, + method: 'post', + headers: authHeaders(token), + data: { instance_address: baseName(instance) } + }) +} + export async function downloadFrom(host, instance_address, pack_name, as, token) { if (as.trim() === '') { as = null @@ -58,7 +68,7 @@ export async function downloadFrom(host, instance_address, pack_name, as, token) url: '/api/pleroma/emoji/packs/download_from', method: 'post', headers: authHeaders(token), - data: { instance_address, pack_name, as }, + data: { instance_address: baseName(instance_address), pack_name, as }, timeout: 0 }) } diff --git a/src/api/initialDataForConfig.js b/src/api/initialDataForConfig.js deleted file mode 100644 index f24ffb9a..00000000 --- a/src/api/initialDataForConfig.js +++ /dev/null @@ -1,117 +0,0 @@ -export const initialSettings = [ - { - group: 'pleroma', - key: ':instance', - value: [ - { 'tuple': [':name', 'Pleroma'] }, - { 'tuple': [':email', 'example@example.com'] }, - { 'tuple': [':notify_email', 'noreply@example.com'] }, - { 'tuple': [':description', 'A Pleroma instance, an alternative fediverse server'] }, - { 'tuple': [':limit', 5000] }, - { 'tuple': [':remote_limit', 100000] }, - { 'tuple': [':upload_limit', 16 * 1048576] }, - { 'tuple': [':avatar_upload_limit', 2 * 1048576] }, - { 'tuple': [':background_upload_limit', 4 * 1048576] }, - { 'tuple': [':banner_upload_limit', 4 * 1048576] }, - { 'tuple': [':poll_limits', [ - { 'tuple': [':max_options', 20] }, - { 'tuple': [':max_option_chars', 200] }, - { 'tuple': [':min_expiration', 0] }, - { 'tuple': [':max_expiration', 365 * 86400] } - ]] }, - { 'tuple': [':registrations_open', true] }, - { 'tuple': [':invites_enabled', false] }, - { 'tuple': [':account_activation_required', false] }, - { 'tuple': [':federating', true] }, - { 'tuple': [':federation_reachability_timeout_days', 7] }, - { 'tuple': - [':federation_publisher_modules', ['Pleroma.Web.ActivityPub.Publisher', 'Pleroma.Web.Websub', 'Pleroma.Web.Salmon']] }, - { 'tuple': [':allow_relay', true] }, - { 'tuple': [':rewrite_policy', 'Pleroma.Web.ActivityPub.MRF.NoOpPolicy'] }, - { 'tuple': [':public', true] }, - { 'tuple': [':managed_config', true] }, - { 'tuple': [':static_dir', 'instance/static/'] }, - { 'tuple': [':allowed_post_formats', ['text/plain', 'text/html', 'text/markdown', 'text/bbcode']] }, - { 'tuple': [':mrf_transparency', true] }, - { 'tuple': [':extended_nickname_format', false] }, - { 'tuple': [':max_pinned_statuses', 1] }, - { 'tuple': [':no_attachment_links', false] }, - { 'tuple': [':max_report_comment_size', 1000] }, - { 'tuple': [':safe_dm_mentions', false] }, - { 'tuple': [':healthcheck', false] }, - { 'tuple': [':remote_post_retention_days', 90] }, - { 'tuple': [':skip_thread_containment', true] }, - { 'tuple': [':limit_to_local_content', ':unauthenticated'] }, - { 'tuple': [':dynamic_configuration', true] }, - { 'tuple': [':max_account_fields', 10] }, - { 'tuple': [':max_remote_account_fields', 20] }, - { 'tuple': [':account_field_name_length', 255] }, - { 'tuple': [':account_field_value_length', 255] }, - { 'tuple': [':external_user_synchronization', true] }, - { 'tuple': [':user_bio_length', 5000] }, - { 'tuple': [':user_name_length', 100] } - ] - }, - { - group: 'mime', - key: ':types', - value: { - 'application/activity+json': ['activity+json'], - 'application/jrd+json': ['jrd+json'], - 'application/ld+json': ['activity+json'], - 'application/xml': ['xml'], - 'application/xrd+xml': ['xrd+xml'] - } - }, - { - group: 'cors_plug', - key: ':max_age', - value: 86400 - }, - { - group: 'cors_plug', - key: ':methods', - value: ['POST', 'PUT', 'DELETE', 'GET', 'PATCH', 'OPTIONS'] - }, - { - group: 'cors_plug', - key: ':expose', - value: [ - 'Link', - 'X-RateLimit-Reset', - 'X-RateLimit-Limit', - 'X-RateLimit-Remaining', - 'X-Request-Id', - 'Idempotency-Key' - ] - }, - { - group: 'cors_plug', - key: ':credentials', - value: true - }, - { - group: 'cors_plug', - key: ':headers', - value: ['Authorization', 'Content-Type', 'Idempotency-Key'] - }, - { - group: 'tesla', - key: ':adapter', - value: 'Tesla.Adapter.Hackney' - }, - { - group: 'pleroma', - key: ':markup', - value: [ - { 'tuple': [':allow_inline_images', true] }, - { 'tuple': [':allow_headings', false] }, - { 'tuple': [':allow_tables', false] }, - { 'tuple': [':allow_fonts', false] }, - { 'tuple': [':scrub_policy', [ - 'Pleroma.HTML.Transform.MediaProxy', - 'Pleroma.HTML.Scrubber.Default' - ]] } - ] - } -] diff --git a/src/api/moderationLog.js b/src/api/moderationLog.js new file mode 100644 index 00000000..b866df33 --- /dev/null +++ b/src/api/moderationLog.js @@ -0,0 +1,38 @@ +import _ from 'lodash' + +import request from '@/utils/request' +import { getToken } from '@/utils/auth' +import { baseName } from './utils' + +export async function fetchLog(authHost, token, params, page = 1) { + const normalizedParams = new URLSearchParams( + _.omitBy({ ...params, page }, _.isUndefined) + ).toString() + + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/moderation_log?${normalizedParams}`, + method: 'get', + headers: authHeaders(token) + }) +} + +export async function fetchAdmins(authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/users?filters=is_admin`, + method: 'get', + headers: authHeaders(token) + }) +} + +export async function fetchModerators(authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/users?filters=is_moderator`, + method: 'get', + headers: authHeaders(token) + }) +} + +const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {} diff --git a/src/api/nodeInfo.js b/src/api/nodeInfo.js new file mode 100644 index 00000000..c67df01b --- /dev/null +++ b/src/api/nodeInfo.js @@ -0,0 +1,10 @@ +import request from '@/utils/request' +import { baseName } from './utils' + +export async function getNodeInfo(authHost) { + return await request({ + baseURL: baseName(authHost), + url: `/nodeinfo/2.0.json`, + method: 'get' + }) +} diff --git a/src/api/relays.js b/src/api/relays.js new file mode 100644 index 00000000..3be0188d --- /dev/null +++ b/src/api/relays.js @@ -0,0 +1,34 @@ +import request from '@/utils/request' +import { getToken } from '@/utils/auth' +import { baseName } from './utils' + +export async function fetchRelays(authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: '/api/pleroma/admin/relay', + method: 'get', + headers: authHeaders(token) + }) +} + +export async function addRelay(relay, authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: '/api/pleroma/admin/relay', + method: 'post', + headers: authHeaders(token), + data: { relay_url: relay } + }) +} + +export async function deleteRelay(relay, authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: '/api/pleroma/admin/relay', + method: 'delete', + headers: authHeaders(token), + data: { relay_url: `https://${relay}/actor` } + }) +} + +const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {} diff --git a/src/api/reports.js b/src/api/reports.js index 27936859..373e5bd5 100644 --- a/src/api/reports.js +++ b/src/api/reports.js @@ -2,13 +2,13 @@ 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 } }) } diff --git a/src/api/users.js b/src/api/users.js index 0f8e4a41..fb168d6c 100644 --- a/src/api/users.js +++ b/src/api/users.js @@ -2,12 +2,23 @@ import request from '@/utils/request' import { getToken } from '@/utils/auth' import { baseName } from './utils' -export async function addRight(nickname, right, authHost, token) { +export async function activateUsers(nicknames, authHost, token) { return await request({ baseURL: baseName(authHost), - url: `/api/pleroma/admin/users/${nickname}/permission_group/${right}`, + url: `/api/pleroma/admin/users/activate`, + method: 'patch', + headers: authHeaders(token), + data: { nicknames } + }) +} + +export async function addRight(nicknames, right, authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/users/permission_group/${right}`, method: 'post', - headers: authHeaders(token) + headers: authHeaders(token), + data: { nicknames } }) } @@ -21,21 +32,33 @@ export async function createNewAccount(nickname, email, password, authHost, toke }) } -export async function deleteRight(nickname, right, authHost, token) { +export async function deactivateUsers(nicknames, authHost, token) { return await request({ baseURL: baseName(authHost), - url: `/api/pleroma/admin/users/${nickname}/permission_group/${right}`, - method: 'delete', - headers: authHeaders(token) + url: `/api/pleroma/admin/users/deactivate`, + method: 'patch', + headers: authHeaders(token), + data: { nicknames } }) } -export async function deleteUser(nickname, authHost, token) { +export async function deleteRight(nicknames, right, authHost, token) { return await request({ baseURL: baseName(authHost), - url: `/api/pleroma/admin/users?nickname=${nickname}`, + url: `/api/pleroma/admin/users/permission_group/${right}`, method: 'delete', - headers: authHeaders(token) + headers: authHeaders(token), + data: { nicknames } + }) +} + +export async function deleteUsers(nicknames, authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/users`, + method: 'delete', + headers: authHeaders(token), + data: { nicknames } }) } @@ -66,6 +89,15 @@ export async function getPasswordResetToken(nickname, authHost, token) { }) } +export async function requirePasswordReset(nickname, authHost, token) { + return await request({ + baseURL: baseName(authHost), + url: `/api/pleroma/admin/users/${nickname}/force_password_reset`, + method: 'patch', + headers: authHeaders(token) + }) +} + export async function searchUsers(query, filters, authHost, token, page = 1) { return await request({ baseURL: baseName(authHost), @@ -85,15 +117,6 @@ export async function tagUser(nicknames, tags, authHost, token) { }) } -export async function toggleUserActivation(nickname, authHost, token) { - return await request({ - baseURL: baseName(authHost), - url: `/api/pleroma/admin/users/${nickname}/toggle_activation`, - method: 'patch', - headers: authHeaders(token) - }) -} - export async function untagUser(nicknames, tags, authHost, token) { return await request({ baseURL: baseName(authHost), diff --git a/src/api/utils.js b/src/api/utils.js index 4943f650..9f47a1f8 100644 --- a/src/api/utils.js +++ b/src/api/utils.js @@ -1,5 +1,10 @@ const isLocalhost = (instanceName) => instanceName.startsWith('localhost:') || instanceName.startsWith('127.0.0.1:') -export const baseName = (instanceName) => - isLocalhost(instanceName) ? `http://${instanceName}` : `https://${instanceName}` +export const baseName = (instanceName = 'localhost') => { + if (instanceName.match(/https?:\/\//)) { + return instanceName + } else { + return isLocalhost(instanceName) ? `http://${instanceName}` : `https://${instanceName}` + } +} diff --git a/src/components/Tinymce/components/editorImage.vue b/src/components/Tinymce/components/editorImage.vue deleted file mode 100644 index 93b211d0..00000000 --- a/src/components/Tinymce/components/editorImage.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - - - diff --git a/src/components/Tinymce/index.vue b/src/components/Tinymce/index.vue deleted file mode 100644 index d9f81acc..00000000 --- a/src/components/Tinymce/index.vue +++ /dev/null @@ -1,210 +0,0 @@ -