Merge branch 'admin-godmode' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2026-06-10 17:41:48 +03:00
commit eff61d0509
87 changed files with 3737 additions and 1802 deletions

View file

@ -1,7 +1,7 @@
# This file is a template, and might need editing before it works on your project.
# Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/
image: node:18
image: node:20
stages:
- check-changelog

View file

@ -1 +1 @@
18.20.8
20.19.0

View file

@ -16,7 +16,7 @@ labels:
steps:
build:
image: docker.io/node:18-alpine
image: docker.io/node:20-alpine
commands:
- apk add --no-cache zip git
- yarn --frozen-lockfile

View file

@ -9,7 +9,7 @@ when:
steps:
install-depends:
image: &node-image
docker.io/node:18-alpine
docker.io/node:20-alpine
commands:
- yarn --frozen-lockfile

View file

@ -0,0 +1 @@
displaying other user's backgrounds (if supported by BE)

View file

@ -0,0 +1 @@
user management (view and modify user info, view and modify user statuses)

View file

@ -159,7 +159,10 @@ export default {
return this.currentUser.background_image
},
foreignProfileBackground() {
return useMergedConfigStore().mergedConfig.allowForeignUserBackground && useInterfaceStore().foreignProfileBackground
return (
useMergedConfigStore().mergedConfig.allowForeignUserBackground &&
useInterfaceStore().foreignProfileBackground
)
},
instanceBackground() {
return useMergedConfigStore().mergedConfig.hideInstanceWallpaper
@ -167,7 +170,11 @@ export default {
: this.instanceBackgroundUrl
},
background() {
return this.foreignProfileBackground || this.userBackground || this.instanceBackground
return (
this.foreignProfileBackground ||
this.userBackground ||
this.instanceBackground
)
},
bgStyle() {
if (this.background) {

View file

@ -84,6 +84,13 @@ export default (store) => {
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'user-profile-admin-view',
path: '/users/$:id/admin_view',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile_admin_view.vue'),
),
},
{
name: 'interactions',
path: '/users/:username/interactions',

View file

@ -4,6 +4,7 @@ export default {
validInnerComponents: ['Text', 'Icon', 'Link', 'Border', 'ButtonUnstyled'],
variants: {
normal: '.neutral',
info: '.info',
error: '.error',
warning: '.warning',
success: '.success',
@ -47,5 +48,11 @@ export default {
background: '--cGreen',
},
},
{
variant: 'info',
directives: {
background: '--cBlue',
},
},
],
}

View file

@ -31,9 +31,7 @@ const Announcement = {
canEditAnnouncement() {
return (
this.currentUser &&
this.currentUser.privileges.includes(
'announcements_manage_announcements',
)
this.currentUser.privileges.has('announcements_manage_announcements')
)
},
content() {

View file

@ -8,7 +8,15 @@ import { useMergedConfigStore } from 'src/stores/merged_config.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
props: ['user'],
props: {
user: {
type: Object,
},
showLineLabels: {
type: Boolean,
default: false,
},
},
components: {
UserPopover,
UserAvatar,

View file

@ -23,6 +23,10 @@
:title="user.name"
class="basic-user-card-user-name"
>
<strong v-if="showLineLabels">
{{ $t('admin_dash.users.labels.name_colon') }}
{{ ' ' }}
</strong>
<RichContent
class="basic-user-card-user-name-value"
:html="user.name"
@ -31,6 +35,10 @@
/>
</div>
<div>
<strong v-if="showLineLabels">
{{ $t('admin_dash.users.labels.handle_colon') }}
{{ ' ' }}
</strong>
<user-link
class="basic-user-card-screen-name"
:user="user"
@ -48,6 +56,7 @@
display: flex;
flex: 1 0;
margin: 0;
line-height: 1.25;
--emoji-size: 1em;
@ -69,7 +78,7 @@
&-user-name-value,
&-screen-name {
display: inline-block;
display: inline;
max-width: 100%;
overflow: hidden;
white-space: nowrap;

View file

@ -1,5 +1,10 @@
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleQuestion } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleQuestion)
/**
* This component emits the following events:
* cancelled, emitted when the action should not be performed;

View file

@ -8,7 +8,16 @@
<span v-text="title" />
</template>
<slot />
<div class="content">
<FAIcon
icon="circle-question"
size="3x"
fixed-width
/>
<div class="text">
<slot />
</div>
</div>
<template #footer>
<slot name="footerLeft" />
@ -29,3 +38,31 @@
</template>
<script src="./confirm_modal.js"></script>
<style lang="scss">
.confirm-modal {
.content {
display: flex;
align-items: center;
text-align: left;
justify-content: center;
line-height: 1.5;
p {
margin: 0.75em;
margin-right: 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
}
.text {
max-width: 50ch;
}
}
</style>

View file

@ -0,0 +1,40 @@
import ConfirmModal from './confirm_modal.vue'
//import Select from 'src/components/select/select.vue'
export default {
props: {
title: {
type: String,
},
message: {
type: String,
},
cancelText: {
type: String,
},
confirmText: {
type: String,
},
},
emits: ['hide', 'show', 'action'],
data: () => ({
showing: false,
}),
components: {
ConfirmModal,
},
methods: {
show() {
this.showing = true
this.$emit('show')
},
hide() {
this.showing = false
this.$emit('hide')
},
doGeneric() {
this.$emit('action')
this.hide()
},
},
}

View file

@ -0,0 +1,23 @@
<template>
<ConfirmModal
v-if="showing"
:title="title"
:cancel-text="cancelText"
:confirm-text="confirmText"
@accepted="doGeneric"
@cancelled="hide"
>
<template #default>
<span v-text="message" />
</template>
</ConfirmModal>
</template>
<script src="./generic_confirm.js" />
<style lang="scss">
.expiry-amount {
width: 4em;
text-align: right;
}
</style>

View file

@ -0,0 +1,41 @@
import ConfirmModal from './confirm_modal.vue'
//import Select from 'src/components/select/select.vue'
export default {
props: {
title: {
type: String,
},
message: {
type: String,
},
cancelText: {
type: String,
},
confirmText: {
type: String,
},
},
emits: ['hide', 'show', 'action'],
data: () => ({
showing: false,
text: '',
}),
components: {
ConfirmModal,
},
methods: {
show() {
this.showing = true
this.$emit('show')
},
hide() {
this.showing = false
this.$emit('hide')
},
doWithText() {
this.$emit('action', this.text)
this.hide()
},
},
}

View file

@ -0,0 +1,27 @@
<template>
<ConfirmModal
v-if="showing"
:title="title"
:cancel-text="cancelText"
:confirm-text="confirmText"
@accepted="doWithText"
@cancelled="hide"
>
<template #default>
<span v-text="message" />
<input
v-model="text"
class="input string-input filter-input"
>
</template>
</ConfirmModal>
</template>
<script src="./text_confirm.js" />
<style lang="scss">
.expiry-amount {
width: 4em;
text-align: right;
}
</style>

View file

@ -15,7 +15,7 @@
</div>
<template v-else>
<List
:items="drafts"
:external-items="drafts"
:non-interactive="true"
>
<template #item="{ item: draft }">
@ -58,6 +58,7 @@
.Drafts {
.draft {
margin: 1em 0;
width: 100%;
}
.remove-all {

View file

@ -17,7 +17,7 @@ const Interactions = {
allowFollowingMove:
this.$store.state.users.currentUser.allow_following_move,
filterMode: tabModeDict.mentions,
canSeeReports: this.$store.state.users.currentUser.privileges.includes(
canSeeReports: this.$store.state.users.currentUser.has.has(
'reports_manage_reports',
),
}

View file

@ -0,0 +1,58 @@
.List {
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
display: flex;
flex-direction: column;
.list {
flex: 1;
}
.list-item {
display: flex;
align-items: center;
&:not(:last-child) {
border-bottom: 1px dotted var(--border);
}
}
.header {
display: flex;
align-items: center;
padding: var(--__vertical-gap) var(--__horizontal-gap);
border-bottom: 1px solid;
border-bottom-color: var(--border);
.actions {
flex: 1;
}
}
.footer {
padding: 0.9em;
text-align: center;
a {
cursor: pointer;
}
}
.checkbox-wrapper {
padding-right: var(--__horizontal-gap);
flex: none;
}
&.-scrollable {
overflow-y: hidden;
display: flex;
flex-direction: column;
.list {
overflow-y: auto;
flex: 1 1 auto;
}
}
}

154
src/components/list/list.js Normal file
View file

@ -0,0 +1,154 @@
import { isEmpty } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const List = {
props: {
boxOnly: {
type: Boolean,
default: false,
},
fetchFunction: {
type: Function,
default: null,
},
getKey: {
type: Function,
default: (item) => item.id,
},
getClass: {
type: Function,
default: () => '',
},
nonInteractive: {
type: Boolean,
default: false,
},
scrollable: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
externalItems: {
type: Array,
default: null,
},
},
emits: ['fetchRequested', 'select'],
components: {
Checkbox,
},
data() {
return {
items: [],
selected: new Set([]),
loading: false,
bottomedOut: true,
error: null,
page: 1,
total: null,
}
},
computed: {
allKeys() {
return new Set(this.finalItems.map(this.getKey))
},
selectedItems() {
return this.items.filter((item) => this.selected.has(this.getKey(item)))
},
allSelected() {
return (
this.selected.size !== 0 &&
this.selected.size === this.finalItems.length
)
},
noneSelected() {
return this.selected.size === 0
},
someSelected() {
return !this.allSelected && !this.noneSelected
},
finalItems() {
return this.externalItems || this.items
},
},
created() {
window.addEventListener('scroll', this.scrollLoad)
if (this.fetchFunction && this.items.length === 0) {
this.fetchEntries()
}
},
unmounted() {
window.removeEventListener('scroll', this.scrollLoad)
},
methods: {
fetchEntries() {
if (this.loading) return
this.loading = true
this.error = null
this.fetchFunction(this.page)
.then((result) => {
this.loading = false
this.bottomedOut = isEmpty(result.items)
if (this.externalItems) return
this.page += 1
this.total = result.count
this.items.push(...result.items)
})
.catch((error) => {
this.loading = false
this.error = error
console.error('Error loading list data:', error)
})
},
reset() {
this.items = []
this.page = 1
this.total = null
this.error = null
this.loading = false
this.fetchEntries()
},
scrollLoad(e) {
if (this.fetchFunction) {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchEntries()
}
}
},
isSelected(item) {
return this.selected.has(this.getKey(item))
},
toggle(checked, item) {
const key = this.getKey(item)
if (checked) {
this.selected.add(key)
} else {
this.selected.delete(key)
}
this.$emit('select', this.selected)
},
toggleAll(value) {
if (value) {
this.selected = new Set([...this.allKeys])
} else {
this.selected = new Set([])
}
this.$emit('select', this.selected)
},
},
}
export default List

View file

@ -1,48 +1,91 @@
<template>
<div
class="list"
role="list"
class="List"
:class="{ '-scrollable': scrollable }"
>
<div
v-for="item in items"
:key="getKey(item)"
class="list-item"
:class="[getClass(item), nonInteractive ? '-non-interactive' : '']"
role="listitem"
v-if="selectable"
class="header"
>
<slot
name="item"
:item="item"
/>
<div class="checkbox-wrapper">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
@update:model-value="toggleAll"
>
{{ $t('selectable_list.select_all') }}
</Checkbox>
</div>
<div class="actions">
<slot
name="header"
:selected="selectedItems"
/>
</div>
</div>
<div
v-if="items.length === 0 && !!$slots.empty"
class="list-empty-content faint"
class="list"
role="list"
>
<slot name="empty" />
<div
v-for="item in finalItems"
:key="getKey(item)"
class="list-item"
:class="[getClass(item), nonInteractive ? '-non-interactive' : '']"
role="listitem"
>
<div
v-if="selectable"
class="checkbox-wrapper"
>
<Checkbox
:model-value="isSelected(item)"
@update:model-value="checked => toggle(checked, item)"
@click.stop
/>
</div>
<slot
name="item"
:item="item"
/>
</div>
<div
v-if="finalItems.length === 0 && !!$slots.empty"
class="list-empty-content faint"
>
<slot name="empty" />
<slot name="load" />
</div>
<div class="footer">
<button
v-if="error"
class="button-unstyled -link -fullwidth alert error"
@click="fetchEntries"
>
{{ $t('general.generic_error') }}
{{ error }}
</button>
<FAIcon
v-else-if="loading"
spin
icon="circle-notch"
/>
<a
v-else-if="!bottomedOut"
@click="fetchEntries"
role="button"
tabindex="0"
>
{{ $t('general.more') }}
</a>
<span v-else>
{{ $t('general.no_more') }}
</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => [],
},
getKey: {
type: Function,
default: (item) => item.id,
},
getClass: {
type: Function,
default: () => '',
},
nonInteractive: {
type: Boolean,
default: false,
},
},
}
</script>
<script src="./list.js"></script>
<style src="./list.css"></style>

View file

@ -1,6 +1,9 @@
import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
import { last } from 'lodash'
import ConfirmModal from 'src/components/confirm_modal/confirm_modal.vue'
import Popover from 'src/components/popover/popover.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
@ -16,41 +19,382 @@ const DISABLE_REMOTE_SUBSCRIPTION = 'mrf_tag:disable-remote-subscription'
const DISABLE_ANY_SUBSCRIPTION = 'mrf_tag:disable-any-subscription'
const SANDBOX = 'mrf_tag:sandbox'
const QUARANTINE = 'mrf_tag:quarantine'
const TAGS = new Set([
FORCE_NSFW,
STRIP_MEDIA,
FORCE_UNLISTED,
DISABLE_REMOTE_SUBSCRIPTION,
DISABLE_ANY_SUBSCRIPTION,
SANDBOX,
QUARANTINE,
])
const ENTRIES = [
{
check: '!state:activated',
label: 'user_card.admin_menu.activate_account',
},
{
check: 'state:activated',
label: 'user_card.admin_menu.deactivate_account',
},
{
separator: true,
},
{
check: '!state:confirmed',
label: 'user_card.admin_menu.confirm_account',
},
{
check: 'action:resend_confirmation',
conditions: ['!state:confirmed'],
label: 'user_card.admin_menu.resend_confirmation',
},
// No API for revocation
// {
// check: 'state:confirmed',
// label: 'user_card.admin_menu.unconfirm_account',
// },
{
check: '!state:approved',
conditions: ['property:local'],
label: 'user_card.admin_menu.approve_account',
},
// No API for revocation
// {
// check: 'state:approved',
// label: 'user_card.admin_menu.unapprove_account',
// },
{
check: '!state:suggested',
// conditions: ['property:local'], // TODO Should we allow non-local users in suggested?
label: 'user_card.admin_menu.suggest_account',
},
{
check: 'state:suggested',
label: 'user_card.admin_menu.remove_suggested_account',
},
{
separator: true,
},
{
check: 'action:statuses',
label: 'user_card.admin_menu.show_statuses',
},
{
separator: true,
},
{
check: 'action:disable_mfa',
label: 'user_card.admin_menu.disable_mfa',
},
{
check: 'action:require_password_change',
label: 'user_card.admin_menu.require_password_change',
},
{
separator: true,
},
{
check: '!rights:moderator',
label: 'user_card.admin_menu.grant_moderator',
conditions: ['property:local', 'state:activated'],
},
{
check: 'rights:moderator',
label: 'user_card.admin_menu.revoke_moderator',
conditions: ['property:local', 'state:activated'],
},
{
check: '!rights:admin',
label: 'user_card.admin_menu.grant_admin',
conditions: ['property:local', 'state:activated'],
},
{
check: 'rights:admin',
label: 'user_card.admin_menu.revoke_admin',
conditions: ['property:local', 'state:activated'],
},
{
separator: true,
},
{
check: FORCE_NSFW,
label: 'user_card.admin_menu.force_nsfw',
},
{
check: STRIP_MEDIA,
label: 'user_card.admin_menu.strip_media',
},
{
check: FORCE_UNLISTED,
label: 'user_card.admin_menu.force_unlisted',
},
{
check: SANDBOX,
label: 'user_card.admin_menu.sandbox',
},
{
check: DISABLE_ANY_SUBSCRIPTION,
conditions: ['property:local'],
label: 'user_card.admin_menu.disable_any_subscription',
},
{
check: DISABLE_REMOTE_SUBSCRIPTION,
conditions: ['property:local'],
label: 'user_card.admin_menu.disable_remote_subscription',
},
{
check: QUARANTINE,
conditions: ['property:local'],
label: 'user_card.admin_menu.quarantine',
},
{
separator: true,
},
{
check: 'action:delete',
label: 'user_card.admin_menu.delete_account',
},
]
const ModerationTools = {
props: ['user'],
props: {
users: {
type: Array,
required: true,
},
},
created() {
if (this.users.length !== 1) return
useAdminSettingsStore().getUserData({ user: this.users[0] })
},
data() {
return {
tags: {
FORCE_NSFW,
STRIP_MEDIA,
FORCE_UNLISTED,
DISABLE_REMOTE_SUBSCRIPTION,
DISABLE_ANY_SUBSCRIPTION,
SANDBOX,
QUARANTINE,
},
showDeleteUserDialog: false,
toggled: false,
open: false,
confirmDialogShow: false,
confirmDialogTitle: null,
confirmDialogContent: null,
confirmDialogConfirm: null,
confirmDialogAction: null,
confirmDialogGroup: null,
confirmDialogName: null,
}
},
components: {
DialogModal,
ConfirmModal,
Popover,
},
computed: {
ready() {
return this.users.every((u) => u.adminData)
},
entries() {
return ENTRIES.map(({ check, label, separator, conditions }) => {
if (separator) return 'separator'
const [, negateToken, group, name] =
/^([!~]?)([a-z-_]+):([a-z-_]+)$/.exec(check)
const hasTag = this.tagsSet.has(`${group}:${name}`)
const noTag = this.tagsSet.has(`!${group}:${name}`)
const maybeTag = this.tagsSet.has(`~${group}:${name}`)
// We are checking for condition to show element, i.e. only show "activate" if user is "deactivated"
const checkNegated = negateToken === '!' || negateToken === '~'
// Naturally, new value should also be the same
const value = checkNegated
const action = (() => {
switch (group) {
case 'rights':
return () => this.setRight(name, value)
case 'state':
return () => this.setStatus(name, value)
case 'mrf_tag':
return () => this.setTag(`${group}:${name}`, noTag)
case 'action': {
switch (name) {
case 'delete': {
return () => this.deleteUsers()
}
case 'resend_confirmation': {
return () => this.resendConfirmationEmail()
}
case 'disable_mfa': {
return () => this.disableMFA()
}
case 'statuses': {
return () =>
this.$router.push(`/users/\$${this.users[0].id}/admin_view`)
}
case 'require_password_change': {
return () => this.requirePasswordChange()
}
default:
throw new Error(`Unknown action group: ${name}`)
}
}
default:
throw new Error(`Unknown moderation group: ${group}`)
}
})()
let checkboxClass = ''
if (maybeTag) {
checkboxClass = 'menu-checkbox-indeterminate'
} else if (hasTag) {
checkboxClass = 'menu-checkbox-checked'
}
return {
check,
negateToken,
checkbox: group === 'mrf_tag',
checkboxClass,
conditions,
group,
name,
action,
label,
value,
}
})
.filter((entry) => {
if (entry === 'separator') return true
const { group, name, value, conditions } = entry
if (conditions) {
// Checking that all items match positive criteria
const positive = conditions.every((condition) =>
this.totalSet.has(condition),
)
// Checking that there are no items that don't match criteria
const negative = conditions.some((condition) =>
this.totalSet.has('!' + condition),
)
if (!(positive && !negative)) return false
}
switch (group) {
case 'action': {
return true
}
case 'rights': {
return this.canGrantRole(name, value)
}
case 'state': {
return this.canChangeState(name, value)
}
case 'mrf_tag': {
return this.canUseTagPolicy
}
default: {
throw new Error(`Unknown moderation group: ${group}`)
}
}
})
.reduce((acc, entry, index) => {
if (entry === 'separator') {
if (
acc.length === 0 ||
last(acc) === 'separator' ||
index === ENTRIES.length - 1
) {
return acc
}
}
return [...acc, entry]
}, [])
},
rightsSet() {
return this.users.reduce((acc, user) => {
if (user.rights.admin) {
acc.add('rights:admin')
} else {
acc.add('!rights:admin')
}
if (user.rights.moderator) {
acc.add('rights:moderator')
} else {
acc.add('!rights:moderator')
}
return acc
}, new Set())
},
stateSet() {
return this.users.reduce((acc, user) => {
if (!user.deactivated) {
acc.add('state:activated')
} else {
acc.add('!state:activated')
}
if (user.adminData?.is_confirmed) {
acc.add('state:confirmed')
} else {
acc.add('!state:confirmed')
}
if (user.adminData?.is_approved) {
acc.add('state:approved')
} else {
acc.add('!state:approved')
}
if (user.adminData?.is_suggested) {
acc.add('state:suggested')
} else {
acc.add('!state:suggested')
}
return acc
}, new Set())
},
tagsSet() {
return new Set(this.user.tags)
const present = new Set()
const missing = new Set()
this.users.forEach((user) => {
TAGS.forEach((tag) => {
if (user.tags.has(tag)) {
present.add(tag)
} else {
missing.add(tag)
}
})
})
const result = new Set()
TAGS.forEach((tag) => {
if (present.has(tag) && missing.has(tag)) {
result.add(`~${tag}`)
} else if (missing.has(tag)) {
result.add(`!${tag}`)
} else {
result.add(tag)
}
})
return result
},
canGrantRole() {
return (
this.user.is_local &&
!this.user.deactivated &&
this.$store.state.users.currentUser.role === 'admin'
)
propertySet() {
return this.users.reduce((acc, user) => {
if (user.is_local) {
acc.add('property:local')
} else {
acc.add('!property:local')
}
return acc
}, new Set())
},
canChangeActivationState() {
return this.privileged('users_manage_activation_state')
disabled() {
return !this.ready || this.users.length === 0
},
totalSet() {
return new Set([
...this.rightsSet,
...this.stateSet,
...this.tagsSet,
...this.propertySet,
])
},
canDeleteAccount() {
return this.privileged('users_delete')
@ -63,87 +407,260 @@ const ModerationTools = {
},
},
methods: {
hasTag(tagName) {
return this.tagsSet.has(tagName)
canGrantRole(name, value) {
const setEntry = `${value ? '!' : ''}rights:${name}`
return (
this.$store.state.users.currentUser.role === 'admin' &&
this.totalSet.has(setEntry)
)
},
canChangeState(name, value) {
let privilege
switch (name) {
// TODO detailed privileges
default: {
privilege = 'users_manage_activation_state'
}
}
const setEntry = `${value ? '!' : ''}state:${name}`
return this.privileged(privilege) && this.totalSet.has(setEntry)
},
doConfirmDialogAction() {
if (typeof this.confirmDialogAction !== 'function') {
console.error('Confirm Dialog action is not a function!!')
}
this.confirmDialogAction()
this.clearConfirmDialog()
},
clearConfirmDialog() {
this.confirmDialogShow = false
this.confirmDialogTitle = null
this.confirmDialogContent = null
this.confirmDialogContent2 = null
this.confirmDialogDanger = false
this.confirmDialogConfirm = null
this.confirmDialogAction = null
this.confirmDialogGroup = null
this.confirmDialogName = null
},
privileged(privilege) {
return this.$store.state.users.currentUser.privileges.includes(privilege)
return this.$store.state.users.currentUser.privileges.has(privilege)
},
toggleTag(tag) {
const store = this.$store
if (this.tagsSet.has(tag)) {
store.state.api.backendInteractor
.untagUser({ user: this.user, tag })
.then((response) => {
if (!response.ok) {
return
}
store.commit('untagUser', { user: this.user, tag })
})
} else {
store.state.api.backendInteractor
.tagUser({ user: this.user, tag })
.then((response) => {
if (!response.ok) {
return
}
store.commit('tagUser', { user: this.user, tag })
})
}
},
toggleRight(right) {
const store = this.$store
if (this.user.rights[right]) {
store.state.api.backendInteractor
.deleteRight({ user: this.user, right })
.then((response) => {
if (!response.ok) {
return
}
store.commit('updateRight', {
user: this.user,
right,
value: false,
})
})
} else {
store.state.api.backendInteractor
.addRight({ user: this.user, right })
.then((response) => {
if (!response.ok) {
return
}
store.commit('updateRight', { user: this.user, right, value: true })
})
}
},
toggleActivationStatus() {
this.$store.dispatch('toggleActivationStatus', { user: this.user })
},
deleteUserDialog(show) {
this.showDeleteUserDialog = show
},
deleteUser() {
const store = this.$store
const user = this.user
const { id, name } = user
store.state.api.backendInteractor.deleteUser({ user }).then(() => {
this.$store.dispatch(
'markStatusesAsDeleted',
(status) => user.id === status.user.id,
)
const isProfile =
this.$route.name === 'external-user-profile' ||
this.$route.name === 'user-profile'
const isTargetUser =
this.$route.params.name === name || this.$route.params.id === id
if (isProfile && isTargetUser) {
window.history.back()
}
setTag(tag, value) {
useAdminSettingsStore().setUsersTags({
users: this.users,
value,
tags: [tag],
})
},
setToggled(value) {
this.toggled = value
setRight(right, value) {
useAdminSettingsStore().setUsersRight({ users: this.users, value, right })
},
setStatus(name, value) {
const noun = (() => {
switch (name) {
case 'activated':
return 'Activation'
case 'confirmed':
return 'Confirmation'
case 'approved':
return 'Approval'
case 'suggested':
return 'Suggestion'
}
})()
useAdminSettingsStore()[`setUsers${noun}Status`]({
users: this.users,
value,
})
},
resendConfirmationEmail() {
useAdminSettingsStore().resendConfirmationEmail({ users: this.users })
},
requirePasswordChange() {
useAdminSettingsStore().requirePasswordChange({ users: this.users })
},
disableMFA() {
this.users.forEach((user) => {
useAdminSettingsStore().disableMFA({ user })
})
},
deleteUsers() {
const { id, name } = this.users[0]
useAdminSettingsStore()
.deleteUsers({ users: this.users })
.then((userIds) => {
if (userIds.length > 1) return
const isProfile =
this.$route.name === 'external-user-profile' ||
this.$route.name === 'user-profile'
const isTargetUser =
this.$route.params.name === name || this.$route.params.id === id
if (isProfile && isTargetUser) {
window.history.back()
}
})
},
setOpen(value) {
this.open = value
},
maybeShowConfirm(close, { group, name, action, value }) {
close()
this.confirmDialogName = name
this.confirmDialogGroup = group
this.confirmDialogAction = () => action()
switch (group) {
case 'action': {
switch (name) {
case 'delete': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.delete_title',
)
this.confirmDialogDanger = true
this.confirmDialogContent =
'user_card.admin_menu.confirm_modal.delete_content'
this.confirmDialogContent2 =
'user_card.admin_menu.confirm_modal.delete_content_2'
this.confirmDialogConfirm = this.$t(
'user_card.admin_menu.confirm_modal.delete',
)
break
}
case 'resend_confirmation': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.resend_confirmation_title',
)
this.confirmDialogContent =
'user_card.admin_menu.confirm_modal.resend_confirmation_content'
this.confirmDialogConfirm = this.$t(
'user_card.admin_menu.confirm_modal.send',
)
break
}
case 'disable_mfa': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.disable_mfa_title',
)
this.confirmDialogContent =
'user_card.admin_menu.confirm_modal.disable_mfa_content'
this.confirmDialogConfirm = this.$t('settings.confirm')
break
}
case 'require_password_change': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.require_password_change_title',
)
this.confirmDialogContent =
'user_card.admin_menu.confirm_modal.require_password_change_content'
this.confirmDialogConfirm = this.$t('settings.confirm')
break
}
}
break
}
case 'state': {
switch (name) {
case 'activated': {
this.confirmDialogShow = true
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.activate_title',
)
this.confirmDialogContent = value
? 'user_card.admin_menu.confirm_modal.activate_content'
: 'user_card.admin_menu.confirm_modal.deactivate_content'
this.confirmDialogConfirm = value
? this.$t('user_card.admin_menu.confirm_modal.activate')
: this.$t('user_card.admin_menu.confirm_modal.deactivate')
break
}
// Confirmation and Approval statuses cannot be revokedn(no API)
case 'confirmed': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.confirm_title',
)
this.confirmDialogContent = //value
/*?*/ 'user_card.admin_menu.confirm_modal.confirm_content'
//: 'user_card.admin_menu.confirm_modal.confirm_revoke_content'
this.confirmDialogConfirm = value
/*?*/ this.$t('user_card.admin_menu.confirm_modal.confirm')
//: this.$t('user_card.admin_menu.confirm_modal.revoke')
break
}
case 'approved': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.approval_title',
)
this.confirmDialogContent = //value
/*?*/ 'user_card.admin_menu.confirm_modal.approval_content'
//: 'user_card.admin_menu.confirm_modal.approval_revoke_content'
this.confirmDialogConfirm = value
/*?*/ this.$t('user_card.admin_menu.confirm_modal.approve')
//: this.$t('user_card.admin_menu.confirm_modal.revoke')
break
}
case 'suggested': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.suggest_title',
)
this.confirmDialogContent = value
? 'user_card.admin_menu.confirm_modal.add_suggest_content'
: 'user_card.admin_menu.confirm_modal.remove_suggest_content'
this.confirmDialogConfirm = value
? this.$t('user_card.admin_menu.confirm_modal.add')
: this.$t('user_card.admin_menu.confirm_modal.remove')
break
}
}
break
}
case 'rights': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.confirm_modal.user_rights_title',
)
this.confirmDialogContent = value
? 'user_card.admin_menu.confirm_modal.grant_role_content'
: 'user_card.admin_menu.confirm_modal.revoke_role_content'
this.confirmDialogConfirm = value
? this.$t('user_card.admin_menu.confirm_modal.grant')
: this.$t('user_card.admin_menu.confirm_modal.revoke')
break
}
case 'mrf_tag': {
this.confirmDialogTitle = this.$t(
'user_card.admin_menu.user_tag_title',
)
this.confirmDialogContent = value
? 'user_card.admin_menu.confirm_modal.assign_tag_content'
: 'user_card.admin_menu.confirm_modal.unassign_tag_content'
this.confirmDialogConfirm = value
? this.$t('user_card.admin_menu.confirm_modal.assign')
: this.$t('user_card.admin_menu.confirm_modal.unassign')
break
}
}
if (this.users.length > 1) {
this.confirmDialogShow = true
}
if (!this.confirmDialogShow) {
this.doConfirmDialogAction()
}
},
},
}

View file

@ -3,154 +3,37 @@
<Popover
trigger="click"
class="moderation-tools-popover"
:disabled="disabled"
placement="bottom"
:offset="{ y: 5 }"
@show="setToggled(true)"
@close="setToggled(false)"
@show="setOpen(true)"
@close="setOpen(false)"
>
<template #content>
<template #content="{close}">
<div class="dropdown-menu">
<template v-if="canGrantRole">
<div class="menu-item dropdown-item -icon-space">
<button
class="main-button"
@click="toggleRight(&quot;admin&quot;)"
>
{{ $t(!!user.rights.admin ? 'user_card.admin_menu.revoke_admin' : 'user_card.admin_menu.grant_admin') }}
</button>
</div>
<div class="menu-item dropdown-item -icon-space">
<button
class="main-button"
@click="toggleRight(&quot;moderator&quot;)"
>
{{ $t(!!user.rights.moderator ? 'user_card.admin_menu.revoke_moderator' : 'user_card.admin_menu.grant_moderator') }}
</button>
</div>
<template v-for="(entry, index) in entries">
<div
v-if="canChangeActivationState || canDeleteAccount"
v-if="entry === 'separator'"
:key="index"
role="separator"
class="dropdown-divider"
/>
</template>
<div
v-if="canChangeActivationState"
class="menu-item dropdown-item -icon-space"
>
<button
class="main-button"
@click="toggleActivationStatus()"
>
{{ $t(!!user.deactivated ? 'user_card.admin_menu.activate_account' : 'user_card.admin_menu.deactivate_account') }}
</button>
</div>
<div
v-if="canDeleteAccount"
class="menu-item dropdown-item -icon-space"
>
<button
class="main-button"
@click="deleteUserDialog(true)"
>
{{ $t('user_card.admin_menu.delete_account') }}
</button>
</div>
<template v-if="canUseTagPolicy">
<div
role="separator"
class="dropdown-divider"
/>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleTag(tags.FORCE_NSFW)"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_NSFW) }"
/>
{{ $t('user_card.admin_menu.force_nsfw') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleTag(tags.STRIP_MEDIA)"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.STRIP_MEDIA) }"
/>
{{ $t('user_card.admin_menu.strip_media') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleTag(tags.FORCE_UNLISTED)"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.FORCE_UNLISTED) }"
/>
{{ $t('user_card.admin_menu.force_unlisted') }}
</button>
</div>
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="toggleTag(tags.SANDBOX)"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.SANDBOX) }"
/>
{{ $t('user_card.admin_menu.sandbox') }}
</button>
</div>
<div
v-if="user.is_local"
class="menu-item dropdown-item -icon"
v-else
:key="entry.label"
class="menu-item dropdown-item"
:class="entry.checkbox ? '-icon' : '-icon-space'"
>
<button
class="main-button"
@click="toggleTag(tags.DISABLE_REMOTE_SUBSCRIPTION)"
@click="() => maybeShowConfirm(close, entry)"
>
<span
v-if="entry.checkbox"
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_REMOTE_SUBSCRIPTION) }"
:class="entry.checkboxClass"
/>
{{ $t('user_card.admin_menu.disable_remote_subscription') }}
</button>
</div>
<div
v-if="user.is_local"
class="menu-item dropdown-item -icon"
>
<button
class="main-button"
@click="toggleTag(tags.DISABLE_ANY_SUBSCRIPTION)"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.DISABLE_ANY_SUBSCRIPTION) }"
/>
{{ $t('user_card.admin_menu.disable_any_subscription') }}
</button>
</div>
<div
v-if="user.is_local"
class="menu-item dropdown-item -icon"
>
<button
class="main-button"
@click="toggleTag(tags.QUARANTINE)"
>
<span
class="input menu-checkbox"
:class="{ 'menu-checkbox-checked': hasTag(tags.QUARANTINE) }"
/>
{{ $t('user_card.admin_menu.quarantine') }}
{{ $t(entry.label) }}
</button>
</div>
</template>
@ -159,37 +42,60 @@
<template #trigger>
<button
class="btn button-default btn-block moderation-tools-button"
:class="{ toggled }"
:class="{ toggled: open, disabled }"
:disabled="disabled"
>
{{ $t('user_card.admin_menu.moderation') }}
<FAIcon icon="chevron-down" />
<FAIcon v-if="ready" icon="chevron-down" />
<span v-else class="loading-spinner">
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</span>
</button>
</template>
</Popover>
<teleport to="#modal">
<DialogModal
v-if="showDeleteUserDialog"
:on-cancel="deleteUserDialog.bind(this, false)"
<ConfirmModal
v-if="confirmDialogShow"
:title="$t(confirmDialogTitle)"
:confirm-text="confirmDialogConfirm"
:confirm-danger="confirmDialogDanger"
:cancel-text="$t('general.cancel')"
@accepted="doConfirmDialogAction"
@cancelled="clearConfirmDialog"
>
<template #header>
{{ $t('user_card.admin_menu.delete_user') }}
</template>
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
<template #footer>
<button
class="btn button-default"
@click="deleteUserDialog(false)"
>
{{ $t('general.cancel') }}
</button>
<button
class="btn button-default danger"
@click="deleteUser()"
>
{{ $t('user_card.admin_menu.delete_user') }}
</button>
</template>
</DialogModal>
<i18n-t
:plural="users.length"
scope="global"
:keypath="confirmDialogContent"
tag="p"
>
<template #user>
<span
v-text="users[0].screen_name_ui"
/>
</template>
<template #count>
{{ users.length }}
</template>
<template #name>
<code>
{{ confirmDialogName }}
</code>
</template>
</i18n-t>
<p v-if="confirmDialogContent2">
{{ $t(confirmDialogContent2) }}
</p>
<ul v-if="users.length > 1">
<li v-for="user in users">
{{ user.screen_name }}
</li>
</ul>
</ConfirmModal>
</teleport>
</div>
</template>

View file

