Skip to content
Snippets Groups Projects
Verified Commit e186c6bb authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Admin UI to list and manage remote and local accounts

parent b1194e50
Branches
Tags
No related merge requests found
Showing
with 795 additions and 281 deletions
......@@ -61,16 +61,6 @@ class ActorQuerySet(models.QuerySet):
return qs
def with_outbox_activities_count(self):
return self.annotate(
outbox_activities_count=models.Count("outbox_activities", distinct=True)
)
def with_followers_count(self):
return self.annotate(
followers_count=models.Count("received_follows", distinct=True)
)
def with_uploads_count(self):
return self.annotate(
uploads_count=models.Count("libraries__uploads", distinct=True)
......@@ -86,7 +76,9 @@ class DomainQuerySet(models.QuerySet):
def with_outbox_activities_count(self):
return self.annotate(
outbox_activities_count=models.Count("actors__outbox_activities", distinct=True)
outbox_activities_count=models.Count(
"actors__outbox_activities", distinct=True
)
)
......@@ -186,10 +178,10 @@ class Actor(models.Model):
@property
def full_username(self):
return "{}@{}".format(self.preferred_username, self.domain)
return "{}@{}".format(self.preferred_username, self.domain_id)
def __str__(self):
return "{}@{}".format(self.preferred_username, self.domain)
return "{}@{}".format(self.preferred_username, self.domain_id)
@property
def is_local(self):
......@@ -217,6 +209,35 @@ class Actor(models.Model):
data["total"] = sum(data.values())
return data
def get_stats(self):
from funkwhale_api.music import models as music_models
data = Actor.objects.filter(pk=self.pk).aggregate(
outbox_activities=models.Count("outbox_activities", distinct=True),
libraries=models.Count("libraries", distinct=True),
received_library_follows=models.Count(
"libraries__received_follows", distinct=True
),
emitted_library_follows=models.Count("library_follows", distinct=True),
)
data["artists"] = music_models.Artist.objects.filter(
from_activity__actor=self.pk
).count()
data["albums"] = music_models.Album.objects.filter(
from_activity__actor=self.pk
).count()
data["tracks"] = music_models.Track.objects.filter(
from_activity__actor=self.pk
).count()
uploads = music_models.Upload.objects.filter(library__actor=self.pk)
data["uploads"] = uploads.count()
data["media_total_size"] = uploads.aggregate(v=models.Sum("size"))["v"] or 0
data["media_downloaded_size"] = (
uploads.with_file().aggregate(v=models.Sum("size"))["v"] or 0
)
return data
class InboxItem(models.Model):
"""
......
......@@ -37,10 +37,15 @@ class ManageActorFilterSet(filters.FilterSet):
search_fields={
"name": {"to": "name"},
"username": {"to": "preferred_username"},
"email": {"to": "user__email"},
"bio": {"to": "summary"},
"type": {"to": "type"},
},
filter_fields={"domain": {"to": "domain_id__iexact"}},
filter_fields={
"domain": {"to": "domain__name__iexact"},
"username": {"to": "preferred_username__iexact"},
"email": {"to": "user__email__iexact"},
},
)
)
local = filters.BooleanFilter(name="_", method="filter_local")
......
......@@ -116,6 +116,7 @@ class ManageUserSerializer(serializers.ModelSerializer):
"permissions",
"privacy_level",
"upload_quota",
"full_username",
)
read_only_fields = [
"id",
......@@ -194,9 +195,8 @@ class ManageDomainSerializer(serializers.ModelSerializer):
class ManageActorSerializer(serializers.ModelSerializer):
outbox_activities_count = serializers.SerializerMethodField()
uploads_count = serializers.SerializerMethodField()
followers_count = serializers.SerializerMethodField()
user = ManageUserSerializer()
class Meta:
model = federation_models.Actor
......@@ -205,6 +205,7 @@ class ManageActorSerializer(serializers.ModelSerializer):
"url",
"fid",
"preferred_username",
"full_username",
"domain",
"name",
"summary",
......@@ -215,16 +216,9 @@ class ManageActorSerializer(serializers.ModelSerializer):
"outbox_url",
"shared_inbox_url",
"manually_approves_followers",
"outbox_activities_count",
"uploads_count",
"followers_count",
"user",
]
def get_uploads_count(self, o):
return getattr(o, "uploads_count", 0)
def get_followers_count(self, o):
return getattr(o, "followers_count", 0)
def get_outbox_activities_count(self, o):
return getattr(o, "outbox_activities_count", 0)
......@@ -138,10 +138,9 @@ class ManageActorViewSet(
lookup_value_regex = r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"
queryset = (
federation_models.Actor.objects.all()
.with_outbox_activities_count()
.with_followers_count()
.with_uploads_count()
.order_by("-creation_date")
.select_related("user")
)
serializer_class = serializers.ManageActorSerializer
filter_class = filters.ManageActorFilterSet
......@@ -155,7 +154,6 @@ class ManageActorViewSet(
"creation_date",
"last_fetch_date",
"uploads_count",
"followers_count",
"outbox_activities_count",
]
......
......@@ -204,6 +204,9 @@ class User(AbstractUser):
return ["user.{}.{}".format(self.pk, g) for g in groups]
def full_username(self):
return "{}@{}".format(self.username, settings.FEDERATION_HOSTNAME)
def generate_code(length=10):
return "".join(
......
......@@ -97,3 +97,22 @@ def test_domain_stats(factories):
domain = factories["federation.Domain"]()
assert domain.get_stats() == expected
def test_actor_stats(factories):
expected = {
"libraries": 0,
"tracks": 0,
"albums": 0,
"uploads": 0,
"artists": 0,
"outbox_activities": 0,
"received_library_follows": 0,
"emitted_library_follows": 0,
"media_total_size": 0,
"media_downloaded_size": 0,
}
actor = factories["federation.Actor"]()
assert actor.get_stats() == expected
......@@ -55,16 +55,12 @@ def test_manage_domain_serializer(factories, now):
def test_manage_actor_serializer(factories, now):
actor = factories["federation.Actor"]()
setattr(actor, "outbox_activities_count", 23)
setattr(actor, "followers_count", 42)
setattr(actor, "uploads_count", 66)
expected = {
"id": actor.id,
"name": actor.name,
"creation_date": actor.creation_date.isoformat().split("+")[0] + "Z",
"last_fetch_date": actor.last_fetch_date.isoformat().split("+")[0] + "Z",
"outbox_activities_count": 23,
"followers_count": 42,
"uploads_count": 66,
"fid": actor.fid,
"url": actor.url,
......@@ -76,6 +72,8 @@ def test_manage_actor_serializer(factories, now):
"summary": actor.summary,
"preferred_username": actor.preferred_username,
"manually_approves_followers": actor.manually_approves_followers,
"full_username": actor.full_username,
"user": None,
}
s = serializers.ManageActorSerializer(actor)
......
......@@ -247,7 +247,6 @@ export default {
self.isLoading = false;
}).catch(error => {
if (error.response) {
console.log(error.response)
if (error.response.status === 404) {
self.error = 'server_not_found'
}
......@@ -274,7 +273,6 @@ export default {
self.isLoading = false;
}).catch(error => {
if (error.response) {
console.log(error.response)
if (error.response.status === 404) {
self.error = 'server_not_found'
}
......
<template>
<div>
<div class="ui inline form">
<div class="fields">
<div class="ui six wide field">
<label><translate>Search</translate></label>
<form @submit.prevent="search.query = $refs.search.value">
<input ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
</form>
</div>
<div class="field">
<label><translate>Ordering</translate></label>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ sharedLabels.filters[option[1]] }}
</option>
</select>
</div>
<div class="field">
<label><translate>Ordering direction</translate></label>
<select class="ui dropdown" v-model="orderingDirection">
<option value="+"><translate>Ascending</translate></option>
<option value="-"><translate>Descending</translate></option>
</select>
</div>
</div>
</div>
<div class="dimmable">
<div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<action-table
v-if="result"
@action-launched="fetchData"
:objects-data="result"
:actions="actions"
:filters="actionFilters">
<template slot="header-cells">
<th><translate>Name</translate></th>
<th><translate>Domain</translate></th>
<th><translate>Uploads</translate></th>
<th><translate>First seen</translate></th>
<th><translate>Last seen</translate></th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.full_username }}">{{ scope.obj.preferred_username }}</router-link>
</td>
<td>
<template v-if="!scope.obj.user">
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: scope.obj.domain }}">
<i class="wrench icon"></i>
</router-link>
<span role="button" class="discrete link" @click="addSearchToken('domain', scope.obj.domain)" :title="scope.obj.domain">{{ scope.obj.domain }}</span>
</template>
<span role="button" v-else class="ui tiny teal icon link label" @click="addSearchToken('domain', scope.obj.domain)">
<i class="home icon"></i>
<translate>Local account</translate>
</span>
</td>
<td>
{{ scope.obj.uploads_count }}
</td>
<td>
<human-date :date="scope.obj.creation_date"></human-date>
</td>
<td>
<human-date v-if="scope.obj.last_fetch_date" :date="scope.obj.last_fetch_date"></human-date>
</td>
</template>
</action-table>
</div>
<div>
<pagination
v-if="result && result.count > paginateBy"
@page-changed="selectPage"
:compact="true"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
<span v-if="result && result.results.length > 0">
<translate
:translate-params="{start: ((page-1) * paginateBy) + 1, end: ((page-1) * paginateBy) + result.results.length, total: result.count}">
Showing results %{ start }-%{ end } on %{ total }
</translate>
</span>
</div>
</div>
</template>
<script>
import axios from 'axios'
import _ from '@/lodash'
import time from '@/utils/time'
import {normalizeQuery, parseTokens} from '@/search'
import Pagination from '@/components/Pagination'
import ActionTable from '@/components/common/ActionTable'
import OrderingMixin from '@/components/mixins/Ordering'
import TranslationsMixin from '@/components/mixins/Translations'
import SmartSearchMixin from '@/components/mixins/SmartSearch'
export default {
mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
props: {
filters: {type: Object, required: false},
},
components: {
Pagination,
ActionTable
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
time,
isLoading: false,
result: null,
page: 1,
paginateBy: 50,
search: {
query: this.defaultQuery,
tokens: parseTokens(normalizeQuery(this.defaultQuery))
},
orderingDirection: defaultOrdering.direction || '+',
ordering: defaultOrdering.field,
orderingOptions: [
['creation_date', 'first_seen'],
["last_fetch_date", "last_seen"],
["preferred_username", "username"],
["domain", "domain"],
["uploads_count", "uploads"],
]
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
let params = _.merge({
'page': this.page,
'page_size': this.paginateBy,
'q': this.search.query,
'ordering': this.getOrderingAsString()
}, this.filters)
let self = this
self.isLoading = true
self.checked = []
axios.get('/manage/accounts/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
selectPage: function (page) {
this.page = page
}
},
computed: {
labels () {
return {
searchPlaceholder: this.$gettext('Search by domain, username, bio...')
}
},
actionFilters () {
var currentFilters = {
q: this.search.query
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
} else {
return currentFilters
}
},
actions () {
return [
// {
// name: 'delete',
// label: this.$gettext('Delete'),
// isDangerous: true
// }
]
}
},
watch: {
search (newValue) {
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
ordering () {
this.fetchData()
},
orderingDirection () {
this.fetchData()
}
}
}
</script>
......@@ -45,7 +45,7 @@
</template>
<template slot="row-cells" slot-scope="scope">
<td>
<router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
<router-link :to="{name: 'manage.moderation.accounts.detail', params: {id: scope.obj.full_username }}">{{ scope.obj.username }}</router-link>
</td>
<td>
<span>{{ scope.obj.email }}</span>
......@@ -168,17 +168,13 @@ export default {
},
permissions () {
return [
{
'code': 'upload',
'label': this.$gettext('Upload')
},
{
'code': 'library',
'label': this.$gettext('Library')
},
{
'code': 'federation',
'label': this.$gettext('Federation')
'code': 'moderation',
'label': this.$gettext('Moderation')
},
{
'code': 'settings',
......
......@@ -4,7 +4,8 @@ import {normalizeQuery, parseTokens, compileTokens} from '@/search'
export default {
props: {
defaultQuery: {type: String, default: '', required: false},
defaultQuery: {type: String, required: false},
updateUrl: {type: Boolean, required: false, default: false},
},
methods: {
getTokenValue (key, fallback) {
......@@ -47,6 +48,15 @@ export default {
this.search.query = compileTokens(newValue)
this.page = 1
this.fetchData()
if (this.updateUrl) {
let params = {}
if (this.search.query) {
params.q = this.search.query
}
this.$router.replace({
query: params
})
}
},
deep: true
},
......
......@@ -16,6 +16,7 @@ export default {
filters: {
creation_date: this.$gettext('Creation date'),
first_seen: this.$gettext('First seen date'),
last_seen: this.$gettext('Last seen date'),
accessed_date: this.$gettext('Accessed date'),
modification_date: this.$gettext('Modification date'),
imported_date: this.$gettext('Imported date'),
......@@ -31,8 +32,11 @@ export default {
date_joined: this.$gettext('Sign-up date'),
last_activity: this.$gettext('Last activity'),
username: this.$gettext('Username'),
domain: this.$gettext('Domain'),
users: this.$gettext('Users'),
received_messages: this.$gettext('Received messages'),
uploads: this.$gettext('Uploads'),
followers: this.$gettext('Followers'),
}
}
}
......
......@@ -27,12 +27,13 @@ import AdminSettings from '@/views/admin/Settings'
import AdminLibraryBase from '@/views/admin/library/Base'
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
import AdminUsersBase from '@/views/admin/users/Base'
import AdminUsersDetail from '@/views/admin/users/UsersDetail'
import AdminUsersList from '@/views/admin/users/UsersList'
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
import AdminModerationBase from '@/views/admin/moderation/Base'
import AdminDomainsList from '@/views/admin/moderation/DomainsList'
import AdminDomainsDetail from '@/views/admin/moderation/DomainsDetail'
import AdminAccountsList from '@/views/admin/moderation/AccountsList'
import AdminAccountsDetail from '@/views/admin/moderation/AccountsDetail'
import ContentBase from '@/views/content/Base'
import ContentHome from '@/views/content/Home'
import LibrariesHome from '@/views/content/libraries/Home'
......@@ -214,12 +215,6 @@ export default new Router({
name: 'manage.users.users.list',
component: AdminUsersList
},
{
path: 'users/:id',
name: 'manage.users.users.detail',
component: AdminUsersDetail,
props: true
},
{
path: 'invitations',
name: 'manage.users.invitations.list',
......@@ -241,6 +236,23 @@ export default new Router({
name: 'manage.moderation.domains.detail',
component: AdminDomainsDetail,
props: true
},
{
path: 'accounts',
name: 'manage.moderation.accounts.list',
component: AdminAccountsList,
props: (route) => {
return {
defaultQuery: route.query.q,
}
}
},
{
path: 'accounts/:id',
name: 'manage.moderation.accounts.detail',
component: AdminAccountsDetail,
props: true
}
]
},
......
<template>
<main>
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="object">
<section :class="['ui', 'head', 'vertical', 'stripe', 'segment']" v-title="object.full_username">
<div class="segment-content">
<h2 class="ui header">
<i class="circular inverted user icon"></i>
<div class="content">
{{ object.full_username }}
<div class="sub header">
<template v-if="object.user">
<span class="ui tiny teal icon label">
<i class="home icon"></i>
<translate>Local account</translate>
</span>
&nbsp;
</template>
<a :href="object.url || object.fid" target="_blank" rel="noopener noreferrer">
<translate>Open profile</translate>&nbsp;
<i class="external icon"></i>
</a>
</div>
</div>
</h2>
</div>
</section>
<div class="ui vertical stripe segment">
<div class="ui stackable three column grid">
<div class="column">
<section>
<h3 class="ui header">
<i class="info icon"></i>
<div class="content">
<translate>Account data</translate>
</div>
</h3>
<table class="ui very basic table">
<tbody>
<tr>
<td>
<translate>Username</translate>
</td>
<td>
{{ object.preferred_username }}
</td>
</tr>
<tr v-if="!object.user">
<td>
<translate>Domain</translate>
</td>
<td>
<router-link :to="{name: 'manage.moderation.domains.detail', params: {id: object.domain }}">
{{ object.domain }}
</router-link>
</td>
</tr>
<tr>
<td>
<translate>Display name</translate>
</td>
<td>
{{ object.name }}
</td>
</tr>
<tr v-if="object.user">
<td>
<translate>Email address</translate>
</td>
<td>
{{ object.user.email }}
</td>
</tr>
<tr v-if="object.user">
<td>
<translate>Login status</translate>
</td>
<td>
<div class="ui toggle checkbox" v-if="object.user.username != $store.state.auth.profile.username">
<input
@change="updateUser('is_active')"
v-model="object.user.is_active" type="checkbox">
<label>
<translate v-if="object.user.is_active" key="1">Enabled</translate>
<translate v-else key="2">Disabled</translate>
</label>
</div>
<translate v-else-if="object.user.is_active" key="1">Enabled</translate>
<translate v-else key="2">Disabled</translate>
</td>
</tr>
<tr v-if="object.user">
<td>
<translate>Permissions</translate>
</td>
<td>
<select
@change="updateUser('permissions')"
v-model="permissions"
multiple
class="ui search selection dropdown">
<option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
</select>
</td>
</tr>
<tr>
<td>
<translate>Type</translate>
</td>
<td>
{{ object.type }}
</td>
</tr>
<tr v-if="!object.user">
<td>
<translate>First seen</translate>
</td>
<td>
<human-date :date="object.creation_date"></human-date>
</td>
</tr>
<tr v-if="!object.user">
<td>
<translate>Last checked</translate>
</td>
<td>
<human-date v-if="object.last_fetch_date" :date="object.last_fetch_date"></human-date>
<translate v-else>N/A</translate>
</td>
</tr>
<tr v-if="object.user">
<td>
<translate>Sign-up date</translate>
</td>
<td>
<human-date :date="object.user.date_joined"></human-date>
</td>
</tr>
<tr v-if="object.user">
<td>
<translate>Last activity</translate>
</td>
<td>
<human-date :date="object.user.last_activity"></human-date>
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="feed icon"></i>
<div class="content">
<translate>Activity</translate>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<div v-if="isLoadingStats" class="ui placeholder">
<div class="full line"></div>
<div class="short line"></div>
<div class="medium line"></div>
<div class="long line"></div>
</div>
<table v-else class="ui very basic table">
<tbody>
<tr>
<td>
<translate>Emitted messages</translate>
</td>
<td>
{{ stats.outbox_activities}}
</td>
</tr>
<tr>
<td>
<translate>Received library follows</translate>
</td>
<td>
{{ stats.received_library_follows}}
</td>
</tr>
<tr>
<td>
<translate>Emitted library follows</translate>
</td>
<td>
{{ stats.emitted_library_follows}}
</td>
</tr>
</tbody>
</table>
</section>
</div>
<div class="column">
<section>
<h3 class="ui header">
<i class="music icon"></i>
<div class="content">
<translate>Audio content</translate>&nbsp;
<span :data-tooltip="labels.statsWarning"><i class="question circle icon"></i></span>
</div>
</h3>
<div v-if="isLoadingStats" class="ui placeholder">
<div class="full line"></div>
<div class="short line"></div>
<div class="medium line"></div>
<div class="long line"></div>
</div>
<table v-else class="ui very basic table">
<tbody>
<tr v-if="!object.user">
<td>
<translate>Cached size</translate>
</td>
<td>
{{ stats.media_downloaded_size | humanSize }}
</td>
</tr>
<tr v-if="object.user">
<td>
<translate>Upload quota</translate>
<span :data-tooltip="labels.uploadQuota"><i class="question circle icon"></i></span>
</td>
<td>
<div class="ui right labeled input">
<input
class="ui input"
@change="updateUser('upload_quota', true)"
v-model.number="object.user.upload_quota"
step="100"
type="number" />
<div class="ui basic label">
<translate>MB</translate>
</div>
</div>
</td>
</tr>
<tr>
<td>
<translate>Total size</translate>
</td>
<td>
{{ stats.media_total_size | humanSize }}
</td>
</tr>
<tr>
<td>
<translate>Libraries</translate>
</td>
<td>
{{ stats.libraries }}
</td>
</tr>
<tr>
<td>
<translate>Uploads</translate>
</td>
<td>
{{ stats.uploads }}
</td>
</tr>
<tr>
<td>
<translate>Artists</translate>
</td>
<td>
{{ stats.artists }}
</td>
</tr>
<tr>
<td>
<translate>Albums</translate>
</td>
<td>
{{ stats.albums}}
</td>
</tr>
<tr>
<td>
<translate>Tracks</translate>
</td>
<td>
{{ stats.tracks }}
</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</div>
</template>
</main>
</template>
<script>
import axios from "axios"
import logger from "@/logging"
import lodash from '@/lodash'
import $ from "jquery"
export default {
props: ["id"],
data() {
return {
lodash,
isLoading: true,
isLoadingStats: false,
object: null,
stats: null,
permissions: [],
}
},
created() {
this.fetchData()
this.fetchStats()
},
methods: {
fetchData() {
var self = this
this.isLoading = true
let url = "manage/accounts/" + this.id + "/"
axios.get(url).then(response => {
self.object = response.data
self.isLoading = false
if (response.data.user) {
self.allPermissions.forEach(p => {
if (self.object.user.permissions[p.code]) {
self.permissions.push(p.code)
}
})
}
})
},
fetchStats() {
var self = this
this.isLoadingStats = true
let url = "manage/accounts/" + this.id + "/stats/"
axios.get(url).then(response => {
self.stats = response.data
self.isLoadingStats = false
})
},
refreshNodeInfo (data) {
this.object.nodeinfo = data
this.object.nodeinfo_fetch_date = new Date()
},
updateUser(attr, toNull) {
let newValue = this.object.user[attr]
if (toNull && !newValue) {
newValue = null
}
let params = {}
if (attr === "permissions") {
params["permissions"] = {}
this.allPermissions.forEach(p => {
params["permissions"][p.code] = this.permissions.indexOf(p.code) > -1
})
} else {
params[attr] = newValue
}
axios.patch(`manage/users/users/${this.object.user.id}/`, params).then(
response => {
logger.default.info(
`${attr} was updated succcessfully to ${newValue}`
)
},
error => {
logger.default.error(
`Error while setting ${attr} to ${newValue}`,
error
)
}
)
}
},
computed: {
labels() {
return {
statsWarning: this.$gettext("Statistics are computed from known activity and content on your instance, and do not reflect general activity for this account"),
uploadQuota: this.$gettext(
"Determine how much content the user can upload. Leave empty to use the default value of the instance."
),
}
},
allPermissions() {
return [
{
code: "library",
label: this.$gettext("Library")
},
{
code: "moderation",
label: this.$gettext("Moderation")
},
{
code: "settings",
label: this.$gettext("Settings")
}
]
}
},
watch: {
object () {
this.$nextTick(() => {
$(this.$el).find("select.dropdown").dropdown()
})
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
<template>
<main v-title="labels.accounts">
<section class="ui vertical stripe segment">
<h2 class="ui header"><translate>Accounts</translate></h2>
<div class="ui hidden divider"></div>
<accounts-table :update-url="true" :default-query="defaultQuery"></accounts-table>
</section>
</main>
</template>
<script>
import AccountsTable from "@/components/manage/moderation/AccountsTable"
export default {
components: {
AccountsTable
},
props: {
defaultQuery: {type: String, required: false},
},
computed: {
labels() {
return {
accounts: this.$gettext("Accounts")
}
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
<template>
<div class="main pusher" v-title="labels.manageDomains">
<div class="main pusher" v-title="labels.moderation">
<nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
<router-link
class="ui item"
:to="{name: 'manage.moderation.domains.list'}"><translate>Domains</translate></router-link>
<router-link
class="ui item"
:to="{name: 'manage.moderation.accounts.list'}"><translate>Accounts</translate></router-link>
</nav>
<router-view :key="$route.fullPath"></router-view>
</div>
......@@ -14,7 +18,7 @@ export default {
computed: {
labels() {
return {
manageDomains: this.$gettext("Manage domains"),
moderation: this.$gettext("Moderation"),
secondaryMenu: this.$gettext("Secondary menu")
}
}
......
......@@ -115,7 +115,11 @@
<tbody>
<tr>
<td>
<translate>Known users</translate>
<router-link
:to="{name: 'manage.moderation.accounts.list', query: {q: 'domain:' + object.name }}">
<translate>Known accounts</translate>
</router-link>
</td>
<td>
{{ stats.actors }}
......@@ -169,58 +173,58 @@
<tbody>
<tr>
<td>
<translate>Artists</translate>
<translate>Cached size</translate>
</td>
<td>
{{ stats.artists }}
{{ stats.media_downloaded_size | humanSize }}
</td>
</tr>
<tr>
<td>
<translate>Albums</translate>
<translate>Total size</translate>
</td>
<td>
{{ stats.albums}}
{{ stats.media_total_size | humanSize }}
</td>
</tr>
<tr>
<td>
<translate>Tracks</translate>
<translate>Libraries</translate>
</td>
<td>
{{ stats.tracks }}
{{ stats.libraries }}
</td>
</tr>
<tr>
<td>
<translate>Libraries</translate>
<translate>Uploads</translate>
</td>
<td>
{{ stats.libraries }}
{{ stats.uploads }}
</td>
</tr>
<tr>
<td>
<translate>Uploads</translate>
<translate>Artists</translate>
</td>
<td>
{{ stats.uploads }}
{{ stats.artists }}
</td>
</tr>
<tr>
<td>
<translate>Cached size</translate>
<translate>Albums</translate>
</td>
<td>
{{ stats.media_downloaded_size | humanSize }}
{{ stats.albums}}
</td>
</tr>
<tr>
<td>
<translate>Total size</translate>
<translate>Tracks</translate>
</td>
<td>
{{ stats.media_total_size | humanSize }}
{{ stats.tracks }}
</td>
</tr>
</tbody>
......
<template>
<main>
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="object">
<section :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']" v-title="object.username">
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted user red icon"></i>
<div class="content">
@{{ object.username }}
</div>
</h2>
</div>
<div class="ui hidden divider"></div>
<div class="ui one column centered grid">
<table class="ui collapsing very basic table">
<tbody>
<tr>
<td>
<translate>Name</translate>
</td>
<td>
{{ object.name }}
</td>
</tr>
<tr>
<td>
<translate>Email address</translate>
</td>
<td>
{{ object.email }}
</td>
</tr>
<tr>
<td>
<translate>Sign-up</translate>
</td>
<td>
<human-date :date="object.date_joined"></human-date>
</td>
</tr>
<tr>
<td>
<translate>Last activity</translate>
</td>
<td>
<human-date v-if="object.last_activity" :date="object.last_activity"></human-date>
<template v-else><translate>N/A</translate></template>
</td>
</tr>
<tr>
<td>
<translate>Account active</translate>
<span :data-tooltip="labels.inactive"><i class="question circle icon"></i></span>
</td>
<td>
<div class="ui toggle checkbox">
<input
@change="update('is_active')"
v-model="object.is_active" type="checkbox">
<label></label>
</div>
</td>
</tr>
<tr>
<td>
<translate>Permissions</translate>
</td>
<td>
<select
@change="update('permissions')"
v-model="permissions"
multiple
class="ui search selection dropdown">
<option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
</select>
</td>
</tr>
<tr>
<td>
<translate>Upload quota</translate>
<span :data-tooltip="labels.uploadQuota"><i class="question circle icon"></i></span>
</td>
<td>
<div class="ui right labeled input">
<input
class="ui input"
@change="update('upload_quota', true)"
v-model.number="object.upload_quota"
step="100"
type="number" />
<div class="ui basic label">
<translate>MB</translate>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="ui hidden divider"></div>
<button @click="fetchData" class="ui basic button"><translate>Refresh</translate></button>
</section>
</template>
</main>
</template>
<script>
import $ from "jquery"
import axios from "axios"
import logger from "@/logging"
export default {
props: ["id"],
data() {
return {
isLoading: true,
object: null,
permissions: []
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
var self = this
this.isLoading = true
let url = "manage/users/users/" + this.id + "/"
axios.get(url).then(response => {
self.object = response.data
self.permissions = []
self.allPermissions.forEach(p => {
if (self.object.permissions[p.code]) {
self.permissions.push(p.code)
}
})
self.isLoading = false
})
},
update(attr, toNull) {
let newValue = this.object[attr]
if (toNull && !newValue) {
newValue = null
}
let params = {}
if (attr === "permissions") {
params["permissions"] = {}
this.allPermissions.forEach(p => {
params["permissions"][p.code] = this.permissions.indexOf(p.code) > -1
})
} else {
params[attr] = newValue
}
axios.patch("manage/users/users/" + this.id + "/", params).then(
response => {
logger.default.info(
`${attr} was updated succcessfully to ${newValue}`
)
},
error => {
logger.default.error(
`Error while setting ${attr} to ${newValue}`,
error
)
}
)
}
},
computed: {
labels() {
return {
inactive: this.$gettext(
"Determine if the user account is active or not. Inactive users cannot login or use the service."
),
uploadQuota: this.$gettext(
"Determine how much content the user can upload. Leave empty to use the default value of the instance."
)
}
},
allPermissions() {
return [
{
code: "upload",
label: this.$gettext("Upload")
},
{
code: "library",
label: this.$gettext("Library")
},
{
code: "federation",
label: this.$gettext("Federation")
},
{
code: "settings",
label: this.$gettext("Settings")
}
]
}
},
watch: {
object() {
this.$nextTick(() => {
$("select.dropdown").dropdown()
})
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment