618 lines
21 KiB
Vue
618 lines
21 KiB
Vue
<script setup lang="ts">
|
|
import type { InstancePolicy } from '~/types'
|
|
|
|
import { computed, ref, reactive, nextTick, watch } from 'vue'
|
|
import { useCurrentElement } from '@vueuse/core'
|
|
import { humanSize } from '~/utils/filters'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useStore } from '~/store'
|
|
|
|
import axios from 'axios'
|
|
|
|
import Layout from '~/components/ui/Layout.vue'
|
|
import Loader from '~/components/ui/Loader.vue'
|
|
import Button from '~/components/ui/Button.vue'
|
|
import Spacer from '~/components/ui/Spacer.vue'
|
|
import Input from '~/components/ui/Input.vue'
|
|
|
|
import InstancePolicyForm from '~/components/manage/moderation/InstancePolicyForm.vue'
|
|
import InstancePolicyCard from '~/components/manage/moderation/InstancePolicyCard.vue'
|
|
|
|
import useErrorHandler from '~/composables/useErrorHandler'
|
|
import useLogger from '~/composables/useLogger'
|
|
|
|
interface Props {
|
|
id: number
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const store = useStore()
|
|
const { t } = useI18n()
|
|
|
|
const logger = useLogger()
|
|
|
|
const labels = computed(() => ({
|
|
statsWarning: t('views.admin.moderation.AccountsDetail.warning.stats'),
|
|
uploadQuota: t('views.admin.moderation.AccountsDetail.tooltip.uploadQuota')
|
|
}))
|
|
|
|
const allPermissions = computed(() => [
|
|
{ code: 'library', label: t('views.admin.moderation.AccountsDetail.option.permission.library') },
|
|
{ code: 'moderation', label: t('views.admin.moderation.AccountsDetail.option.permission.moderation') },
|
|
{ code: 'settings', label: t('views.admin.moderation.AccountsDetail.option.permission.settings') }
|
|
])
|
|
|
|
const isLoadingPolicy = ref(false)
|
|
const policy = ref()
|
|
const fetchPolicy = async (id: number) => {
|
|
isLoadingPolicy.value = true
|
|
|
|
try {
|
|
const response = await axios.get(`manage/moderation/instance-policies/${id}/`)
|
|
policy.value = response.data
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
isLoadingPolicy.value = false
|
|
}
|
|
|
|
const permissions = ref([] as string[])
|
|
const isLoading = ref(false)
|
|
const object = ref()
|
|
const fetchData = async () => {
|
|
isLoading.value = true
|
|
|
|
try {
|
|
const response = await axios.get(`manage/accounts/${props.id}/`)
|
|
object.value = response.data
|
|
|
|
if (response.data.instance_policy) {
|
|
fetchPolicy(response.data.instance_policy)
|
|
}
|
|
|
|
if (response.data.user) {
|
|
for (const { code } of allPermissions.value) {
|
|
if (response.data.user.permissions[code]) {
|
|
permissions.value.push(code)
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
isLoading.value = false
|
|
}
|
|
|
|
const isLoadingStats = ref(false)
|
|
const stats = ref()
|
|
const fetchStats = async () => {
|
|
isLoadingStats.value = true
|
|
|
|
try {
|
|
const response = await axios.get(`manage/accounts/${props.id}/stats/`)
|
|
stats.value = response.data
|
|
} catch (error) {
|
|
useErrorHandler(error as Error)
|
|
}
|
|
|
|
isLoadingStats.value = false
|
|
}
|
|
|
|
fetchStats()
|
|
fetchData()
|
|
|
|
const el = useCurrentElement()
|
|
watch(object, async () => {
|
|
await nextTick()
|
|
// @ts-expect-error JQuery owhere to be found...
|
|
$(el.value).find('select.dropdown').dropdown()
|
|
})
|
|
|
|
const getQuery = (field: string, value: string) => `${field}:"${value}"`
|
|
|
|
const updating = reactive(new Set<string>())
|
|
const updateUser = async (attr: string, toNull = false) => {
|
|
let newValue = object.value.user[attr]
|
|
if (toNull && !newValue) {
|
|
newValue = null
|
|
}
|
|
|
|
updating.add(attr)
|
|
|
|
const params = {
|
|
[attr]: newValue
|
|
}
|
|
|
|
if (attr === 'permissions') {
|
|
params.permissions = allPermissions.value.reduce((acc, { code }) => {
|
|
acc[code] = permissions.value.includes(code)
|
|
return acc
|
|
}, {} as Record<string, boolean>)
|
|
}
|
|
|
|
try {
|
|
await axios.patch(`manage/users/users/${object.value.user.id}/`, params)
|
|
logger.info(`${attr} was updated successfully to ${newValue}`)
|
|
} catch (error) {
|
|
logger.error(`Error while setting ${attr} to ${newValue}`, error)
|
|
// TODO: Use error handler
|
|
}
|
|
|
|
updating.delete(attr)
|
|
}
|
|
|
|
const showPolicyForm = ref(false)
|
|
const updatePolicy = (newPolicy: InstancePolicy) => {
|
|
policy.value = newPolicy
|
|
showPolicyForm.value = false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Layout
|
|
main
|
|
stack
|
|
class="page-admin-account-detail"
|
|
>
|
|
<Loader
|
|
v-if="isLoading"
|
|
/>
|
|
<template v-if="object">
|
|
<section
|
|
v-title="object.full_username"
|
|
>
|
|
<Layout flex>
|
|
<h2 class="ui header">
|
|
<i class="bi-person-circle" />
|
|
{{ object.full_username }}
|
|
<div class="sub header">
|
|
<template v-if="object.user">
|
|
<span class="ui tiny accent label">
|
|
<i class="bi-house-fill" />
|
|
{{ t('views.admin.moderation.AccountsDetail.header.localAccount') }}
|
|
</span>
|
|
|
|
</template>
|
|
<a
|
|
:href="object.url || object.fid"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
{{ t('views.admin.moderation.AccountsDetail.link.openProfile') }}
|
|
<i class="bi bi-box-arrow-up-right" />
|
|
</a>
|
|
</div>
|
|
</h2>
|
|
<Spacer grow />
|
|
<Layout stack>
|
|
<a
|
|
v-if="object.user && store.state.auth.profile && store.state.auth.profile.is_superuser"
|
|
class="ui labeled icon button"
|
|
:href="store.getters['instance/absoluteUrl'](`/api/admin/users/user/${object.user.id}`)"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<i class="bi bi-wrench" />
|
|
{{ t('views.admin.moderation.AccountsDetail.link.django') }}
|
|
</a>
|
|
<a
|
|
v-else-if="store.state.auth.profile && store.state.auth.profile.is_superuser"
|
|
:href="store.getters['instance/absoluteUrl'](`/api/admin/federation/actor/${object.id}`)"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<i class="bi bi-wrench" />
|
|
{{ t('views.admin.moderation.AccountsDetail.link.django') }}
|
|
</a>
|
|
<a
|
|
:href="object.url || object.fid"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<i class="bi bi-box-arrow-up-right" />
|
|
{{ t('views.admin.moderation.AccountsDetail.link.remoteProfile') }}
|
|
</a>
|
|
</Layout>
|
|
<div class="ui column">
|
|
<div
|
|
v-if="!object.user"
|
|
class="ui compact clearing placeholder segment component-placeholder"
|
|
>
|
|
<template v-if="isLoadingPolicy">
|
|
<div class="paragraph">
|
|
<div class="line" />
|
|
<div class="line" />
|
|
<div class="line" />
|
|
<div class="line" />
|
|
<div class="line" />
|
|
</div>
|
|
</template>
|
|
<template v-else-if="!policy && !showPolicyForm">
|
|
<header class="ui header">
|
|
<h3>
|
|
<i class="bi bi-shield-fill" />
|
|
{{ t('views.admin.moderation.AccountsDetail.header.noPolicy') }}
|
|
</h3>
|
|
</header>
|
|
<p>
|
|
{{ t('views.admin.moderation.AccountsDetail.description.policy') }}
|
|
</p>
|
|
<Button
|
|
primary
|
|
@click="showPolicyForm = true"
|
|
>
|
|
{{ t('views.admin.moderation.AccountsDetail.button.addPolicy') }}
|
|
</Button>
|
|
</template>
|
|
<instance-policy-card
|
|
v-else-if="policy && !showPolicyForm"
|
|
:object="policy"
|
|
@update="showPolicyForm = true"
|
|
>
|
|
<header class="ui header">
|
|
<h3>
|
|
{{ t('views.admin.moderation.AccountsDetail.header.activePolicy') }}
|
|
</h3>
|
|
</header>
|
|
</instance-policy-card>
|
|
<instance-policy-form
|
|
v-else-if="showPolicyForm"
|
|
:object="policy"
|
|
type="actor"
|
|
:target="object.full_username"
|
|
@cancel="showPolicyForm = false"
|
|
@save="updatePolicy"
|
|
@delete="policy = null; showPolicyForm = false"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
</section>
|
|
<Layout flex>
|
|
<div class="column">
|
|
<section>
|
|
<h3 class="ui header">
|
|
<i class="bi bi-info-circle-fill" />
|
|
{{ t('views.admin.moderation.AccountsDetail.header.accountData') }}
|
|
</h3>
|
|
<table class="ui very basic table">
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.username') }}
|
|
</td>
|
|
<td>
|
|
{{ object.preferred_username }}
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!object.user">
|
|
<td>
|
|
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
|
|
{{ t('views.admin.moderation.AccountsDetail.link.domain') }}
|
|
</router-link>
|
|
</td>
|
|
<td>
|
|
{{ object.domain }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.displayName') }}
|
|
</td>
|
|
<td>
|
|
{{ object.name }}
|
|
</td>
|
|
</tr>
|
|
<tr v-if="object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.email') }}
|
|
</td>
|
|
<td>
|
|
{{ object.user.email }}
|
|
</td>
|
|
</tr>
|
|
<tr v-if="object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.loginStatus.label') }}
|
|
</td>
|
|
<td>
|
|
<div
|
|
v-if="object.user.username != store.state.auth.profile?.username"
|
|
class="ui toggle checkbox"
|
|
>
|
|
<Input
|
|
id="is-active"
|
|
v-model="object.user.is_active"
|
|
type="checkbox"
|
|
@change="updateUser('is_active')"
|
|
/>
|
|
<label for="is-active">
|
|
<span
|
|
v-if="object.user.is_active"
|
|
>{{ t('views.admin.moderation.AccountsDetail.table.accountData.loginStatus.enabled') }}</span>
|
|
<span
|
|
v-else
|
|
>{{ t('views.admin.moderation.AccountsDetail.table.accountData.loginStatus.disabled') }}</span>
|
|
</label>
|
|
</div>
|
|
<span
|
|
v-else-if="object.user.is_active"
|
|
>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.loginStatus.enabled') }}
|
|
</span>
|
|
<span
|
|
v-else
|
|
>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.loginStatus.disabled') }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.permissions') }}
|
|
</td>
|
|
<td>
|
|
<select
|
|
v-model="permissions"
|
|
multiple
|
|
class="ui search selection dropdown"
|
|
@change="updateUser('permissions')"
|
|
>
|
|
<option
|
|
v-for="(p, key) in allPermissions"
|
|
:key="key"
|
|
:value="p.code"
|
|
>
|
|
{{ p.label }}
|
|
</option>
|
|
</select>
|
|
<action-feedback :is-loading="updating.has('permissions')" />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.userType') }}
|
|
</td>
|
|
<td>
|
|
{{ object.type }}
|
|
</td>
|
|
</tr>
|
|
<tr v-if="!object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.lastChecked') }}
|
|
</td>
|
|
<td>
|
|
<human-date
|
|
v-if="object.last_fetch_date"
|
|
:date="object.last_fetch_date"
|
|
/>
|
|
<span
|
|
v-else
|
|
>
|
|
{{ t('views.admin.moderation.AccountsDetail.notApplicable') }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.signupDate') }}
|
|
</td>
|
|
<td>
|
|
<human-date :date="object.user.date_joined" />
|
|
</td>
|
|
</tr>
|
|
<tr v-if="object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.accountData.lastActivity') }}
|
|
</td>
|
|
<td>
|
|
<human-date :date="object.user.last_activity" />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</div>
|
|
<div class="column">
|
|
<section>
|
|
<h3 class="ui header">
|
|
<i class="bi bi-rss-fill" />
|
|
{{ t('views.admin.moderation.AccountsDetail.header.activity') }}
|
|
<span :data-tooltip="labels.statsWarning"><i class=" bi bi-question-circle-fill" /></span>
|
|
</h3>
|
|
<div
|
|
v-if="isLoadingStats"
|
|
class="ui placeholder"
|
|
>
|
|
<div class="full line" />
|
|
<div class="short line" />
|
|
<div class="medium line" />
|
|
<div class="long line" />
|
|
</div>
|
|
<table
|
|
v-else
|
|
class="ui very basic table"
|
|
>
|
|
<tbody>
|
|
<tr v-if="!object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.activity.firstSeen') }}
|
|
</td>
|
|
<td>
|
|
<human-date :date="object.creation_date" />
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.activity.emittedMessages') }}
|
|
</td>
|
|
<td>
|
|
{{ stats.outbox_activities }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.activity.receivedFollows') }}
|
|
</td>
|
|
<td>
|
|
{{ stats.received_library_follows }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.activity.emittedFollows') }}
|
|
</td>
|
|
<td>
|
|
{{ stats.emitted_library_follows }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<router-link :to="{name: 'manage.moderation.reports.list', query: {q: getQuery('target', `account:${object.full_username}`) }}">
|
|
{{ t('views.admin.moderation.AccountsDetail.link.linkedReports') }}
|
|
</router-link>
|
|
</td>
|
|
<td>
|
|
{{ stats.reports }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<router-link :to="{name: 'manage.moderation.requests.list', query: {q: getQuery('submitter', `${object.full_username}`) }}">
|
|
{{ t('views.admin.moderation.AccountsDetail.link.requests') }}
|
|
</router-link>
|
|
</td>
|
|
<td>
|
|
{{ stats.requests }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</div>
|
|
<div class="column">
|
|
<section>
|
|
<h3 class="ui header">
|
|
<i class="bi bi-music-note-beamed" />
|
|
{{ t('views.admin.moderation.AccountsDetail.header.audioContent') }}
|
|
<span :data-tooltip="labels.statsWarning"><i class="question circle icon" /></span>
|
|
</h3>
|
|
<div
|
|
v-if="isLoadingStats"
|
|
class="ui placeholder"
|
|
>
|
|
<div class="full line" />
|
|
<div class="short line" />
|
|
<div class="medium line" />
|
|
<div class="long line" />
|
|
</div>
|
|
<table
|
|
v-else
|
|
class="ui very basic table"
|
|
>
|
|
<tbody>
|
|
<tr v-if="!object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.audioContent.cachedSize') }}
|
|
</td>
|
|
<td>
|
|
{{ humanSize(stats.media_downloaded_size) }}
|
|
</td>
|
|
</tr>
|
|
<tr v-if="object.user">
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.audioContent.uploadQuota') }}
|
|
<span :data-tooltip="labels.uploadQuota"><i class="question circle icon" /></span>
|
|
</td>
|
|
<td>
|
|
<Input
|
|
v-model.number="object.user.upload_quota"
|
|
step="100"
|
|
name="quota"
|
|
type="number"
|
|
@change="updateUser('upload_quota', true)"
|
|
/>
|
|
<div class="ui basic label">
|
|
{{ t('views.admin.moderation.AccountsDetail.table.audioContent.megabyte') }}
|
|
</div>
|
|
<action-feedback
|
|
class="ui basic label"
|
|
size="tiny"
|
|
:is-loading="updating.has('upload_quota')"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.table.audioContent.totalSize') }}
|
|
</td>
|
|
<td>
|
|
{{ humanSize(stats.media_total_size) }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<router-link :to="{name: 'manage.channels', query: {q: getQuery('account', object.full_username) }}">
|
|
{{ t('views.admin.moderation.AccountsDetail.link.channels') }}
|
|
</router-link>
|
|
</td>
|
|
<td>
|
|
{{ stats.channels }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<router-link :to="{name: 'manage.library.libraries', query: {q: getQuery('account', object.full_username) }}">
|
|
{{ t('views.admin.moderation.AccountsDetail.link.libraries') }}
|
|
</router-link>
|
|
</td>
|
|
<td>
|
|
{{ stats.libraries }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<router-link :to="{name: 'manage.library.uploads', query: {q: getQuery('account', object.full_username) }}">
|
|
{{ t('views.admin.moderation.AccountsDetail.link.uploads') }}
|
|
</router-link>
|
|
</td>
|
|
<td>
|
|
{{ stats.uploads }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.link.artists') }}
|
|
</td>
|
|
<td>
|
|
{{ stats.artists }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.link.albums') }}
|
|
</td>
|
|
<td>
|
|
{{ stats.albums }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
{{ t('views.admin.moderation.AccountsDetail.link.tracks') }}
|
|
</td>
|
|
<td>
|
|
{{ stats.tracks }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</section>
|
|
</div>
|
|
</Layout>
|
|
</template>
|
|
</Layout>
|
|
</template>
|