@ -43,7 +43,7 @@
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
{{ $t("general.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
@ -70,7 +70,7 @@
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
{{ $t("general.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
@ -97,7 +97,7 @@
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
{{ $t("general.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
@ -124,7 +124,7 @@
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
{{ $t("general.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
@ -151,7 +151,7 @@
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
{{ $t("general.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}
@ -178,7 +178,7 @@
>
<td>{{ entry.instance }}</td>
<td v-if="entry.reason === ''">
{{ $t("about.mrf.simple.not_applicable") }}
{{ $t("general.not_applicable") }}
</td>
<td v-else>
{{ entry.reason }}

View file

@ -1,5 +1,5 @@
<template>
<basic-user-card :user="user">
<BasicUserCard :user="user">
<div class="mute-card-content-container">
<span
v-if="muted && muteExpiryAvailable"
@ -30,7 +30,7 @@
:is-mute="true"
/>
</teleport>
</basic-user-card>
</BasicUserCard>
</template>
<script src="./mute_card.js"></script>

View file

@ -96,6 +96,11 @@
content: "";
}
&.menu-checkbox-indeterminate::after {
font-size: 1.25em;
content: "";
}
&.-radio {
border-radius: 9999px;
@ -103,6 +108,11 @@
font-size: 2em;
content: "";
}
&.menu-checkbox-indeterminate::after {
font-size: 2em;
content: "";
}
}
}
}

View file

@ -1,66 +0,0 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import List from 'src/components/list/list.vue'
const SelectableList = {
components: {
List,
Checkbox,
},
props: {
items: {
type: Array,
default: () => [],
},
getKey: {
type: Function,
default: (item) => item.id,
},
},
data() {
return {
selected: [],
}
},
computed: {
allKeys() {
return this.items.map(this.getKey)
},
filteredSelected() {
return this.allKeys.filter((key) => this.selected.indexOf(key) !== -1)
},
allSelected() {
return this.filteredSelected.length === this.items.length
},
noneSelected() {
return this.filteredSelected.length === 0
},
someSelected() {
return !this.allSelected && !this.noneSelected
},
},
methods: {
isSelected(item) {
return this.filteredSelected.indexOf(this.getKey(item)) !== -1
},
toggle(checked, item) {
const key = this.getKey(item)
const oldChecked = this.isSelected(key)
if (checked !== oldChecked) {
if (checked) {
this.selected.push(key)
} else {
this.selected.splice(this.selected.indexOf(key), 1)
}
}
},
toggleAll(value) {
if (value) {
this.selected = this.allKeys.slice(0)
} else {
this.selected = []
}
},
},
}
export default SelectableList

View file

@ -1,88 +0,0 @@
<template>
<div class="selectable-list">
<div
v-if="items.length > 0"
class="selectable-list-header"
>
<div class="selectable-list-checkbox-wrapper">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
@update:model-value="toggleAll"
>
{{ $t('selectable_list.select_all') }}
</Checkbox>
</div>
<div class="selectable-list-header-actions">
<slot
name="header"
:selected="filteredSelected"
/>
</div>
</div>
<List
:items="items"
:get-key="getKey"
:get-class="item => isSelected(item) ? '-active' : ''"
>
<template #item="{item}">
<div
class="selectable-list-item-inner"
:class="{ 'selectable-list-item-selected-inner': isSelected(item) }"
@click.stop="toggle(!isSelected(item), item)"
>
<div class="selectable-list-checkbox-wrapper">
<Checkbox
:model-value="isSelected(item)"
@update:model-value="checked => toggle(checked, item)"
@click.stop
/>
</div>
<slot
name="item"
:item="item"
/>
</div>
</template>
<template #empty>
<slot name="empty" />
</template>
</List>
</div>
</template>
<script src="./selectable_list.js"></script>
<style lang="scss">
.selectable-list {
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
&-item-inner {
display: flex;
align-items: center;
> * {
min-width: 0;
}
}
&-header {
display: flex;
align-items: center;
padding: var(--__vertical-gap) var(--__horizontal-gap);
border-bottom: 1px solid;
border-bottom-color: var(--border);
&-actions {
flex: 1;
}
}
&-checkbox-wrapper {
padding-right: var(--__horizontal-gap);
flex: none;
}
}
</style>

View file

@ -0,0 +1,35 @@
import { defineAsyncComponent } from 'vue'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import ModerationTools from 'src/components/moderation_tools/moderation_tools.vue'
const AdminCard = {
props: {
userId: {
type: String,
},
},
components: {
BasicUserCard,
ModerationTools,
},
computed: {
user() {
return this.$store.getters.findUser(this.userId)
},
relationship() {
return this.$store.getters.relationship(this.userId)
},
isAdmin() {
return this.user.rights.admin
},
isModerator() {
return this.user.rights.moderator
},
isActivated() {
return !this.user.deactivated
},
},
}
export default AdminCard

View file

@ -0,0 +1,9 @@
.AdminCard {
width: 100%;
.right-side {
align-items: baseline;
justify-content: end;
display: flex;
}
}

View file

@ -0,0 +1,107 @@
<template>
<template v-if="!user">
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</template>
<template v-else>
<BasicUserCard
class="AdminCard"
:user="user"
show-line-labels
>
<div>
<strong>
{{ $t('admin_dash.users.labels.email_colon') }}
</strong>
{{ ' ' }}
<span
class="faint"
v-if="user.adminData.email == null"
>
{{ $t('general.not_available') }}
</span>
<a :href="'mailto:' + user.adminData.email">
{{ user.adminData.email }}
</a>
</div>
<details
v-if="user.adminData.registration_reason != null"
open
>
<summary>
{{ $t('user_card.admin_data.registration_reason') }}
</summary>
<span>
{{ user.adminData.registration_reason }}
</span>
</details>
<div class="right-side">
<label
v-if="user.is_local && isAdmin"
class="alert neutral user-role"
>
{{ $t('admin_dash.users.indicator.admin') }}
</label>
<label
v-if="user.is_local && isModerator"
class="alert neutral user-role"
>
{{ $t('admin_dash.users.indicator.moderator') }}
</label>
<label
v-if="isActivated"
class="alert success user-role"
>
{{ $t('admin_dash.users.indicator.active') }}
</label>
<label
v-if="!isActivated"
class="alert error user-role"
>
{{ $t('admin_dash.users.indicator.deactivated') }}
</label>
<label
v-if="user.is_local && user.adminData.is_confirmed"
class="alert success user-role"
>
{{ $t('admin_dash.users.indicator.confirmed') }}
</label>
<label
v-if="user.is_local && !user.adminData.is_confirmed"
class="alert warning user-role"
>
{{ $t('admin_dash.users.indicator.unconfirmed') }}
</label>
<label
v-if="user.is_local && user.adminData.is_approved"
class="alert success user-role"
>
{{ $t('admin_dash.users.indicator.approved') }}
</label>
<label
v-if="user.is_local && !user.adminData.is_approved"
class="alert warning user-role"
>
{{ $t('admin_dash.users.indicator.unapproved') }}
</label>
<label
v-if="user.adminData.is_suggested"
class="alert info user-role"
>
{{ $t('admin_dash.users.indicator.suggested') }}
</label>
<ModerationTools
class="moderation-menu"
:users="[user]"
/>
</div>
</BasicUserCard>
</template>
</template>
<script src="./admin_card.js"></script>
<style lang="scss" src="./admin_card.scss"></style>

View file

@ -0,0 +1,95 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import Status from 'src/components/status/status.vue'
import { parseStatus } from 'src/services/entity_normalizer/entity_normalizer.service.js'
const AdminStatusCard = {
props: {
statusDetails: {
type: Object,
required: true,
validator(u) {
return typeof u.id === 'string'
},
},
},
data() {
return {
jsonExpanded: false,
statusCache: undefined,
}
},
computed: {
isSensitive() {
return this.statusDetails.sensitive === true
},
visibility() {
return this.statusDetails.visibility
},
},
methods: {
changeSensitivity(v) {
this.$store
.dispatch('adminChangeStatusScope', {
opts: { id: this.statusDetails.id, sensitive: v },
})
.then((res) => parseStatus(res))
.then((s) => (this.statusCache = s))
},
changeVisibility(v) {
this.$store
.dispatch('adminChangeStatusScope', {
opts: { id: this.statusDetails.id, visibility: v },
})
.then((res) => parseStatus(res))
.then((s) => (this.statusCache = s))
},
/**
* show the confirmation box for bulk actions.
* @param {string} box ref name specified for the confirm component
*/
confirmSelection(box) {
this.$refs[box].show()
this.$refs.dropdown.hidePopover()
},
/**
* called when a bulk action was confirmed
* @param {string} action
*/
selectionConfirmed(action, opts) {
const restricted = []
const s = this.$refs.userList.getSelected()
s.forEach((u) => {
if (
restricted.includes(action) !== false ||
u.id !== this.$store.state.users.currentUser.id
) {
this.$store.dispatch(action, {
id: this.statusDetails.id,
...(opts || {}),
})
}
})
this.reset()
},
},
components: {
Checkbox,
Select,
Status,
},
/**
* fetch and cache status info
*/
mounted() {
this.$store
.dispatch('adminChangeStatusScope', {
opts: { id: this.statusDetails.id },
})
.then((res) => parseStatus(res))
.then((s) => (this.statusCache = s))
},
}
export default AdminStatusCard

View file

@ -0,0 +1,106 @@
<template>
<div class="setting-item">
<h2> {{ $t('admin_dash.users.title_info') }}: </h2>
<ul
class="setting-list"
>
<li>
<span> {{ $t('admin_dash.users.status_id') }}: {{ statusDetails.id }} </span>
</li>
<li>
<span> {{ $t('admin_dash.users.created_at') }}: {{ new Date(statusDetails.created_at).toLocaleString() }} </span>
</li>
<li>
<span v-if="statusDetails.edited_at !== null"> {{ $t('admin_dash.users.edited_at') }}: {{ new Date(statusDetails.edited_at).toLocaleString() }} </span>
</li>
</ul>
<h2> {{ $t('admin_dash.users.title_content') }}: </h2>
<ul
class="setting-list"
>
<li>
<Status
v-if="typeof(statusCache) !== 'undefined'"
class="Notification"
:compact="true"
:statusoid="statusCache"
@interacted="false"
/>
</li>
<p> action dropdown thingy </p>
<!--
<li>
<button
class="button button-default btn"
type="button"
@click="deleteStatus(status.id)"
>
{{ $t('admin_dash.users.delete_status') }}
</button>
</li>
<li>
<Checkbox
:model-value="isSensitive"
@update:model-value="v => changeSensitivity(v)"
>
{{ $t('admin_dash.users.content_nsfw') }}
</Checkbox>
</li>
<li>
<Select
:model-value="visibility"
@update:model-value="v => changeVisibility(v)"
>
<option
value="public"
>
{{ $t('admin_dash.users.scope_public') }}
</option>
<option
value="unlisted"
>
{{ $t('admin_dash.users.scope_unlisted') }}
</option>
<option
value="private"
>
{{ $t('admin_dash.users.scope_private') }}
</option>
<option
value="direct"
>
{{ $t('admin_dash.users.scope_direct') }}
</option>
</Select>
</li>
<li>
<a :href="statusDetails.url"> {{ $t('admin_dash.users.link_source') }} </a>
</li>
-->
</ul>
<!--
<div v-if="!jsonExpanded">
<button
class="button button-default btn"
type="button"
@click="jsonExpanded = !jsonExpanded"
>
{{ $t('admin_dash.users.expand_raw_info') }}
</button>
</div>
<div v-else>
<button
class="button button-default btn"
type="button"
@click="jsonExpanded = !jsonExpanded"
>
{{ $t('admin_dash.users.collapse_raw_info') }}
</button>
<h2> {{ $t('admin_dash.users.title_details') }} </h2>
<pre> {{ JSON.stringify(statusDetails, null, 2) }} </pre>
</div>
-->
</div>
</template>
<script src="./admin_status_card.js"></script>

View file

@ -9,6 +9,8 @@ import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import TupleSetting from '../helpers/tuple_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const AuthTab = {
provide() {
return {
@ -30,9 +32,7 @@ const AuthTab = {
computed: {
...SharedComputedObject(),
LDAPEnabled() {
return this.$store.state.adminSettings.draft[':pleroma'][':ldap'][
':enabled'
]
return useAdminSettingsStore().draft[':pleroma'][':ldap'][':enabled']
},
},
}

View file

@ -7,6 +7,7 @@ import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -37,13 +38,13 @@ const FrontendsTab = {
},
created() {
if (this.user.rights.admin) {
this.$store.dispatch('loadFrontendsStuff')
useAdminSettingsStore().loadFrontendsStuff()
}
},
computed: {
...SharedComputedObject(),
frontends() {
return this.$store.state.adminSettings.frontends
return useAdminSettingsStore().frontends
},
},
methods: {
@ -76,7 +77,7 @@ const FrontendsTab = {
this.working = false
})
.then(async (response) => {
this.$store.dispatch('loadFrontendsStuff')
useAdminSettingsStore().loadFrontendsStuff()
if (response.error) {
const reason = await response.error.json()
useInterfaceStore().pushGlobalNotice({
@ -104,7 +105,7 @@ const FrontendsTab = {
const ref = suggestRef || this.getSuggestedRef(frontend)
const { name } = frontend
this.$store.commit('updateAdminDraft', {
useAdminSettingsStore.updateAdminDraft({
path: [':pleroma', ':frontends', ':primary'],
value: { name, ref },
})

View file

@ -12,6 +12,8 @@ import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import TupleSetting from '../helpers/tuple_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const HTTPTab = {
provide() {
return {
@ -35,7 +37,7 @@ const HTTPTab = {
...SharedComputedObject(),
sslOptions() {
const desc = get(
this.$store.state.adminSettings.descriptions,
useAdminSettingsStore().descriptions,
':pleroma.:http.:adapter.:ssl_options.:versions',
)
return new Set(

View file

@ -12,6 +12,8 @@ import PWAManifestIconsSetting from '../helpers/pwa_manifest_icons_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const InstanceTab = {
provide() {
return {
@ -34,7 +36,7 @@ const InstanceTab = {
computed: {
...SharedComputedObject(),
providersOptions() {
const desc = get(this.$store.state.adminSettings.descriptions, [
const desc = get(useAdminSettingsStore().descriptions, [
':pleroma',
'Pleroma.Web.Metadata',
':providers',
@ -47,7 +49,7 @@ const InstanceTab = {
)
},
limitLocalContentOptions() {
const desc = get(this.$store.state.adminSettings.descriptions, [
const desc = get(useAdminSettingsStore().descriptions, [
':pleroma',
':instance',
':limit_to_local_content',

View file

@ -10,6 +10,8 @@ import ListSetting from '../helpers/list_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const LinksTab = {
provide() {
return {
@ -30,27 +32,27 @@ const LinksTab = {
computed: {
classIsPresent() {
return (
this.$store.state.adminSettings.draft[':pleroma']['Pleroma.Formatter'][
useAdminSettingsStore().draft[':pleroma']['Pleroma.Formatter'][
':class'
] !== false
)
},
relIsPresent() {
return (
this.$store.state.adminSettings.draft[':pleroma']['Pleroma.Formatter'][
useAdminSettingsStore().draft[':pleroma']['Pleroma.Formatter'][
':rel'
] !== false
)
},
truncateIsPresent() {
return (
this.$store.state.adminSettings.draft[':pleroma']['Pleroma.Formatter'][
useAdminSettingsStore().draft[':pleroma']['Pleroma.Formatter'][
':truncate'
] !== false
)
},
truncateDescription() {
return get(this.$store.state.adminSettings.descriptions, [
return get(useAdminSettingsStore().descriptions, [
':pleroma',
'Pleroma.Formatter',
':truncate',
@ -58,7 +60,7 @@ const LinksTab = {
},
ttlSettersOptions() {
const desc = get(
this.$store.state.adminSettings.descriptions,
useAdminSettingsStore().descriptions,
':pleroma.:rich_media.:ttl_setters',
)
return new Set(
@ -70,7 +72,7 @@ const LinksTab = {
},
parsersOptions() {
const desc = get(
this.$store.state.adminSettings.descriptions,
useAdminSettingsStore().descriptions,
':pleroma.:rich_media.:parsers',
)
return new Set(
@ -97,12 +99,12 @@ const LinksTab = {
]
},
mediaProxyEnabled() {
return this.$store.state.adminSettings.draft[':pleroma'][':media_proxy'][
return useAdminSettingsStore().draft[':pleroma'][':media_proxy'][
':enabled'
]
},
mediaInvalidationProvider() {
return this.$store.state.adminSettings.draft[':pleroma'][':media_proxy'][
return useAdminSettingsStore().draft[':pleroma'][':media_proxy'][
':invalidation'
][':provider']
},
@ -110,19 +112,19 @@ const LinksTab = {
},
methods: {
checkRel(e) {
this.$store.commit('updateAdminDraft', {
useAdminSettingsStore.updateAdminDraft({
path: [':pleroma', 'Pleroma.Formatter', ':rel'],
value: e ? '' : false,
})
},
checkClass(e) {
this.$store.commit('updateAdminDraft', {
useAdminSettingsStore.updateAdminDraft({
path: [':pleroma', 'Pleroma.Formatter', ':class'],
value: e ? '' : false,
})
},
checkTruncate(e) {
this.$store.commit('updateAdminDraft', {
useAdminSettingsStore.updateAdminDraft({
path: [':pleroma', 'Pleroma.Formatter', ':truncate'],
value: e ? 20 : false,
})

View file

@ -7,6 +7,8 @@ import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const MailerTab = {
provide() {
return {
@ -26,7 +28,7 @@ const MailerTab = {
computed: {
adaptersLabels() {
const prefix = 'Swoosh.Adapters.'
const descriptions = this.$store.state.adminSettings.descriptions
const descriptions = useAdminSettingsStore().descriptions
const options =
descriptions[':pleroma']['Pleroma.Emails.Mailer'][':adapter']
.suggestions
@ -46,20 +48,20 @@ const MailerTab = {
// ]))
},
adapter() {
return this.$store.state.adminSettings.draft[':pleroma'][
'Pleroma.Emails.Mailer'
][':adapter']
return useAdminSettingsStore().draft[':pleroma']['Pleroma.Emails.Mailer'][
':adapter'
]
},
mailerEnabled() {
return this.$store.state.adminSettings.draft[':pleroma'][
'Pleroma.Emails.Mailer'
][':enabled']
return useAdminSettingsStore().draft[':pleroma']['Pleroma.Emails.Mailer'][
':enabled'
]
},
...SharedComputedObject(),
},
methods: {
adapterHasKey(key) {
const descriptions = this.$store.state.adminSettings.descriptions
const descriptions = useAdminSettingsStore().descriptions
const mailerStuff = descriptions[':pleroma']['Pleroma.Emails.Mailer']
const adapterStuff = mailerStuff[':subgroup,' + this.adapter]
return Object.hasOwn(adapterStuff, key)

View file

@ -7,6 +7,8 @@ import ListSetting from '../helpers/list_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const MediaProxyTab = {
provide() {
return {
@ -25,12 +27,12 @@ const MediaProxyTab = {
},
computed: {
mediaProxyEnabled() {
return this.$store.state.adminSettings.draft[':pleroma'][':media_proxy'][
return useAdminSettingsStore().draft[':pleroma'][':media_proxy'][
':enabled'
]
},
mediaInvalidationProvider() {
return this.$store.state.adminSettings.draft[':pleroma'][':media_proxy'][
return useAdminSettingsStore().draft[':pleroma'][':media_proxy'][
':invalidation'
][':provider']
},

View file

@ -4,6 +4,8 @@ import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const UploadsTab = {
provide() {
return {
@ -40,9 +42,9 @@ const UploadsTab = {
},
computed: {
uploader() {
return this.$store.state.adminSettings.draft[':pleroma'][
'Pleroma.Upload'
][':uploader']
return useAdminSettingsStore().draft[':pleroma']['Pleroma.Upload'][
':uploader'
]
},
...SharedComputedObject(),
},

View file

@ -0,0 +1,133 @@
import { isEmpty } from 'lodash'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import GenericConfirm from 'src/components/confirm_modal/generic_confirm.vue'
import List from 'src/components/list/list.vue'
import ModerationTools from 'src/components/moderation_tools/moderation_tools.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Select from 'src/components/select/select.vue'
import AdminCard from 'src/components/settings_modal/admin_tabs/admin_card.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
const UsersTab = {
components: {
Checkbox,
Select,
BasicUserCard,
List,
ProgressButton,
AdminCard,
TabSwitcher,
ModerationTools,
GenericConfirm,
},
provide() {
return {
defaultDraftMode: true,
defaultSource: 'admin',
}
},
data() {
return {
filtersOrigin: 'local',
filtersActivity: 'all',
filtersPrivileges: 'all',
filtersNeedApproval: false,
filtersUnconfirmed: false,
filtersQuery: '',
filtersName: '',
filtersEmail: '',
expandedUser: null,
}
},
computed: {
/**
* do we filter for admins?
* @returns {boolean}
*/
filtersIsAdmin() {
return (
this.filtersPrivileges === 'admin' ||
this.filtersPrivileges === 'modsnadmins'
)
},
/**
* do we filter for moderators?
* @returns {boolean}
*/
filtersIsModerator() {
return (
this.filtersPrivileges === 'moderator' ||
this.filtersPrivileges === 'modsnadmins'
)
},
/**
* do we filter for active users?
* @returns {boolean}
*/
filtersActive() {
return this.filtersActivity === 'active'
},
/**
* do we filter for deactivated users?
* @returns {boolean}
*/
filtersDeactivated() {
return this.filtersActivity === 'deactivated'
},
/**
* do we filter for local users?
* @returns {boolean}
*/
filtersLocal() {
return this.filtersOrigin === 'local'
},
/**
* do we filter for external users?
* @return {boolean}
*/
filtersExternal() {
return this.filtersOrigin === 'external'
},
fetchOptions() {
const filters = {
isAdmin: this.filtersIsAdmin,
isModerator: this.filtersIsModerator,
active: this.filtersActive,
deactivated: this.filtersDeactivated,
local: this.filtersLocal,
external: this.filtersExternal,
needApproval: this.filtersNeedApproval,
unconfirmed: this.filtersUnconfirmed,
}
return {
query: this.filtersQuery,
name: this.filtersName,
email: this.filtersEmail,
pageSize: 50,
filters,
}
},
},
methods: {
fetchUsers(page) {
return useAdminSettingsStore()
.fetchUsers({
...this.fetchOptions,
page,
})
.then(({ count, users }) => ({ count, items: users }))
},
},
watch: {
fetchOptions() {
this.$refs.usersList.reset()
},
},
}
export default UsersTab

View file

@ -0,0 +1,30 @@
.UsersTab {
max-height: 100%;
display: flex;
flex-direction: column;
overflow-y: hidden;
.filters-section {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5em 1em;
> div {
flex: 0 1 auto;
}
.filter {
display: block;
margin-bottom: 0.5em;
min-width: 14em;
.query-label {
margin-bottom: 0.5em;
}
> input {
width: 100%;
}
}
}
}

View file

@ -0,0 +1,146 @@
<template>
<div
class="UsersTab"
:label="$t('admin_dash.users.management')"
>
<h3>
{{ $t('admin_dash.users.title') }}
</h3>
<div class="filters-section">
<label class="filter">
<div class="query-label">
{{ $t('admin_dash.users.labels.query') }}
</div>
<input
v-model="filtersQuery"
class="input string-input filter-input"
>
</label>
<label class="filter">
<div class="query-label">
{{ $t('admin_dash.users.labels.name') }}
</div>
<input
v-model="filtersName"
class="input string-input filter-input"
>
</label>
<label class="filter">
<div class="query-label">
{{ $t('admin_dash.users.labels.email') }}
</div>
<input
v-model="filtersEmail"
class="input string-input filter-input"
>
</label>
<div class="filter">
<div class="query-label">
{{ $t('admin_dash.users.labels.origin') }}
</div>
<Select
v-model="filtersOrigin"
>
<option
value="all"
>
{{ $t('admin_dash.users.options.all') }}
</option>
<option
value="local"
>
{{ $t('admin_dash.users.options.only_local') }}
</option>
<option
value="external"
>
{{ $t('admin_dash.users.options.only_external') }}
</option>
</Select>
</div>
<div class="filter">
<div class="query-label">
{{ $t('admin_dash.users.labels.activity') }}
</div>
<Select
v-model="filtersActivity"
>
<option
value="all"
>
{{ $t('admin_dash.users.options.all') }}
</option>
<option
value="active"
>
{{ $t('admin_dash.users.options.only_active') }}
</option>
<option
value="deactivated"
>
{{ $t('admin_dash.users.options.only_deactivated') }}
</option>
</Select>
</div>
<div class="filter">
<div class="query-label">
{{ $t('admin_dash.users.labels.privileges') }}
</div>
<Select v-model="filtersPrivileges">
<option
value="all"
>
{{ $t('admin_dash.users.options.all') }}
</option>
<option
value="admin"
>
{{ $t('admin_dash.users.options.only_admins') }}
</option>
<option
value="modsnadmins"
>
{{ $t('admin_dash.users.options.only_privileged') }}
</option>
<option
value="moderator"
>
{{ $t('admin_dash.users.options.only_moderators') }}
</option>
</Select>
</div>
<div class="filter">
<Checkbox v-model="filtersNeedApproval">
{{ $t('admin_dash.users.options.only_unapproved') }}
</Checkbox>
</div>
<div class="filter">
<Checkbox v-model="filtersUnconfirmed">
{{ $t('admin_dash.users.options.only_unconfirmed') }}
</Checkbox>
</div>
</div>
<List
ref="usersList"
:fetch-function="fetchUsers"
@select="onSelect"
selectable
scrollable
>
<template #header="{selected}">
<ModerationTools :users="selected" />
</template>
<template #item="{item}">
<AdminCard :user-id="item.id" />
</template>
<template #load>
<span> loading </span>
</template>
<template #empty>
<span> no users </span>
</template>
</List>
</div>
</template>
<script src="./users_tab.js"></script>
<style lang="scss" src="./users_tab.scss"></style>

View file

@ -4,6 +4,7 @@ import DraftButtons from './draft_buttons.vue'
import LocalSettingIndicator from './local_setting_indicator.vue'
import ModifiedIndicator from './modified_indicator.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
@ -125,14 +126,14 @@ export default {
draft: {
get() {
if (this.realSource === 'admin' || this.path == null) {
return get(this.$store.state.adminSettings.draft, this.canonPath)
return get(useAdminSettingsStore().draft, this.canonPath)
} else {
return this.localDraft
}
},
set(value) {
if (this.realSource === 'admin' || this.path == null) {
this.$store.commit('updateAdminDraft', {
useAdminSettingsStore.updateAdminDraft({
path: this.canonPath,
value,
})
@ -164,10 +165,7 @@ export default {
: this.draftMode
},
backendDescription() {
return get(
this.$store.state.adminSettings.descriptions,
this.descriptionPath,
)
return get(useAdminSettingsStore().descriptions, this.descriptionPath)
},
backendDescriptionLabel() {
if (this.realSource !== 'admin') return ''
@ -221,10 +219,7 @@ export default {
let parentValue = null
if (this.parentPath !== undefined && this.realSource === 'admin') {
if (this.realDraftMode) {
parentValue = get(
this.$store.state.adminSettings.draft,
this.parentPath,
)
parentValue = get(useAdminSettingsStore().draft, this.parentPath)
} else {
parentValue = get(this.configSource, this.parentPath)
}
@ -243,7 +238,7 @@ export default {
case 'profile':
return this.$store.state.profileConfig
case 'admin':
return this.$store.state.adminSettings.config
return useAdminSettingsStore().config
default:
return useMergedConfigStore().mergedConfig
}
@ -259,7 +254,7 @@ export default {
this.$store.dispatch('setProfileOption', { name: k, value: v })
case 'admin':
return (k, v) =>
this.$store.dispatch('pushAdminSetting', { path: k, value: v })
useAdminSettingsStore.pushAdminSetting({ path: k, value: v })
default:
return (readPath, value) => {
const writePath = `${readPath}`
@ -372,9 +367,7 @@ export default {
canHardReset() {
return (
this.realSource === 'admin' &&
this.$store.state.adminSettings.modifiedPaths?.has(
this.canonPath.join(' -> '),
)
useAdminSettingsStore().modifiedPaths?.has(this.canonPath.join(' -> '))
)
},
matchesExpertLevel() {

View file

@ -1,6 +1,7 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const SharedComputedObject = () => ({
@ -8,9 +9,11 @@ const SharedComputedObject = () => ({
...mapPiniaState(useMergedConfigStore, {
expertLevel: (store) => store.mergedConfig.expertLevel,
}),
...mapPiniaState(useAdminSettingsStore, {
adminConfig: (store) => store.config,
adminDraft: (store) => store.draft,
}),
...mapState({
adminConfig: (state) => state.adminSettings.config,
adminDraft: (state) => state.adminSettings.draft,
user: (state) => state.users.currentUser,
}),
})

View file

@ -40,6 +40,7 @@
.tab-slot-wrapper {
flex: 1 1 auto;
position: relative;
height: 100%;
padding: 0 1em;
overflow-y: auto;
@ -56,10 +57,16 @@
}
&.-full-height {
height: 100%;
> * {
height: 100%;
}
}
&.-full-width.-full-height {
position: absolute;
inset: 0;
}
}
}

View file

@ -8,6 +8,7 @@ import Modal from 'src/components/modal/modal.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import Popover from 'src/components/popover/popover.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
@ -232,10 +233,10 @@ const SettingsModal = {
return clone
},
resetAdminDraft() {
this.$store.commit('resetAdminDraft')
useAdminSettingsStore.resetAdminDraft()
},
pushAdminDraft() {
this.$store.dispatch('pushAdminDraft')
useAdminSettingsStore.pushAdminDraft()
},
...mapActions(useInterfaceStore, [
'temporaryChangesRevert',
@ -265,8 +266,8 @@ const SettingsModal = {
},
adminDraftAny() {
return !isEqual(
this.$store.state.adminSettings.config,
this.$store.state.adminSettings.draft,
useAdminSettingsStore().config,
useAdminSettingsStore().draft,
)
},
},

View file

@ -36,6 +36,38 @@
margin-top: 0.75em;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
align-items: end;
.header-buttons {
display: flex;
flex: 1 0 auto;
justify-content: end;
align-items: end;
&:not(.btn-group) {
gap: 0.5em;
}
.header-text {
flex: 1 0 auto;
}
}
.button-default {
flex: 0 0 auto;
padding: 0.5em;
font-size: 1rem;
}
.popover-wrapper {
display: flex;
}
}
p {
line-height: 1.5;
margin-left: 2em;

View file

@ -1,3 +1,4 @@
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import AuthTab from './admin_tabs/auth_tab.vue'
import EmojiTab from './admin_tabs/emoji_tab.vue'
import FederationTab from './admin_tabs/federation_tab.vue'
@ -15,27 +16,35 @@ import PostsTab from './admin_tabs/posts_tab.vue'
import RatesTab from './admin_tabs/rates_tab.vue'
import RegistrationsTab from './admin_tabs/registrations_tab.vue'
import UploadsTab from './admin_tabs/uploads_tab.vue'
import UsersTab from './admin_tabs/users_tab.vue'
import VerticalTabSwitcher from './helpers/vertical_tab_switcher.jsx'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faChain,
faChartLine,
faCircleNodes,
faDoorOpen,
faDownload,
faEllipsis,
faEnvelope,
faEyeSlash,
faGauge,
faGears,
faGlobe,
faHand,
faInfo,
faKey,
faLaptopCode,
faMessage,
faPaintBrush,
faTowerBroadcast,
faUpload,
faUser,
faWrench,
} from '@fortawesome/free-solid-svg-icons'
@ -45,6 +54,12 @@ library.add(
faChain,
faGlobe,
faLaptopCode,
faPaintBrush,
faBell,
faDownload,
faEyeSlash,
faInfo,
faUser,
faTowerBroadcast,
faEnvelope,
faChartLine,
@ -63,11 +78,12 @@ const SettingsModalAdminContent = {
VerticalTabSwitcher,
InstanceTab,
UsersTab,
LimitsTab,
RegistrationsTab,
EmojiTab,
FrontendsTab,
FederationTab,
LimitsTab,
MailerTab,
UploadsTab,
MediaProxyTab,
@ -94,18 +110,18 @@ const SettingsModalAdminContent = {
return useInterfaceStore().settingsModalState === 'visible'
},
adminDbLoaded() {
return this.$store.state.adminSettings.loaded
return useAdminSettingsStore().loaded
},
adminDescriptionsLoaded() {
return this.$store.state.adminSettings.descriptions !== null
return useAdminSettingsStore().descriptions !== null
},
noDb() {
return this.$store.state.adminSettings.dbConfigEnabled === false
return useAdminSettingsStore().dbConfigEnabled === false
},
},
created() {
if (this.user.rights.admin) {
this.$store.dispatch('loadAdminStuff')
useAdminSettingsStore().loadAdminStuff()
}
},
methods: {

View file

@ -49,6 +49,17 @@
<InstanceTab />
</div>
<div
v-if="adminDbLoaded"
:label="$t('admin_dash.tabs.users')"
icon="user"
data-tab-name="users"
full-width
full-height
>
<UsersTab />
</div>
<div
:label="$t('admin_dash.tabs.registrations')"
icon="door-open"

View file

@ -1,49 +1,30 @@
import { get, map, reject } from 'lodash'
import { get, isEmpty, map, reject } from 'lodash'
import withLoadMore from 'src/components/../hocs/with_load_more/with_load_more'
import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import BlockCard from 'src/components/block_card/block_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
import List from 'src/components/list/list.vue'
import MuteCard from 'src/components/mute_card/mute_card.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import SelectableList from 'src/components/selectable_list/selectable_list.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens.js'
const BlockList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) =>
get($store.state.users.currentUser, 'blockIds', []),
destroy: () => {
/* no-op */
},
childPropName: 'items',
})(SelectableList)
const MuteList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
destroy: () => {
/* no-op */
},
childPropName: 'items',
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) =>
get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items',
})(SelectableList)
const MutesAndBlocks = {
data() {
return {
activeTab: 'profile',
mutesLoading: false,
mutesError: null,
mutesBottomedOut: false,
blocksLoading: false,
blocksError: null,
blocksBottomedOut: false,
domainsLoading: false,
domainsError: null,
domainsBottomedOut: false,
}
},
created() {
@ -52,12 +33,10 @@ const MutesAndBlocks = {
},
components: {
TabSwitcher,
BlockList,
MuteList,
DomainMuteList,
BlockCard,
MuteCard,
DomainMuteCard,
BlockCard,
List,
MuteCard,
ProgressButton,
Autosuggest,
Checkbox,
@ -69,8 +48,20 @@ const MutesAndBlocks = {
user() {
return this.$store.state.users.currentUser
},
blocks() {
return get(this.$store.state.users.currentUser, 'blockIds', [])
},
mutes() {
return get(this.$store.state.users.currentUser, 'muteIds', [])
},
domains() {
return get(this.$store.state.users.currentUser, 'domainMutes', [])
},
},
methods: {
fetchItems(group) {
return () => this.$store.dispatch('fetch' + group, this.userId)
},
importFollows(file) {
return this.$store.state.api.backendInteractor
.importFollows({ file })

View file

@ -1,5 +1,5 @@
.mutes-and-blocks-tab {
height: 100%;
min-height: 100%;
.usersearch-wrapper {
padding: 1em;
@ -26,4 +26,13 @@
margin-top: 1em;
width: 10em;
}
.blocks,
.mutes {
display: flex;
flex-direction: column;
min-height: 100%;
position: absolute;
inset: 0;
}
}

View file

@ -1,9 +1,12 @@
<template>
<tab-switcher
:scrollable-tabs="true"
class="mutes-and-blocks-tab"
>
<div :label="$t('settings.blocks_tab')">
:scrollable-tabs="true"
>
<div
class="blocks"
:label="$t('settings.user_blocks')"
>
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnblockedUsers"
@ -17,9 +20,12 @@
</template>
</Autosuggest>
</div>
<BlockList
:refresh="true"
<List
:get-key="i => i"
:external-items="blocks"
:fetch-function="fetchItems('Blocks')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
@ -51,103 +57,106 @@
<template #empty>
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
</List>
</div>
<div :label="$t('settings.mutes_tab')">
<tab-switcher>
<div :label="$t('settings.user_mutes')">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
<div class="mutes" :label="$t('settings.user_mutes2')">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
<template #default="row">
<MuteCard
:user-id="row.item"
/>
</template>
</Autosuggest>
</div>
<List
:get-key="i => i"
:external-items="mutes"
:fetch-function="fetchItems('Mutes')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => muteUsers(selected)"
>
<template #default="row">
<MuteCard
:user-id="row.item"
/>
{{ $t('user_card.mute') }}
<template #progress>
{{ $t('user_card.mute_progress') }}
</template>
</Autosuggest>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template #progress>
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
<MuteList
:refresh="true"
:get-key="i => i"
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
<template #progress>
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template #progress>
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template #item="{item}">
<MuteCard :user-id="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
</div>
</template>
<template #item="{item}">
<MuteCard :user-id="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</List>
</div>
<div :label="$t('settings.domain_mutes')">
<div class="domain-mute-form">
<Autosuggest
:filter="filterUnMutedDomains"
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
<div :label="$t('settings.domain_mutes2')">
<div class="domain-mute-form">
<Autosuggest
:filter="filterUnMutedDomains"
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
<template #default="row">
<DomainMuteCard
:domain="row.item"
/>
</template>
</Autosuggest>
</div>
<List
:get-key="i => i"
:external-items="domains"
:fetch-function="fetchItems('DomainMutes')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteDomains(selected)"
>
<template #default="row">
<DomainMuteCard
:domain="row.item"
/>
{{ $t('domain_mute_card.unmute') }}
<template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</Autosuggest>
</ProgressButton>
</div>
<DomainMuteList
:refresh="true"
:get-key="i => i"
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
<template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template #item="{item}">
<DomainMuteCard :domain="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
</div>
</tab-switcher>
</template>
<template #item="{item}">
{{ item }}
<DomainMuteCard :domain="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</List>
</div>
</tab-switcher>
</template>

View file

@ -262,7 +262,9 @@ const Status = {
},
muteFilterHits() {
return muteFilterHits(
Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters || {}),
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},

View file

@ -19,6 +19,7 @@ import {
faChevronDown,
faChevronRight,
faExternalLinkAlt,
faEye,
faEyeSlash,
faHistory,
faMinus,
@ -51,6 +52,7 @@ library.add(
faBookmark,
faBookmarkRegular,
faEyeSlash,
faEye,
faThumbtack,
faShareAlt,
faExternalLinkAlt,

View file

@ -3,14 +3,30 @@ import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import ActionButton from './action_button.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEnvelope,
faEye,
faEyeSlash,
faFolderTree,
faGlobe,
faLock,
faLockOpen,
faUser,
} from '@fortawesome/free-solid-svg-icons'
library.add(faUser, faGlobe, faFolderTree)
library.add(
faUser,
faGlobe,
faFolderTree,
faEye,
faEyeSlash,
faLock,
faLockOpen,
faEnvelope,
)
export default {
components: {
@ -61,8 +77,27 @@ export default {
this.domain,
)
},
availableScopes() {
return ['private', 'unlisted', 'direct', 'public'].filter((scope) => {
return scope !== this.status.visibility
})
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
unmuteUser() {
return this.$store.dispatch('unmuteUser', this.user.id)
},
@ -79,6 +114,18 @@ export default {
this.$refs.confirmUser.optionallyPrompt()
}
},
setScope(visibility) {
return useAdminSettingsStore().changeStatusScope({
id: this.status.id,
visibility,
})
},
setSensitive(sensitive) {
useAdminSettingsStore().changeStatusScope({
id: this.status.id,
sensitive,
})
},
toggleConversationMute() {
if (this.conversationIsMuted) {
this.unmuteConversation()

View file

@ -14,6 +14,58 @@
/>
</template>
<template #content>
<div
v-if="button.name === 'changeScope'"
:id="`popup-menu-scope-${randomSeed}`"
class="dropdown-menu"
role="menu"
>
<div
v-for="visibility in availableScopes"
class="menu-item dropdown-item extra-action -icon"
>
<button
class="main-button"
@click="() => setScope(visibility)"
>
<FAIcon
:icon="visibilityIcon(visibility)"
fixed-width
/>
{{ $t('general.scope_in_timeline.' + visibility) }}
</button>
</div>
<div
v-if="status.nsfw"
class="menu-item dropdown-item extra-action -icon"
>
<button
class="main-button"
@click="() => setSensitive(false)"
>
<FAIcon
icon="eye"
fixed-width
/>
{{ $t('status.mark_as_non-sensitive') }}
</button>
</div>
<div
v-else
class="menu-item dropdown-item extra-action -icon"
>
<button
class="main-button"
@click="() => setSensitive(true)"
>
<FAIcon
icon="eye-slash"
fixed-width
/>
{{ $t('status.mark_as_sensitive') }}
</button>
</div>
</div>
<div
v-if="button.name === 'mute'"
:id="`popup-menu-${randomSeed}`"

View file

@ -229,7 +229,7 @@ export const BUTTONS = [
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.includes('messages_delete'))
currentUser.privileges.has('messages_delete'))
)
},
confirm: ({ getters }) => useMergedConfigStore().mergedConfig.modalOnDelete,
@ -243,6 +243,26 @@ export const BUTTONS = [
return dispatch('deleteStatus', { id: status.id })
},
},
{
// =========
// CHANGE SCOPE
// =========
name: 'changeScope',
icon: 'eye',
label: 'status.admin_change_scope',
if({ status, loggedIn, currentUser }) {
return (
loggedIn &&
(status.user.id === currentUser.id ||
currentUser.privileges.has('messages_delete'))
)
},
toggleable: false,
dropdown: true,
action({ status, dispatch, emit }) {
/* prevent hiding */
},
},
{
// =========
// SHARE/COPY

View file

@ -49,6 +49,7 @@
.contents.scrollable-tabs {
flex-basis: 0;
position: relative;
}
}

View file

@ -84,6 +84,12 @@ export default {
default: false,
type: Boolean,
},
// Hide action buttons
hideButtons: {
required: false,
default: false,
type: Boolean,
},
// default - open profile, 'zoom' - zoom, function - call function
avatarAction: {
required: false,
@ -280,7 +286,7 @@ export default {
},
},
visibleRole() {
if (!this.newShowRole) {
if (!this.user.show_role && !this.user.adminData) {
return
}
const rights = this.user.rights

View file

@ -22,6 +22,23 @@
}
}
summary {
font-weight: bold;
cursor: pointer;
}
details {
margin: 0.5em 0;
&[open] summary {
margin-bottom: 0.5em;
}
ul {
margin: 0;
}
}
.input.bio {
height: auto; // override settings default textarea size
}
@ -56,7 +73,7 @@
.user-card-bio {
text-align: center;
margin: 0 0.6em;
margin: 0.6em;
&.input {
margin: 0 1em;
@ -81,15 +98,18 @@
--_still-image-label-visibility: hidden;
}
.admin-data,
.personal-marks {
margin: 0.6em;
padding: 0.6em;
margin: 0 0.6em;
padding: 0 0.6em;
&:not(:last-child) {
border-bottom: 1px dotted var(--border);
}
.highlighter {
margin-bottom: 0.5em;
h4 {
margin-top: 0.6em;
}

View file

@ -104,7 +104,7 @@
/>
</a>
<AccountActions
v-if="isOtherUser && loggedIn"
v-if="isOtherUser && loggedIn && !hideButtons"
:user="user"
:relationship="relationship"
/>
@ -228,7 +228,7 @@
</div>
</div>
<div
v-if="loggedIn && isOtherUser"
v-if="loggedIn && isOtherUser && !hideButtons"
class="user-interactions"
>
<div class="btn-group">
@ -291,8 +291,7 @@
</button>
<ModerationTools
v-if="showModerationMenu"
class="moderation-menu"
:user="user"
:users="[user]"
/>
</div>
<div
@ -348,6 +347,122 @@
/>
</div>
</div>
<div
class="admin-data"
v-if="user.adminData && !hideBio"
>
<details>
<summary>
{{ $t('user_card.admin_data.data') }}
</summary>
<div class="user-profile-fields">
<dl class="user-profile-field">
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.local_id') }}
</dt>
<dd class="user-profile-field-value">
{{ user.adminData.id }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.labels.email') }}
</dt>
<dd
class="user-profile-field-value"
:class="{ faint: user.adminData.email == null }"
>
{{ user.adminData.email == null ? $t('general.not_available') : user.adminData.email }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('general.role.admin') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.roles.admin ? 'yes' : 'no')) }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('general.role.moderator') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.roles.moderator ? 'yes' : 'no')) }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.indicator.confirmed') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.is_confirmed ? 'yes' : 'no')) }}
</dd>
</dl>
<dl
v-if="user.is_local"
class="user-profile-field"
>
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.indicator.approved') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.is_approved ? 'yes' : 'no')) }}
</dd>
</dl>
<dl class="user-profile-field">
<dt class="user-profile-field-name">
{{ $t('admin_dash.users.indicator.suggested') }}
</dt>
<dd class="user-profile-field-value">
{{ $t('general.' + (user.adminData.is_suggested ? 'yes' : 'no')) }}
</dd>
</dl>
<details
v-if="user.is_local"
open
>
<summary>
{{ $t('user_card.admin_data.registration_reason') }}
</summary>
<span>
{{ user.adminData.registration_reason == null ? $t('general.not_available') : user.adminData.registration_reason }}
</span>
</details>
<details open>
<summary>
{{ $t('user_card.admin_data.tags') }}
</summary>
<ul>
<li v-if="user.adminData.tags.length === 0">
{{ $t('general.none') }}
</li>
<li
v-for="tag in user.adminData.tags"
:key="tag"
>
<code>
{{ tag }}
</code>
{{ ' ' }}
</li>
</ul>
</details>
</div>
</details>
</div>
<h3 v-if="editable">
<span>
{{ $t('settings.bio') }}

View file

@ -1,17 +1,15 @@
import { get } from 'lodash'
import { mapState } from 'pinia'
import Conversation from 'src/components/conversation/conversation.vue'
import FollowCard from 'src/components/follow_card/follow_card.vue'
import List from 'src/components/list/list.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Timeline from 'src/components/timeline/timeline.vue'
import UserCard from 'src/components/user_card/user_card.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
@ -19,28 +17,6 @@ import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const FollowerList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchFollowers', props.userId),
select: (props, $store) =>
get($store.getters.findUser(props.userId), 'followerIds', []).map((id) =>
$store.getters.findUser(id),
),
destroy: (props, $store) => $store.dispatch('clearFollowers', props.userId),
childPropName: 'items',
additionalPropNames: ['userId'],
})(List)
const FriendList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchFriends', props.userId),
select: (props, $store) =>
get($store.getters.findUser(props.userId), 'friendIds', []).map((id) =>
$store.getters.findUser(id),
),
destroy: (props, $store) => $store.dispatch('clearFriends', props.userId),
childPropName: 'items',
additionalPropNames: ['userId'],
})(List)
const defaultTabKey = 'statuses'
const UserProfile = {
@ -64,6 +40,8 @@ const UserProfile = {
unmounted() {
this.stopFetching()
useInterfaceStore().setForeignProfileBackground(null)
this.$store.dispatch('clearFollowers', this.userId)
this.$store.dispatch('clearFriends', this.userId)
},
computed: {
timeline() {
@ -104,11 +82,31 @@ const UserProfile = {
compactProfiles() {
return useMergedConfigStore().mergedConfig.compactProfiles
},
friends() {
return get(
this.$store.getters.findUser(this.userId),
'friendIds',
[],
).map((id) => this.$store.getters.findUser(id))
},
followers() {
return get(
this.$store.getters.findUser(this.userId),
'followerIds',
[],
).map((id) => this.$store.getters.findUser(id))
},
},
methods: {
setFooterRef(el) {
this.footerRef = el
},
fetchUsers(group) {
return () =>
this.$store
.dispatch('fetch' + group, this.userId)
.then((result) => ({ items: result }))
},
load(userNameOrId) {
const startFetchingTimeline = (timeline, userId) => {
// Clear timeline only if load another user's profile
@ -203,11 +201,9 @@ const UserProfile = {
components: {
UserCard,
Timeline,
FollowerList,
FriendList,
List,
FollowCard,
TabSwitcher,
Conversation,
},
}

View file

@ -0,0 +1,65 @@
.user-profile {
flex: 2;
.card-wrapper {
border-top-left-radius: var(--roundness);
border-top-right-radius: var(--roundness);
}
.panel-footer {
border-bottom-left-radius: var(--roundness);
border-bottom-right-radius: var(--roundness);
}
// No sticky header on user profile
--currentPanelStack: 0;
.userlist-placeholder {
display: flex;
justify-content: center;
align-items: center;
padding: 2em;
}
.user-info {
margin: 1.2em;
}
&.-admin-view {
.godmode {
padding: 1em;
}
.list-item {
padding: 0;
border-bottom: 1px solid var(--border);
.Status{
width: 100%
}
}
.footer {
background: var(--background);
}
}
}
.user-profile-placeholder {
.panel-body {
display: flex;
justify-content: center;
align-items: center;
padding: 7em;
}
.alert {
padding: 0.75em 5em;
border-width: 2px;
.error-message {
color: var(--text);
font-weight: bold;
}
}
}

View file

@ -39,14 +39,14 @@
:label="$t('user_card.followees')"
:disabled="!user.friends_count"
>
<FriendList
:user-id="userId"
:non-interactive="true"
<List
:fetch-function="fetchUsers('Friends')"
:external-items="friends"
>
<template #item="{item}">
<FollowCard :user="item" />
</template>
</FriendList>
</List>
</div>
<div
v-if="followersTabVisible"
@ -55,9 +55,9 @@
:label="$t('user_card.followers')"
:disabled="!user.followers_count"
>
<FollowerList
:user-id="userId"
:non-interactive="true"
<List
:fetch-function="fetchUsers('Followers')"
:external-items="followers"
>
<template #item="{item}">
<FollowCard
@ -65,7 +65,7 @@
:no-follows-you="isUs"
/>
</template>
</FollowerList>
</List>
</div>
<Timeline
key="media"
@ -126,52 +126,4 @@
<script src="./user_profile.js"></script>
<style lang="scss">
.user-profile {
flex: 2;
.card-wrapper {
border-top-left-radius: var(--roundness);
border-top-right-radius: var(--roundness);
}
.panel-footer {
border-bottom-left-radius: var(--roundness);
border-bottom-right-radius: var(--roundness);
}
// No sticky header on user profile
--currentPanelStack: 0;
.userlist-placeholder {
display: flex;
justify-content: center;
align-items: center;
padding: 2em;
}
.user-info {
margin: 1.2em;
}
}
.user-profile-placeholder {
.panel-body {
display: flex;
justify-content: center;
align-items: center;
padding: 7em;
}
.alert {
padding: 0.75em 5em;
border-width: 2px;
.error-message {
color: var(--text);
font-weight: bold;
}
}
}
</style>
<style src="./user_profile.scss" lang="scss"></style>

View file

@ -0,0 +1,69 @@
import { get } from 'lodash'
import { mapState } from 'pinia'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import List from 'src/components/list/list.vue'
import Status from 'src/components/status/status.vue'
import UserCard from 'src/components/user_card/user_card.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const UserProfileAdminView = {
data() {
return {
userId: null,
godmode: false,
}
},
created() {
this.userId = this.$route.params.id
this.$store.dispatch('fetchUserIfMissing', this.userId)
useInterfaceStore().setForeignProfileBackground(this.user?.background_image)
},
updated() {
useInterfaceStore().setForeignProfileBackground(this.user?.background_image)
},
unmounted() {
useInterfaceStore().setForeignProfileBackground(null)
},
computed: {
fetchOptions() {
return {
pageSize: 20,
godmode: this.godmode,
id: this.userId,
withReblogs: false,
}
},
user() {
return this.$store.getters.findUser(this.userId)
},
},
methods: {
fetchStatuses(page) {
return useAdminSettingsStore().fetchStatuses({
...this.fetchOptions,
page,
})
},
},
components: {
UserCard,
List,
Status,
Checkbox,
},
watch: {
godmode() {
this.$refs.list.reset()
},
},
}
export default UserProfileAdminView

View file

@ -0,0 +1,32 @@
<template>
<div
v-if="user"
class="user-profile -admin-view panel panel-default"
>
<div class="panel-body card-wrapper">
<UserCard
:user-id="userId"
:compact="true"
hide-bio
hide-buttons
/>
<Checkbox class="godmode" v-model="godmode">
{{ $t('admin_dash.users.godmode') }}
</Checkbox>
</div>
<List
ref="list"
:fetch-function="fetchStatuses"
@select="onSelect"
scrollable
>
<template #item="{item}">
<Status :statusoid="item" />
</template>
</List>
</div>
</template>
<script src="./user_profile_admin_view.js"></script>
<style src="./user_profile.scss" lang="scss"></style>

View file

@ -1,119 +0,0 @@
// eslint-disable-next-line no-unused
import { isEmpty } from 'lodash'
import { h } from 'vue'
import { getComponentProps } from '../../services/component_utils/component_utils'
import './with_load_more.scss'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
library.add(faCircleNotch)
const withLoadMore =
({
fetch, // function to fetch entries and return a promise
select, // function to select data from store
unmounted, // function called at "destroyed" lifecycle
childPropName = 'entries', // name of the prop to be passed into the wrapped component
additionalPropNames = [], // additional prop name list of the wrapper component
}) =>
(WrappedComponent) => {
const originalProps = Object.keys(getComponentProps(WrappedComponent))
const props = originalProps
.filter((v) => v !== childPropName)
.concat(additionalPropNames)
return {
props,
data() {
return {
loading: false,
bottomedOut: false,
error: false,
entries: [],
}
},
created() {
window.addEventListener('scroll', this.scrollLoad)
if (this.entries.length === 0) {
this.fetchEntries()
}
},
unmounted() {
window.removeEventListener('scroll', this.scrollLoad)
unmounted && unmounted(this.$props, this.$store)
},
methods: {
// Entries is not a computed because computed can't track the dynamic
// selector for changes and won't trigger after fetch.
updateEntries() {
this.entries = select(this.$props, this.$store) || []
},
fetchEntries() {
if (!this.loading) {
this.loading = true
this.error = false
fetch(this.$props, this.$store)
.then((newEntries) => {
this.loading = false
this.bottomedOut = isEmpty(newEntries)
})
.catch(() => {
this.loading = false
this.error = true
})
.finally(() => {
this.updateEntries()
})
}
},
scrollLoad(e) {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.loading === false &&
this.bottomedOut === false &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchEntries()
}
},
},
render() {
const props = {
...this.$props,
[childPropName]: this.entries,
}
const children = this.$slots
return (
<div class="with-load-more">
<WrappedComponent {...props}>{children}</WrappedComponent>
<div class="with-load-more-footer">
{this.error && (
<button
onClick={this.fetchEntries}
class="button-unstyled -link -fullwidth alert error"
>
{this.$t('general.generic_error')}
</button>
)}
{!this.error && this.loading && (
<FAIcon spin icon="circle-notch" />
)}
{!this.error && !this.loading && !this.bottomedOut && (
<a onClick={this.fetchEntries} role="button" tabindex="0">
{this.$t('general.more')}
</a>
)}
</div>
</div>
)
},
}
}
export default withLoadMore

View file

@ -1,16 +0,0 @@
.with-load-more {
&-footer {
padding: 0.9em;
text-align: center;
border-top: 1px solid;
border-top-color: var(--border);
.error {
font-size: 1rem;
}
a {
cursor: pointer;
}
}
}

View file

@ -1,94 +0,0 @@
// eslint-disable-next-line no-unused
import { isEmpty } from 'lodash'
import { h } from 'vue'
import { getComponentProps } from '../../services/component_utils/component_utils'
import './with_subscription.scss'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
library.add(faCircleNotch)
const withSubscription =
({
fetch, // function to fetch entries and return a promise
select, // function to select data from store
childPropName = 'content', // name of the prop to be passed into the wrapped component
additionalPropNames = [], // additional prop name list of the wrapper component
}) =>
(WrappedComponent) => {
const originalProps = Object.keys(getComponentProps(WrappedComponent))
const props = originalProps
.filter((v) => v !== childPropName)
.concat(additionalPropNames)
return {
props: [
...props,
'refresh', // boolean saying to force-fetch data whenever created
],
data() {
return {
loading: false,
error: false,
}
},
computed: {
fetchedData() {
return select(this.$props, this.$store)
},
},
created() {
if (this.refresh || isEmpty(this.fetchedData)) {
this.fetchData()
}
},
methods: {
fetchData() {
if (!this.loading) {
this.loading = true
this.error = false
fetch(this.$props, this.$store)
.then(() => {
this.loading = false
})
.catch(() => {
this.error = true
this.loading = false
})
}
},
},
render() {
if (!this.error && !this.loading) {
const props = {
...this.$props,
[childPropName]: this.fetchedData,
}
const children = this.$slots
return (
<div class="with-subscription">
<WrappedComponent {...props}>{children}</WrappedComponent>
</div>
)
} else {
return (
<div class="with-subscription-loading">
{this.error ? (
<a onClick={this.fetchData} class="alert error">
{this.$t('general.generic_error')}
</a>
) : (
<FAIcon spin icon="circle-notch" />
)}
</div>
)
}
},
}
}
export default withSubscription

View file

@ -1,10 +0,0 @@
.with-subscription {
&-loading {
padding: 0.7em;
text-align: center;
.error {
font-size: 1rem;
}
}
}

View file

@ -86,6 +86,7 @@
"apply": "Apply",
"submit": "Submit",
"more": "More",
"no_more": "No more items",
"loading": "Loading…",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
@ -105,6 +106,9 @@
"undo": "Undo",
"yes": "Yes",
"no": "No",
"none": "None",
"not_applicable": "N/A",
"not_available": "N/A",
"peek": "Peek",
"scroll_to_top": "Scroll to top",
"role": {
@ -381,6 +385,9 @@
"selectable_list": {
"select_all": "Select all"
},
"page_list": {
"load_more": "Load more"
},
"settings": {
"invalid_settings_imported": "Error importing settings",
"add_language": "Add fallback language",
@ -584,6 +591,9 @@
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"domain_mutes2": "Excluded domains",
"user_mutes2": "Muted users",
"user_blocks": "Blocked users",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels. Recommended aspect ratio is 1:1",
"banner_size_instruction": "The recommended minimum size for banner images is 450x150 pixels. Recommended aspect ratio is 3:1",
"pad_emoji": "Pad emoji with spaces when adding from picker",
@ -1156,6 +1166,7 @@
"tabs": {
"nodb": "No DB Config",
"instance": "Instance",
"users": "Users",
"limits": "Limits",
"frontends": "Front-ends",
"mailer": "EMails",
@ -1284,6 +1295,57 @@
"adapter": "Mailing Adapter",
"auth": "Authentication"
},
"users": {
"title": "Users",
"local_id": "Local ID",
"godmode": "Show direct messages",
"labels": {
"query": "Search",
"name": "Name",
"name_colon": "Name:",
"email": "Email",
"email_colon": "Email:",
"handle_colon": "Handle:",
"origin": "Origin",
"activity": "Activity",
"privileges": "Privileges"
},
"tags": {
"add_new": "Add New Tag",
"new_title": "Enter New Tag And Confirm",
"yes": "Add",
"no": "Abort"
},
"options": {
"all": "All",
"only_local": "Only Local",
"only_external": "Only External",
"only_active": "Only Active",
"only_deactivated": "Only Deactivated",
"only_admins": "Only Admins",
"only_privileged": "Only Privileged",
"only_moderators": "Only Moderators",
"only_unapproved": "Exclude Approved",
"only_unconfirmed": "Exclude Confirmed"
},
"filters": {
"show_direct": "Show Direct Posts",
"show_reblogs": "Show Reblogs",
"ascending": "Oldest First",
"descending": "Newest First"
},
"indicator": {
"admin": "Admin",
"moderator": "Moderator",
"active": "Active",
"deactivated": "Deactivated",
"confirmed": "Confirmed",
"unconfirmed": "Pending confirmation",
"approved": "Approved",
"suggested": "Suggested",
"unapproved": "Pending approval"
}
},
"limits": {
"arbitrary_limits": "Arbitrary limits",
"posts": "Post limits",
@ -1628,7 +1690,10 @@
"invisible_quote": "Quoted status unavailable: {link}",
"more_actions": "More actions on this status",
"loading": "Loading...",
"load_error": "Unable to load status: {error}"
"load_error": "Unable to load status: {error}",
"admin_change_scope": "Change visibility",
"mark_as_sensitive": "Sensitive",
"mark_as_non-sensitive": "Non-sensitive"
},
"user_card": {
"approve": "Approve",
@ -1716,15 +1781,28 @@
"group": "Group",
"birthday": "Born {birthday}",
"joined": "Joined",
"admin_data": {
"data": "Administrative info",
"registration_reason": "Registration reason",
"tags": "Tags"
},
"admin_menu": {
"moderation": "Moderation",
"grant_admin": "Grant Admin",
"revoke_admin": "Revoke Admin",
"grant_moderator": "Grant Moderator",
"revoke_moderator": "Revoke Moderator",
"activate_account": "Activate account",
"deactivate_account": "Deactivate account",
"delete_account": "Delete account",
"activate_account": "Activate",
"deactivate_account": "Deactivate",
"delete_account": "Delete",
"suggest_account": "Add to suggested",
"remove_suggested_account": "Remove from suggested",
"approve_account": "Approve",
"confirm_account": "Confirm",
"show_statuses": "Show all posts",
"disable_mfa": "Disable MFA",
"force_nsfw": "Mark all posts as NSFW",
"strip_media": "Remove media from posts",
"force_unlisted": "Force posts to be unlisted",
@ -1732,8 +1810,48 @@
"disable_remote_subscription": "Disallow following user from remote instances",
"disable_any_subscription": "Disallow following user at all",
"quarantine": "Disallow user posts from federating",
"delete_user": "Delete user",
"delete_user_data_and_deactivate_confirmation": "This will permanently delete the data from this account and deactivate it. Are you absolutely sure?"
"require_password_change": "Require Password Change",
"resend_confirmation": "Resend Confirmation Email",
"confirm_modal": {
"delete_title": "User deletion",
"delete_content": "Delete user {user}? | Delete {count} users?",
"delete_content_2": "This will permanently delete the data from this accounts and deactivate it. Are you absolutely sure?",
"activate_title": "User activation",
"activate_content": "Activate user {user}? | Activate {count} users?",
"deactivate_content": "Dectivate user {user}? | Dectivate {count} users?",
"approval_title": "Approve users",
"approval_content": "Approve user {user}? | Approve {count} users?",
"confirm_title": "Confirm users",
"confirm_content": "Approve user {user}? | Approve {count} users?",
"suggest_title": "Suggest users",
"add_suggest_content": "Add user {user} to suggested users list? | Add {count} users to suggested users list?",
"remove_suggest_content": "Remove user {user} from suggested users list? | Add {count} users to suggested users list?",
"rights_title": "Promote users",
"grant_rights_content": "Grant user {user} {name} role? | Grant {count} users {name} role?",
"revoke_rights_content": "Revoke {name} role from user {user}? | Revoke {name} from {count} users?",
"tag_title": "Assign user policy",
"assign_tag_content": "Assign {user} a {name} policy? | Assign {name} policy to {count} users?",
"unassign_tag_content": "Unassign policy {name} from {user}? | Unassign policy {name} from {count} users?",
"resend_confirmation_title": "Email confirmation resend",
"resend_confirmation_content": "Resend confirmation email to {count} users?",
"disable_mfa_title": "Disable MFA",
"disable_mfa_content": "Disable Mult-Factor Authentication for {count} users?",
"require_password_change_title": "Force password change",
"require_password_change_content": "Force {count} users to change password on next login?",
"add": "Add",
"remove": "Remove",
"delete": "Delete",
"activate": "Activate",
"deactivate": "Deactivate",
"grant": "Grant",
"revoke": "Revoke",
"approve": "Approve",
"confirm": "Confirm",
"assign": "Assign",
"unassign": "Unassign",
"send": "Send"
}
},
"highlight_new": {
"disabled": "Don't highlight",

View file

@ -1,294 +0,0 @@
import { cloneDeep, differenceWith, flatten, get, isEqual, set } from 'lodash'
export const defaultState = {
frontends: [],
loaded: false,
needsReboot: null,
config: null,
modifiedPaths: null,
descriptions: null,
draft: null,
dbConfigEnabled: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
}
const adminSettingsStorage = {
state: {
...cloneDeep(defaultState),
},
mutations: {
setInstanceAdminNoDbConfig(state) {
state.loaded = false
state.dbConfigEnabled = false
},
setAvailableFrontends(state, { frontends }) {
state.frontends = frontends.map((f) => {
f.installedRefs = f.installed_refs
if (f.name === 'pleroma-fe') {
f.refs = ['master', 'develop']
} else {
f.refs = [f.ref]
}
return f
})
},
updateAdminSettings(state, { config, modifiedPaths }) {
state.loaded = true
state.dbConfigEnabled = true
state.config = config
state.modifiedPaths = modifiedPaths
},
updateAdminDescriptions(state, { descriptions }) {
state.descriptions = descriptions
},
updateAdminDraft(state, { path, value }) {
const [group, key, subkey] = path
const parent = [group, key, subkey]
set(state.draft, path, value)
// force-updating grouped draft to trigger refresh of group settings
if (path.length > parent.length) {
set(state.draft, parent, cloneDeep(get(state.draft, parent)))
}
},
resetAdminDraft(state) {
state.draft = cloneDeep(state.config)
},
},
actions: {
loadFrontendsStuff({ rootState, commit }) {
rootState.api.backendInteractor
.fetchAvailableFrontends()
.then((frontends) => commit('setAvailableFrontends', { frontends }))
},
loadAdminStuff({ state, rootState, dispatch, commit }) {
rootState.api.backendInteractor
.fetchInstanceDBConfig()
.then((backendDbConfig) => {
if (backendDbConfig.error) {
if (backendDbConfig.error.status === 400) {
backendDbConfig.error.json().then((errorJson) => {
if (/configurable_from_database/.test(errorJson.error)) {
commit('setInstanceAdminNoDbConfig')
}
})
}
} else {
dispatch('setInstanceAdminSettings', { backendDbConfig })
}
})
if (state.descriptions === null) {
rootState.api.backendInteractor
.fetchInstanceConfigDescriptions()
.then((backendDescriptions) =>
dispatch('setInstanceAdminDescriptions', { backendDescriptions }),
)
}
},
setInstanceAdminSettings({ state, commit }, { backendDbConfig }) {
const config = state.config || {}
const modifiedPaths = new Set()
backendDbConfig.configs.forEach((c) => {
const path = [c.group, c.key]
if (c.db) {
// Path elements can contain dot, therefore we use ' -> ' as a separator instead
// Using strings for modified paths for easier searching
c.db.forEach((x) => modifiedPaths.add([...path, x].join(' -> ')))
}
// we need to preserve tuples on second level only, possibly third
// but it's not a case right now.
const convert = (value, preserveTuples, preserveTuplesLv2) => {
if (Array.isArray(value) && value.length > 0 && value[0].tuple) {
if (!preserveTuples) {
return value.reduce((acc, c) => {
if (c.tuple == null) {
return {
...acc,
[c]: c,
}
}
return {
...acc,
[c.tuple[0]]: convert(c.tuple[1], preserveTuplesLv2),
}
}, {})
} else {
return value.map((x) => x.tuple)
}
} else {
if (!preserveTuples) {
return value
} else {
return value.tuple
}
}
}
// for most stuff we want maps since those are more convenient
// however this doesn't allow for multiple values per same key
// so for those cases we want to preserve tuples as-is
// right now it's made exclusively for :pleroma.:rate_limit
// so it might not work properly elsewhere
const preserveTuples = path.find((x) => x === ':rate_limit')
set(config, path, convert(c.value, false, preserveTuples))
})
// patching http adapter config to be easier to handle
const adapter = config[':pleroma'][':http'][':adapter']
if (Array.isArray(adapter)) {
config[':pleroma'][':http'][':adapter'] = {
[':ssl_options']: {
[':versions']: [],
},
}
}
commit('updateAdminSettings', { config, modifiedPaths })
commit('resetAdminDraft')
},
setInstanceAdminDescriptions({ commit }, { backendDescriptions }) {
const convert = (
{ children, description, label, key = '<ROOT>', group, suggestions },
path,
acc,
) => {
const newPath = group ? [group, key] : [key]
const obj = { description, label, suggestions }
if (Array.isArray(children)) {
children.forEach((c) => {
convert(c, newPath, obj)
})
}
set(acc, newPath, obj)
}
const descriptions = {}
backendDescriptions.forEach((d) => convert(d, '', descriptions))
commit('updateAdminDescriptions', { descriptions })
},
// This action takes draft state, diffs it with live config state and then pushes
// only differences between the two. Difference detection only work up to subkey (third) level.
pushAdminDraft({ rootState, state, dispatch }) {
// TODO cleanup paths in modifiedPaths
const convert = (value) => {
if (typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(convert)
} else {
return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] }))
}
}
// Getting all group-keys used in config
const allGroupKeys = flatten(
Object.entries(state.config).map(([group, lv1data]) =>
Object.keys(lv1data).map((key) => ({ group, key })),
),
)
// Only using group-keys where there are changes detected
const changedGroupKeys = allGroupKeys.filter(({ group, key }) => {
return !isEqual(state.config[group][key], state.draft[group][key])
})
// Here we take all changed group-keys and get all changed subkeys
const changed = changedGroupKeys.map(({ group, key }) => {
const config = state.config[group][key]
const draft = state.draft[group][key]
// We convert group-key value into entries arrays
const eConfig = Object.entries(config)
const eDraft = Object.entries(draft)
// Then those entries array we diff so only changed subkey entries remain
// We use the diffed array to reconstruct the object and then shove it into convert()
return {
group,
key,
value: convert(
Object.fromEntries(differenceWith(eDraft, eConfig, isEqual)),
),
}
})
rootState.api.backendInteractor
.pushInstanceDBConfig({
payload: {
configs: changed,
},
})
.then(() => rootState.api.backendInteractor.fetchInstanceDBConfig())
.then((backendDbConfig) =>
dispatch('setInstanceAdminSettings', { backendDbConfig }),
)
},
pushAdminSetting({ rootState, dispatch }, { path, value }) {
const [group, key, ...rest] = Array.isArray(path)
? path
: path.split(/\./g)
const clone = {} // not actually cloning the entire thing to avoid excessive writes
set(clone, rest, value)
// TODO cleanup paths in modifiedPaths
const convert = (value) => {
if (typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(convert)
} else {
return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] }))
}
}
rootState.api.backendInteractor
.pushInstanceDBConfig({
payload: {
configs: [
{
group,
key,
value: convert(clone),
},
],
},
})
.then(() => rootState.api.backendInteractor.fetchInstanceDBConfig())
.then((backendDbConfig) =>
dispatch('setInstanceAdminSettings', { backendDbConfig }),
)
},
resetAdminSetting({ rootState, state, dispatch }, { path }) {
const [group, key, subkey] = Array.isArray(path)
? path
: path.split(/\./g)
state.modifiedPaths.delete(path)
return rootState.api.backendInteractor
.pushInstanceDBConfig({
payload: {
configs: [
{
group,
key,
delete: true,
subkeys: [subkey],
},
],
},
})
.then(() => rootState.api.backendInteractor.fetchInstanceDBConfig())
.then((backendDbConfig) =>
dispatch('setInstanceAdminSettings', { backendDbConfig }),
)
},
},
}
export default adminSettingsStorage

View file

@ -141,7 +141,7 @@ export const INSTANCE_DEFAULT_CONFIG_DEFINITIONS = {
default: false,
},
allowForeignUserBackground: {
description: 'Allow other user\'s profiles to override wallpaper',
description: "Allow other user's profiles to override wallpaper",
default: true,
},
hideInstanceWallpaper: {

View file

@ -1,4 +1,3 @@
import adminSettings from './adminSettings.js'
import api from './api.js'
import chats from './chats.js'
import drafts from './drafts.js'
@ -13,7 +12,6 @@ export default {
users,
api,
profileConfig,
adminSettings,
drafts,
chats,
}

View file

@ -170,15 +170,11 @@ const unmuteDomain = (store, domain) => {
export const mutations = {
tagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
const tags = user.tags || []
const newTags = tags.concat([tag])
user.tags = newTags
user.tags.add(tag)
},
untagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
const tags = user.tags || []
const newTags = tags.filter((t) => t !== tag)
user.tags = newTags
user.tags.delete(tag)
},
updateRight(state, { user: { id }, right, value }) {
const user = state.usersObject[id]
@ -186,9 +182,12 @@ export const mutations = {
newRights[right] = value
user.rights = newRights
},
updateActivationStatus(state, { user: { id }, deactivated }) {
const user = state.usersObject[id]
user.deactivated = deactivated
updateUserAdminData(state, { user }) {
const { id } = user
const localUser = state.usersObject[id]
localUser.adminData = user
localUser.deactivated = !user.is_active
localUser.tags = new Set(user.tags)
},
setCurrentUser(state, user) {
state.lastLoginName = user.screen_name
@ -369,10 +368,22 @@ const users = {
getters,
actions: {
fetchUserIfMissing(store, id) {
if (!store.getters.findUser(id)) {
store.dispatch('fetchUser', id)
const user = store.getters.findUser(id)
if (!user) {
return store.dispatch('fetchUser', id)
} else {
return Promise.resolve(user)
}
},
updateUserAdminData(store, { userAdminData }) {
return store
.dispatch('fetchUserIfMissing', userAdminData.id)
.then((user) => {
user.adminData = userAdminData
store.commit('addNewUsers', [user])
return user
})
},
fetchUser(store, id) {
return store.rootState.api.backendInteractor
.fetchUser({ id })
@ -541,15 +552,6 @@ const users = {
commit('updateUserRelationship', [relationship]),
)
},
toggleActivationStatus({ rootState, commit }, { user }) {
const api = user.deactivated
? rootState.api.backendInteractor.activateUser
: rootState.api.backendInteractor.deactivateUser
api({ user }).then((user) => {
const deactivated = !user.is_active
commit('updateActivationStatus', { user, deactivated })
})
},
registerPushNotifications(store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = useInstanceStore().vapidPublicKey

View file

@ -1,4 +1,4 @@
import { concat, each, get, last, map } from 'lodash'
import { concat, each, last, map } from 'lodash'
import {
parseAttachment,
@ -20,12 +20,6 @@ const CHANGE_EMAIL_URL = '/api/pleroma/change_email'
const CHANGE_PASSWORD_URL = '/api/pleroma/change_password'
const MOVE_ACCOUNT_URL = '/api/pleroma/move_account'
const ALIASES_URL = '/api/pleroma/aliases'
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (screenName, right) =>
`/api/pleroma/admin/users/${screenName}/permission_group/${right}`
const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate'
const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate'
const ADMIN_USERS_URL = '/api/v1/pleroma/admin/users'
const SUGGESTIONS_URL = '/api/v1/suggestions'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
@ -121,7 +115,6 @@ const PLEROMA_CHAT_MESSAGES_URL = (id) => `/api/v1/pleroma/chats/${id}/messages`
const PLEROMA_CHAT_READ_URL = (id) => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) =>
`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const PLEROMA_ADMIN_REPORTS = '/api/v1/pleroma/admin/reports'
const PLEROMA_BACKUP_URL = '/api/v1/pleroma/backups'
const PLEROMA_ANNOUNCEMENTS_URL = '/api/v1/pleroma/admin/announcements'
const PLEROMA_POST_ANNOUNCEMENT_URL = '/api/v1/pleroma/admin/announcements'
@ -138,6 +131,7 @@ const PLEROMA_BOOKMARK_FOLDERS_URL = '/api/v1/pleroma/bookmark_folders'
const PLEROMA_BOOKMARK_FOLDER_URL = (id) =>
`/api/v1/pleroma/bookmark_folders/${id}`
const PLEROMA_ADMIN_REPORTS = '/api/v1/pleroma/admin/reports'
const PLEROMA_ADMIN_CONFIG_URL = '/api/v1/pleroma/admin/config'
const PLEROMA_ADMIN_DESCRIPTIONS_URL =
'/api/v1/pleroma/admin/config/descriptions'
@ -145,6 +139,65 @@ const PLEROMA_ADMIN_FRONTENDS_URL = '/api/v1/pleroma/admin/frontends'
const PLEROMA_ADMIN_FRONTENDS_INSTALL_URL =
'/api/v1/pleroma/admin/frontends/install'
const PLEROMA_ADMIN_USERS_URL = '/api/v1/pleroma/admin/users'
const PLEROMA_ADMIN_USERS_URL_SHOW = (nickname) =>
`/api/v1/pleroma/admin/users/${nickname}`
const PLEROMA_ADMIN_USERS_URL_LIST = ({
page,
pageSize,
filters = {},
query = '',
name = '',
email = '',
}) => {
const {
local = false,
external = false,
active = false,
needApproval = false,
unconfirmed = false,
deactivated = false,
isAdmin = true,
isModerator = true,
} = filters
const filters_str = [
local && 'local',
external && 'external',
active && 'active',
needApproval && 'need_approval',
unconfirmed && 'unconfirmed',
deactivated && 'deactivated',
isAdmin && 'is_admin',
isModerator && 'is_moderator',
]
.filter((x) => x)
.join(',')
return `/api/v1/pleroma/admin/users?page=${page}&page_size=${pageSize}&filters=${filters_str}&query=${query}&name=${name}&email=${email}`
}
const PLEROMA_ADMIN_TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PLEROMA_ADMIN_PERMISSION_GROUP_URL = (right) =>
`/api/pleroma/admin/users/permission_group/${right}`
const PLEROMA_ADMIN_ACTIVATE_USERS_URL = '/api/pleroma/admin/users/activate'
const PLEROMA_ADMIN_DEACTIVATE_USERS_URL = '/api/pleroma/admin/users/deactivate'
const PLEROMA_ADMIN_SUGGEST_USERS_URL = '/api/pleroma/admin/users/suggest'
const PLEROMA_ADMIN_UNSUGGEST_USERS_URL = '/api/pleroma/admin/users/unsuggest'
const PLEROMA_ADMIN_APPROVE_USERS_URL = '/api/v1/pleroma/admin/users/approve'
const PLEROMA_ADMIN_CONFIRM_USERS_URL =
'/api/v1/pleroma/admin/users/confirm_email'
const PLEROMA_ADMIN_RESEND_CONFIRMATION_EMAIL_URL =
'/api/v1/pleroma/admin/users/resend_confirmation_email'
const PLEROMA_ADMIN_LIST_STATUSES_URL = ({
id,
pageSize,
godmode,
withReblogs,
}) =>
`/api/v1/pleroma/admin/users/${id}/statuses?page_size=${pageSize}&godmode=${godmode}&with_reblogs=${withReblogs}`
const PLEROMA_ADMIN_CHANGE_STATUS_SCOPE_URL = (id) =>
`/api/v1/pleroma/admin/statuses/${id}`
const PLEROMA_ADMIN_REQUIRE_PASSWORD_CHANGE_URL =
'/api/v1/pleroma/admin/users/force_password_reset'
const PLEROMA_ADMIN_DISABLE_MFA_URL = '/api/v1/pleroma/admin/users/disable_mfa'
const PLEROMA_EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
const PLEROMA_EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
const PLEROMA_EMOJI_PACKS_URL = (page, pageSize) =>
@ -205,8 +258,11 @@ const promisedRequest = ({
}
}
return fetch(url, options).then((response) => {
return new Promise((resolve, reject) =>
response
return new Promise((resolve, reject) => {
// 204 is "No content", which fails to parse json (as you'd might think)
if (response.ok && response.status === 204) resolve()
return response
.json()
.then((json) => {
if (!response.ok) {
@ -230,8 +286,8 @@ const promisedRequest = ({
response,
),
)
}),
)
})
})
})
}
@ -723,89 +779,117 @@ const fetchStatusHistory = ({ status, credentials }) => {
})
}
const tagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const form = {
nicknames: [screenName],
tags: [tag],
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(TAG_USER_URL, {
method: 'PUT',
headers,
body: JSON.stringify(form),
})
}
const untagUser = ({ tag, credentials, user }) => {
const screenName = user.screen_name
const body = {
nicknames: [screenName],
tags: [tag],
}
const headers = authHeaders(credentials)
headers['Content-Type'] = 'application/json'
return fetch(TAG_USER_URL, {
method: 'DELETE',
headers,
body: JSON.stringify(body),
})
}
const addRight = ({ right, credentials, user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
method: 'POST',
headers: authHeaders(credentials),
body: {},
})
}
const deleteRight = ({ right, credentials, user }) => {
const screenName = user.screen_name
return fetch(PERMISSION_GROUP_URL(screenName, right), {
method: 'DELETE',
headers: authHeaders(credentials),
body: {},
})
}
const activateUser = ({ credentials, user: { screen_name: nickname } }) => {
const adminSetUsersTags = ({
tags,
credentials,
value,
screen_names: nicknames,
}) => {
return promisedRequest({
url: ACTIVATE_USER_URL,
url: PLEROMA_ADMIN_TAG_USER_URL,
method: value ? 'PUT' : 'DELETE',
credentials,
payload: {
nicknames,
tags,
},
})
}
const adminSetUsersRight = ({
right,
credentials,
value,
screen_names: nicknames,
}) => {
return promisedRequest({
url: PLEROMA_ADMIN_PERMISSION_GROUP_URL(right),
method: value ? 'POST' : 'DELETE',
credentials,
payload: {
nicknames,
},
})
}
const adminSetUsersActivationStatus = ({
credentials,
screen_names: nicknames,
value,
}) => {
return promisedRequest({
url: value
? PLEROMA_ADMIN_ACTIVATE_USERS_URL
: PLEROMA_ADMIN_DEACTIVATE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames: [nickname],
nicknames,
},
}).then((response) => get(response, 'users.0'))
}).then((response) => response.users)
}
const deactivateUser = ({ credentials, user: { screen_name: nickname } }) => {
const adminSetUsersApprovalStatus = ({
credentials,
screen_names: nicknames,
}) => {
return promisedRequest({
url: DEACTIVATE_USER_URL,
url: PLEROMA_ADMIN_APPROVE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames: [nickname],
nicknames,
},
}).then((response) => get(response, 'users.0'))
}).then((response) => response.users)
}
const deleteUser = ({ credentials, user }) => {
const screenName = user.screen_name
const headers = authHeaders(credentials)
const adminSetUsersConfirmationStatus = ({
credentials,
screen_names: nicknames,
}) => {
return promisedRequest({
url: PLEROMA_ADMIN_CONFIRM_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
}
return fetch(`${ADMIN_USERS_URL}?nickname=${screenName}`, {
const adminSetUsersSuggestionStatus = ({
credentials,
screen_names: nicknames,
value,
}) => {
return promisedRequest({
url: value
? PLEROMA_ADMIN_SUGGEST_USERS_URL
: PLEROMA_ADMIN_UNSUGGEST_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
}
const adminGetUserData = ({ credentials, screen_name: nickname }) => {
return promisedRequest({
url: PLEROMA_ADMIN_USERS_URL_SHOW(nickname),
method: 'GET',
credentials,
})
}
const adminDeleteAccounts = ({ credentials, screen_names: nicknames }) => {
return promisedRequest({
url: PLEROMA_ADMIN_USERS_URL,
method: 'DELETE',
headers,
credentials,
payload: {
nicknames,
},
})
}
@ -1617,6 +1701,90 @@ const dismissAnnouncement = ({ id, credentials }) => {
})
}
const adminListUsers = ({ opts, credentials }) => {
// the reported list is hardly useful because standards are for dating i guess,
// so make sure to fetchIfMissing right afterward using this call
const url = PLEROMA_ADMIN_USERS_URL_LIST(opts)
return promisedRequest({
url,
credentials,
method: 'GET',
})
}
const adminResendConfirmationEmail = ({
screen_names: nicknames,
credentials,
}) => {
const url = PLEROMA_ADMIN_RESEND_CONFIRMATION_EMAIL_URL
return promisedRequest({
url,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
}
const adminRequirePasswordChange = ({
screen_names: nicknames,
credentials,
}) => {
const url = PLEROMA_ADMIN_REQUIRE_PASSWORD_CHANGE_URL
return promisedRequest({
url,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
}
const adminDisableMFA = ({ screen_name: nickname, credentials }) => {
const url = PLEROMA_ADMIN_DISABLE_MFA_URL
return promisedRequest({
url,
credentials,
method: 'PUT',
payload: {
nickname,
},
})
}
const adminListStatuses = ({ opts, credentials }) => {
const url = PLEROMA_ADMIN_LIST_STATUSES_URL(opts)
return promisedRequest({
url,
credentials,
method: 'GET',
})
}
const adminChangeStatusScope = ({
opts: { id, sensitive, visibility },
credentials,
}) => {
const url = PLEROMA_ADMIN_CHANGE_STATUS_SCOPE_URL(id)
var payload = {}
if (typeof sensitive !== 'undefined') {
payload['sensitive'] = sensitive
}
if (typeof visibility !== 'undefined') {
payload['visibility'] = visibility
}
return promisedRequest({
url,
credentials,
method: 'PUT',
payload,
})
}
const announcementToPayload = ({ content, startsAt, endsAt, allDay }) => {
const payload = { content }
@ -2089,7 +2257,6 @@ const listEmojiPacks = ({ page, pageSize }) => {
}
const listRemoteEmojiPacks = ({ instance, page, pageSize }) => {
console.log(instance)
if (!instance.startsWith('http')) {
instance = 'https://' + instance
}
@ -2253,13 +2420,6 @@ const apiService = {
fetchBlocks,
fetchOAuthTokens,
revokeOAuthToken,
tagUser,
untagUser,
deleteUser,
addRight,
deleteRight,
activateUser,
deactivateUser,
register,
getCaptcha,
updateProfileImages,
@ -2347,6 +2507,20 @@ const apiService = {
createBookmarkFolder,
updateBookmarkFolder,
deleteBookmarkFolder,
adminListUsers,
adminGetUserData,
adminResendConfirmationEmail,
adminDeleteAccounts,
adminSetUsersRight,
adminSetUsersTags,
adminSetUsersApprovalStatus,
adminSetUsersConfirmationStatus,
adminSetUsersActivationStatus,
adminSetUsersSuggestionStatus,
adminListStatuses,
adminChangeStatusScope,
adminRequirePasswordChange,
adminDisableMFA,
}
export default apiService

View file

@ -15,234 +15,154 @@ import { isStatusNotification } from '../notification_utils/notification_utils.j
* it would be reverted back to []
*/
const qvitterStatusType = (status) => {
if (status.is_post_verb) {
return 'status'
}
if (status.retweeted_status) {
return 'retweet'
}
if (
(typeof status.uri === 'string' &&
status.uri.match(/(fave|objectType=Favourite)/)) ||
(typeof status.text === 'string' && status.text.match(/favorited/))
) {
return 'favorite'
}
if (
status.text.match(/deleted notice {{tag/) ||
status.qvitter_delete_notice
) {
return 'deletion'
}
if (
status.text.match(/started following/) ||
status.activity_type === 'follow'
) {
return 'follow'
}
return 'unknown'
}
export const parseUser = (data) => {
const output = {}
const masto = Object.hasOwn(data, 'acct')
output._original = data // used for server-side settings
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = masto && !Object.hasOwn(data, 'avatar')
const mastoShort = !Object.hasOwn(data, 'avatar')
output.inLists = null
output.id = String(data.id)
output._original = data // used for server-side settings
if (masto) {
output.screen_name = data.acct
output.fqn = data.fqn
output.statusnet_profile_url = data.url
output.screen_name = data.acct
output.fqn = data.fqn
output.statusnet_profile_url = data.url
if (Object.hasOwn(data, 'mute_expires_at')) {
output.mute_expires_at =
data.mute_expires_at == null ? false : data.mute_expires_at
if (Object.hasOwn(data, 'mute_expires_at')) {
output.mute_expires_at =
data.mute_expires_at == null ? false : data.mute_expires_at
}
if (Object.hasOwn(data, 'block_expires_at')) {
output.block_expires_at =
data.block_expires_at == null ? false : data.block_expires_at
}
// There's nothing else to get
if (mastoShort) {
return output
}
output.emoji = data.emojis
output.name = escapeHtml(data.display_name)
output.name_html = output.name
output.name_unescaped = data.display_name
output.description = data.note
// TODO cleanup this shit, output.description is overriden with source data
output.description_html = data.note
output.fields = data.fields
output.fields_html = data.fields.map((field) => {
return {
name: escapeHtml(field.name),
value: field.value,
}
})
output.fields_text = data.fields.map((field) => {
return {
name: unescape(field.name.replace(/<[^>]*>/g, '')),
value: unescape(field.value.replace(/<[^>]*>/g, '')),
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
output.privileges = []
if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
output.user_highlight = data.pleroma.settings_store['user_highlight']
}
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
}
if (Object.hasOwn(data, 'block_expires_at')) {
output.block_expires_at =
data.block_expires_at == null ? false : data.block_expires_at
output.allow_following_move = data.pleroma.allow_following_move
output.hide_favorites = data.pleroma.hide_favorites
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin,
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
// There's nothing else to get
if (mastoShort) {
return output
}
output.birthday = data.pleroma.birthday
output.emoji = data.emojis
output.name = escapeHtml(data.display_name)
output.name_html = output.name
output.name_unescaped = data.display_name
output.description = data.note
// TODO cleanup this shit, output.description is overriden with source data
output.description_html = data.note
output.fields = data.fields
output.fields_html = data.fields.map((field) => {
return {
name: escapeHtml(field.name),
value: field.value,
}
})
output.fields_text = data.fields.map((field) => {
return {
name: unescape(field.name.replace(/<[^>]*>/g, '')),
value: unescape(field.value.replace(/<[^>]*>/g, '')),
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
output.privileges = []
if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
output.user_highlight = data.pleroma.settings_store['user_highlight']
}
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_favorites = data.pleroma.hide_favorites
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin,
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
output.birthday = data.pleroma.birthday
if (data.pleroma.privileges) {
output.privileges = data.pleroma.privileges
} else if (data.pleroma.is_admin) {
output.privileges = [
'users_read',
'users_manage_invites',
'users_manage_activation_state',
'users_manage_tags',
'users_manage_credentials',
'users_delete',
'messages_read',
'messages_delete',
'instances_delete',
'reports_manage_reports',
'moderation_log_read',
'announcements_manage_announcements',
'emoji_manage_emoji',
'statistics_read',
]
} else if (data.pleroma.is_moderator) {
output.privileges = ['messages_delete', 'reports_manage_reports']
} else {
output.privileges = []
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role = data.source.pleroma.show_role
output.discoverable = data.source.pleroma.discoverable
output.show_birthday = data.pleroma.show_birthday
output.actor_type = data.source.pleroma.actor_type
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
} else {
output.screen_name = data.screen_name
output.name = data.name
output.name_html = data.name_html
output.description = data.description
output.description_html = data.description_html
output.profile_image_url = data.profile_image_url
output.profile_image_url_original = data.profile_image_url_original
output.cover_photo = data.cover_photo
output.friends_count = data.friends_count
// output.bot = ??? missing
output.statusnet_profile_url = data.statusnet_profile_url
output.is_local = data.is_local
output.role = data.role
output.show_role = data.show_role
if (data.rights) {
output.rights = {
moderator: data.rights.delete_others_notice,
admin: data.rights.admin,
}
}
output.no_rich_text = data.no_rich_text
output.default_scope = data.default_scope
output.hide_follows = data.hide_follows
output.hide_followers = data.hide_followers
output.hide_follows_count = data.hide_follows_count
output.hide_followers_count = data.hide_followers_count
output.background_image = data.background_image
// Websocket token
output.token = data.token
// Convert relationsip data to expected format
output.relationship = {
muting: data.muted,
blocking: data.statusnet_blocking,
followed_by: data.follows_you,
following: data.following,
if (data.pleroma.privileges) {
output.privileges = new Set(data.pleroma.privileges)
} else if (data.pleroma.is_admin) {
output.privileges = new Set([
'users_read',
'users_manage_invites',
'users_manage_activation_state',
'users_manage_tags',
'users_manage_credentials',
'users_delete',
'messages_read',
'messages_delete',
'instances_delete',
'reports_manage_reports',
'moderation_log_read',
'announcements_manage_announcements',
'emoji_manage_emoji',
'statistics_read',
])
} else if (data.pleroma.is_moderator) {
output.privileges = new Set(['messages_delete', 'reports_manage_reports'])
} else {
output.privileges = new Set()
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role =
typeof data.source.pleroma.show_role === 'boolean'
? data.source.pleroma.show_role
: true
output.discoverable = data.source.pleroma.discoverable
output.show_birthday = data.pleroma.show_birthday
output.actor_type = data.source.pleroma.actor_type
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.last_status_at = new Date(data.last_status_at)
@ -252,7 +172,7 @@ export const parseUser = (data) => {
if (data.pleroma) {
output.follow_request_count = data.pleroma.follow_request_count
output.tags = data.pleroma.tags
output.tags = new Set(data.pleroma.tags)
// deactivated was changed to is_active in Pleroma 2.3.0
// so check if is_active is present
@ -265,7 +185,7 @@ export const parseUser = (data) => {
output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || []
output.tags = output.tags || new Set()
output.rights = output.rights || {}
output.notification_settings = output.notification_settings || {}
@ -289,20 +209,15 @@ export const parseUser = (data) => {
export const parseAttachment = (data) => {
const output = {}
const masto = !Object.hasOwn(data, 'oembed')
if (masto) {
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
} else {
output.mimetype = data.mimetype
// output.meta = ??? missing
}
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
if (data.type !== 'unknown') {
output.type = data.type
// treat gifv like it is "video"
output.type = data.type === 'gifv' ? 'video' : data.type
} else {
output.type = fileType(output.mimetype)
}
@ -325,116 +240,76 @@ export const parseSource = (data) => {
export const parseStatus = (data) => {
const output = {}
const masto = Object.hasOwn(data, 'account')
if (masto) {
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.raw_html = data.content
output.emojis = data.emojis
output.raw_html = data.content
output.emojis = data.emojis
output.tags = data.tags
output.tags = data.tags
output.edited_at = data.edited_at
output.edited_at = data.edited_at
const { pleroma } = data
const { pleroma } = data
if (data.pleroma) {
output.text = pleroma.content
? data.pleroma.content['text/plain']
: data.content
output.summary = pleroma.spoiler_text
? data.pleroma.spoiler_text['text/plain']
: data.spoiler_text
output.statusnet_conversation_id = data.pleroma.conversation_id
output.is_local = pleroma.local
output.in_reply_to_screen_name = pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible =
pleroma.parent_visible === undefined ? true : pleroma.parent_visible
output.quote_visible = pleroma.quote_visible || true
output.quotes_count = pleroma.quotes_count
output.bookmark_folder_id = pleroma.bookmark_folder
} else {
output.text = data.content
output.summary = data.spoiler_text
}
const quoteRaw = pleroma?.quote || data.quote
const quoteData = quoteRaw ? parseStatus(quoteRaw) : undefined
output.quote = quoteData
output.quote_id =
data.quote?.id ?? data.quote_id ?? quoteData?.id ?? pleroma?.quote_id
output.quote_url = data.quote?.url ?? quoteData?.url ?? pleroma?.quote_url
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary_raw_html = escapeHtml(data.spoiler_text)
output.external_url = data.uri || data.url
output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map((field) => ({
...field,
title_html: escapeHtml(field.title),
}))
}
output.pinned = data.pinned
output.muted = data.muted
if (data.pleroma) {
output.text = pleroma.content
? data.pleroma.content['text/plain']
: data.content
output.summary = pleroma.spoiler_text
? data.pleroma.spoiler_text['text/plain']
: data.spoiler_text
output.statusnet_conversation_id = data.pleroma.conversation_id
output.is_local = pleroma.local
output.in_reply_to_screen_name = pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible =
pleroma.parent_visible === undefined ? true : pleroma.parent_visible
output.quote_visible = pleroma.quote_visible || true
output.quotes_count = pleroma.quotes_count
output.bookmark_folder_id = pleroma.bookmark_folder
} else {
output.favorited = data.favorited
output.fave_num = data.fave_num
output.repeated = data.repeated
output.repeat_num = data.repeat_num
// catchall, temporary
// Object.assign(output, data)
output.type = qvitterStatusType(data)
if (data.nsfw === undefined) {
output.nsfw = isNsfw(data)
if (data.retweeted_status) {
output.nsfw = data.retweeted_status.nsfw
}
} else {
output.nsfw = data.nsfw
}
output.raw_html = data.statusnet_html
output.text = data.text
output.in_reply_to_status_id = data.in_reply_to_status_id
output.in_reply_to_user_id = data.in_reply_to_user_id
output.in_reply_to_screen_name = data.in_reply_to_screen_name
output.statusnet_conversation_id = data.statusnet_conversation_id
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.retweeted_status)
}
output.summary = data.summary
output.summary_html = data.summary_html
output.external_url = data.external_url
output.is_local = data.is_local
output.text = data.content
output.summary = data.spoiler_text
}
const quoteRaw = pleroma?.quote || data.quote
const quoteData = quoteRaw ? parseStatus(quoteRaw) : undefined
output.quote = quoteData
output.quote_id =
data.quote?.id ?? data.quote_id ?? quoteData?.id ?? pleroma?.quote_id
output.quote_url = data.quote?.url ?? quoteData?.url ?? pleroma?.quote_url
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary_raw_html = escapeHtml(data.spoiler_text)
output.external_url = data.uri || data.url
output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map((field) => ({
...field,
title_html: escapeHtml(field.title),
}))
}
output.pinned = data.pinned
output.muted = data.muted
output.id = String(data.id)
output.visibility = data.visibility
output.card = data.card
@ -448,17 +323,13 @@ export const parseStatus = (data) => {
? String(output.in_reply_to_user_id)
: null
output.user = parseUser(masto ? data.account : data.user)
output.user = parseUser(data.account)
output.attentions = ((masto ? data.mentions : data.attentions) || []).map(
parseUser,
)
output.attentions = (data.mentions || []).map(parseUser)
output.attachments = (
(masto ? data.media_attachments : data.attachments) || []
).map(parseAttachment)
output.attachments = (data.media_attachments || []).map(parseAttachment)
const retweetedStatus = masto ? data.reblog : data.retweeted_status
const retweetedStatus = data.reblog
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
@ -478,42 +349,26 @@ export const parseNotification = (data) => {
favourite: 'like',
reblog: 'repeat',
}
const masto = !Object.hasOwn(data, 'ntype')
const output = {}
if (masto) {
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
// TODO: null check should be a temporary fix, I guess.
// Investigate why backend does this.
output.status =
isStatusNotification(output.type) && data.status !== null
? parseStatus(data.status)
: null
output.target = output.type !== 'move' ? null : parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
output.emoji_url = data.emoji_url
if (data.report) {
output.report = data.report
output.report.content = data.report.content
output.report.acct = parseUser(data.report.account)
output.report.actor = parseUser(data.report.actor)
output.report.statuses = data.report.statuses.map(parseStatus)
}
} else {
const parsedNotice = parseStatus(data.notice)
output.type = data.ntype
output.seen = Boolean(data.is_seen)
output.status =
output.type === 'like'
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
output.from_profile =
output.type === 'pleroma:chat_mention'
? parseUser(data.account)
: parseUser(data.from_profile)
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
// TODO: null check should be a temporary fix, I guess.
// Investigate why backend does this.
output.status =
isStatusNotification(output.type) && data.status !== null
? parseStatus(data.status)
: null
output.target = output.type !== 'move' ? null : parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
output.emoji_url = data.emoji_url
if (data.report) {
output.report = data.report
output.report.content = data.report.content
output.report.acct = parseUser(data.report.account)
output.report.actor = parseUser(data.report.actor)
output.report.statuses = data.report.statuses.map(parseStatus)
}
output.created_at = new Date(data.created_at)
@ -522,14 +377,6 @@ export const parseNotification = (data) => {
return output
}
const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return (
(status.tags || []).includes('nsfw') ||
!!(status.text || '').match(nsfwRegex)
)
}
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
const flakeId = opts.flakeId
const parsedLinkHeader = parseLinkHeader(linkHeader)

View file

@ -0,0 +1,465 @@
import { cloneDeep, differenceWith, flatten, get, isEqual, set } from 'lodash'
import { defineStore } from 'pinia'
import { parseStatus } from 'src/services/entity_normalizer/entity_normalizer.service.js'
export const defaultState = {
frontends: [],
loaded: false,
needsReboot: null,
config: null,
modifiedPaths: null,
descriptions: null,
draft: null,
dbConfigEnabled: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
}
export const useAdminSettingsStore = defineStore('adminSettings', {
state: () => ({
...cloneDeep(defaultState),
backendInteractor: window.vuex.state.api.backendInteractor,
}),
actions: {
// Configuration Stuff
setInstanceAdminNoDbConfig() {
this.loaded = false
this.dbConfigEnabled = false
},
updateAdminSettings({ config, modifiedPaths }) {
this.loaded = true
this.dbConfigEnabled = true
this.config = config
this.modifiedPaths = modifiedPaths
},
updateAdminDescriptions({ descriptions }) {
this.descriptions = descriptions
},
updateAdminDraft({ path, value }) {
const [group, key, subkey] = path
const parent = [group, key, subkey]
set(this.draft, path, value)
// force-updating grouped draft to trigger refresh of group settings
if (path.length > parent.length) {
set(this.draft, parent, cloneDeep(get(this.draft, parent)))
}
},
resetAdminDraft() {
this.draft = cloneDeep(this.config)
},
loadAdminStuff() {
this.backendInteractor.fetchInstanceDBConfig().then((backendDbConfig) => {
if (backendDbConfig.error) {
if (backendDbConfig.error.status === 400) {
backendDbConfig.error.json().then((errorJson) => {
if (/configurable_from_database/.test(errorJson.error)) {
this.setInstanceAdminNoDbConfig()
}
})
}
} else {
this.setInstanceAdminSettings({ backendDbConfig })
}
})
if (this.descriptions === null) {
this.backendInteractor
.fetchInstanceConfigDescriptions()
.then((backendDescriptions) =>
this.setInstanceAdminDescriptions({ backendDescriptions }),
)
}
},
setInstanceAdminSettings({ backendDbConfig }) {
const config = this.config || {}
const modifiedPaths = new Set()
backendDbConfig.configs.forEach((c) => {
const path = [c.group, c.key]
if (c.db) {
// Path elements can contain dot, therefore we use ' -> ' as a separator instead
// Using strings for modified paths for easier searching
c.db.forEach((x) => modifiedPaths.add([...path, x].join(' -> ')))
}
// we need to preserve tuples on second level only, possibly third
// but it's not a case right now.
const convert = (value, preserveTuples, preserveTuplesLv2) => {
if (Array.isArray(value) && value.length > 0 && value[0].tuple) {
if (!preserveTuples) {
return value.reduce((acc, c) => {
if (c.tuple == null) {
return {
...acc,
[c]: c,
}
}
return {
...acc,
[c.tuple[0]]: convert(c.tuple[1], preserveTuplesLv2),
}
}, {})
} else {
return value.map((x) => x.tuple)
}
} else {
if (!preserveTuples) {
return value
} else {
return value.tuple
}
}
}
// for most stuff we want maps since those are more convenient
// however this doesn't allow for multiple values per same key
// so for those cases we want to preserve tuples as-is
// right now it's made exclusively for :pleroma.:rate_limit
// so it might not work properly elsewhere
const preserveTuples = path.find((x) => x === ':rate_limit')
set(config, path, convert(c.value, false, preserveTuples))
})
// patching http adapter config to be easier to handle
const adapter = config[':pleroma'][':http'][':adapter']
if (Array.isArray(adapter)) {
config[':pleroma'][':http'][':adapter'] = {
[':ssl_options']: {
[':versions']: [],
},
}
}
this.updateAdminSettings({ config, modifiedPaths })
this.resetAdminDraft()
},
setInstanceAdminDescriptions({ backendDescriptions }) {
const convert = (
{ children, description, label, key = '<ROOT>', group, suggestions },
path,
acc,
) => {
const newPath = group ? [group, key] : [key]
const obj = { description, label, suggestions }
if (Array.isArray(children)) {
children.forEach((c) => {
convert(c, newPath, obj)
})
}
set(acc, newPath, obj)
}
const descriptions = {}
backendDescriptions.forEach((d) => convert(d, '', descriptions))
this.updateAdminDescriptions({ descriptions })
},
// This action takes draft state, diffs it with live config state and then pushes
// only differences between the two. Difference detection only work up to subkey (third) level.
pushAdminDraft() {
// TODO cleanup paths in modifiedPaths
const convert = (value) => {
if (typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(convert)
} else {
return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] }))
}
}
// Getting all group-keys used in config
const allGroupKeys = flatten(
Object.entries(this.config).map(([group, lv1data]) =>
Object.keys(lv1data).map((key) => ({ group, key })),
),
)
// Only using group-keys where there are changes detected
const changedGroupKeys = allGroupKeys.filter(({ group, key }) => {
return !isEqual(this.config[group][key], this.draft[group][key])
})
// Here we take all changed group-keys and get all changed subkeys
const changed = changedGroupKeys.map(({ group, key }) => {
const config = this.config[group][key]
const draft = this.draft[group][key]
// We convert group-key value into entries arrays
const eConfig = Object.entries(config)
const eDraft = Object.entries(draft)
// Then those entries array we diff so only changed subkey entries remain
// We use the diffed array to reconstruct the object and then shove it into convert()
return {
group,
key,
value: convert(
Object.fromEntries(differenceWith(eDraft, eConfig, isEqual)),
),
}
})
window.vuex.state.api.backendInteractor
.pushInstanceDBConfig({
payload: {
configs: changed,
},
})
.then(() =>
window.vuex.state.api.backendInteractor.fetchInstanceDBConfig(),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({ backendDbConfig }),
)
},
pushAdminSetting({ path, value }) {
const [group, key, ...rest] = Array.isArray(path)
? path
: path.split(/\./g)
const clone = {} // not actually cloning the entire thing to avoid excessive writes
set(clone, rest, value)
// TODO cleanup paths in modifiedPaths
const convert = (value) => {
if (typeof value !== 'object') {
return value
} else if (Array.isArray(value)) {
return value.map(convert)
} else {
return Object.entries(value).map(([k, v]) => ({ tuple: [k, v] }))
}
}
window.vuex.state.api.backendInteractor
.pushInstanceDBConfig({
payload: {
configs: [
{
group,
key,
value: convert(clone),
},
],
},
})
.then(() =>
window.vuex.state.api.backendInteractor.fetchInstanceDBConfig(),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({ backendDbConfig }),
)
},
resetAdminSetting({ path }) {
const [group, key, subkey] = Array.isArray(path)
? path
: path.split(/\./g)
this.modifiedPaths.delete(path)
return window.vuex.state.api.backendInteractor
.pushInstanceDBConfig({
payload: {
configs: [
{
group,
key,
delete: true,
subkeys: [subkey],
},
],
},
})
.then(() =>
window.vuex.state.api.backendInteractor.fetchInstanceDBConfig(),
)
.then((backendDbConfig) =>
this.setInstanceAdminSettings({ backendDbConfig }),
)
},
// Frontends Stuff
loadFrontendsStuff() {
this.backendInteractor
.fetchAvailableFrontends()
.then((frontends) => this.setAvailableFrontends({ frontends }))
},
setAvailableFrontends({ frontends }) {
this.frontends = frontends.map((f) => {
f.installedRefs = f.installed_refs
if (f.name === 'pleroma-fe') {
f.refs = ['master', 'develop']
} else {
f.refs = [f.ref]
}
return f
})
},
// Statuses stuff
async fetchStatuses(opts) {
const { total, activities } =
await this.backendInteractor.adminListStatuses({
opts,
})
return {
items: activities.map(parseStatus),
count: total,
}
},
async changeStatusScope(opts) {
const raw = await this.backendInteractor.adminChangeStatusScope({
opts,
})
const status = parseStatus(raw)
await window.vuex.dispatch('addNewStatuses', {
statuses: [status],
userId: false,
})
},
// Users stuff
async fetchUsers(opts) {
const { users, count } = await this.backendInteractor.adminListUsers({
opts,
})
return {
items: await Promise.all(
users.map(
async (userAdminData) =>
await window.vuex.dispatch('updateUserAdminData', {
userAdminData,
}),
),
),
count,
}
},
async getUserData({ user }) {
const api = this.backendInteractor.adminGetUserData
const { screen_name } = user
const result = await api({ screen_name })
window.vuex.commit('updateUserAdminData', { user: result })
},
async deleteUsers({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = this.backendInteractor.adminDeleteAccounts
const resultUserIds = await api({ screen_names })
resultUserIds.forEach((userId) => {
window.vuex.dispatch(
'markStatusesAsDeleted',
(status) => userId === status.user.id,
)
// TODO when migrated to pinia, also remove user
})
return resultUserIds
},
resendConfirmationEmail({ users }) {
const screen_names = users.map((u) => u.screen_name)
return this.backendInteractor.adminResendConfirmationEmail({
screen_names,
})
},
requirePasswordChange({ users }) {
const screen_names = users.map((u) => u.screen_name)
return this.backendInteractor.adminRequirePasswordChange({
screen_names,
})
},
// Singular only!
disableMFA({ user }) {
const { screen_name } = user
return this.backendInteractor.adminDisableMFA({ screen_name })
},
async setUsersTags({ users, tags, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = this.backendInteractor.adminSetUsersTags
await api({
screen_names,
tags,
value,
})
users.forEach((user) => {
this.getUserData({ user })
})
},
async setUsersRight({ users, right, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = this.backendInteractor.adminSetUsersRight
await api({
screen_names,
right,
value,
})
users.forEach((user) => {
window.vuex.commit('updateRight', { user, right, value })
})
},
async setUsersActivationStatus({ users, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = this.backendInteractor.adminSetUsersActivationStatus
const resultUsers = await api({
screen_names,
value,
})
resultUsers.forEach((user) => {
window.vuex.commit('updateUserAdminData', { user })
})
},
async setUsersSuggestionStatus({ users, value }) {
const screen_names = users.map((u) => u.screen_name)
const api = this.backendInteractor.adminSetUsersSuggestionStatus
const resultUsers = await api({
screen_names,
value,
})
resultUsers.forEach((user) => {
window.vuex.commit('updateUserAdminData', { user })
})
},
async setUsersConfirmationStatus({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = this.backendInteractor.adminSetUsersConfirmationStatus
await api({ screen_names })
users.forEach((user) => {
this.getUserData({ user })
})
},
async setUsersApprovalStatus({ users }) {
const screen_names = users.map((u) => u.screen_name)
const api = this.backendInteractor.adminSetUsersApprovalStatus
const resultUsers = await api({
screen_names,
})
resultUsers.forEach((user) => {
window.vuex.commit('updateUserAdminData', { user })
})
},
},
})

View file

@ -29,7 +29,7 @@ export const useAnnouncementsStore = defineStore('announcements', {
const currentUser = window.vuex.state.users.currentUser
const isAdmin =
currentUser &&
currentUser.privileges.includes('announcements_manage_announcements')
currentUser.privileges.has('announcements_manage_announcements')
const getAnnouncements = async () => {
if (!isAdmin) {

View file

@ -34,14 +34,9 @@ const logout = async (page) => {
name: 'Logout',
exact: true,
})
if (await confirmLogout.isVisible()) {
await Promise.all([
page.waitForURL(/\/main\/(public|all)/),
confirmLogout.click(),
])
} else {
await page.waitForURL(/\/main\/(public|all)/)
}
await expect(confirmLogout).toBeVisible()
await confirmLogout.click()
await page.waitForURL(/\/main\/(public|all)/)
await expect(page.locator('#sidebar form.login-form')).toBeVisible()
}

View file

@ -1,3 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { config } from '@vue/test-utils'
import { createMemoryHistory, createRouter } from 'vue-router'
import VueVirtualScroller from 'vue-virtual-scroller'
@ -5,9 +6,7 @@ import VueVirtualScroller from 'vue-virtual-scroller'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import makeMockStore from './mock_store'
import { createTestingPinia } from '@pinia/testing'
import routes from 'src/boot/routes'

View file

@ -37,10 +37,9 @@ describe('routes', () => {
const matchedComponents = router.currentRoute.value.matched
expect(
matchedComponents[0].components.default.name,
).to.eql('AsyncComponentWrapper')
expect(matchedComponents[0].components.default.name).to.eql(
'AsyncComponentWrapper',
)
})
it("user's profile at /users", async () => {
@ -48,9 +47,9 @@ describe('routes', () => {
const matchedComponents = router.currentRoute.value.matched
expect(
matchedComponents[0].components.default.name,
).to.eql('AsyncComponentWrapper')
expect(matchedComponents[0].components.default.name).to.eql(
'AsyncComponentWrapper',
)
})
it('list view', async () => {
@ -58,9 +57,9 @@ describe('routes', () => {
const matchedComponents = router.currentRoute.value.matched
expect(
matchedComponents[0].components.default.name,
).to.eql('AsyncComponentWrapper')
expect(matchedComponents[0].components.default.name).to.eql(
'AsyncComponentWrapper',
)
})
it('list timeline', async () => {
@ -68,9 +67,9 @@ describe('routes', () => {
const matchedComponents = router.currentRoute.value.matched
expect(
matchedComponents[0].components.default.name,
).to.eql('AsyncComponentWrapper')
expect(matchedComponents[0].components.default.name).to.eql(
'AsyncComponentWrapper',
)
})
it('list edit', async () => {
@ -78,8 +77,8 @@ describe('routes', () => {
const matchedComponents = router.currentRoute.value.matched
expect(
matchedComponents[0].components.default.name,
).to.eql('AsyncComponentWrapper')
expect(matchedComponents[0].components.default.name).to.eql(
'AsyncComponentWrapper',
)
})
})

View file

@ -65,7 +65,7 @@ describe('Draft saving', () => {
},
)
it.only('should auto-save if it is enabled', async function () {
it('should auto-save if it is enabled', async function () {
vi.useFakeTimers()
const wrapper = mount(PostStatusForm, mountOpts())
const store = useMergedConfigStore()