diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5dfbf0642691e4033bac3f82bdc6fcdeff49f878..206bb50cc4a08fd53c59dcfa64c7f44427f2c823 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -20,7 +20,7 @@ black: before_script: - pip install black script: - - black --check --diff api/ + - black --exclude "/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|migrations)/" --check --diff api/ flake8: image: python:3.6 diff --git a/api/config/settings/common.py b/api/config/settings/common.py index cb5573ed58ddf3edc8ae4df14b7ecf799b0946e4..a836dfdfddb096384bfff92855a34cf478f0aaa6 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -146,6 +146,7 @@ MIDDLEWARE = ( "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "funkwhale_api.users.middleware.RecordActivityMiddleware", ) # MIGRATIONS CONFIGURATION diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py index 2f2bde838fa90695ebc1f077e88650f2d69536c5..e4cda18c5b9de693a83adf12c4335d45249c0052 100644 --- a/api/funkwhale_api/manage/filters.py +++ b/api/funkwhale_api/manage/filters.py @@ -3,6 +3,7 @@ from django_filters import rest_framework as filters from funkwhale_api.common import fields from funkwhale_api.music import models as music_models +from funkwhale_api.users import models as users_models class ManageTrackFileFilterSet(filters.FilterSet): @@ -18,3 +19,21 @@ class ManageTrackFileFilterSet(filters.FilterSet): class Meta: model = music_models.TrackFile fields = ["q", "track__album", "track__artist", "track", "library_track"] + + +class ManageUserFilterSet(filters.FilterSet): + q = fields.SearchFilter(search_fields=["username", "email", "name"]) + + class Meta: + model = users_models.User + fields = [ + "q", + "is_active", + "privacy_level", + "is_staff", + "is_superuser", + "permission_upload", + "permission_library", + "permission_settings", + "permission_federation", + ] diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 1c94cf5538171973a16e29a8f54591daf7778f2e..6e57db81f91a431baab736f24dda6b1a4c2e9f2e 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers from funkwhale_api.common import serializers as common_serializers from funkwhale_api.music import models as music_models +from funkwhale_api.users import models as users_models from . import filters @@ -67,3 +68,50 @@ class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): @transaction.atomic def handle_delete(self, objects): return objects.delete() + + +class PermissionsSerializer(serializers.Serializer): + def to_representation(self, o): + return o.get_permissions(defaults=self.context.get("default_permissions")) + + def to_internal_value(self, o): + return {"permissions": o} + + +class ManageUserSerializer(serializers.ModelSerializer): + permissions = PermissionsSerializer(source="*") + + class Meta: + model = users_models.User + fields = ( + "id", + "username", + "email", + "name", + "is_active", + "is_staff", + "is_superuser", + "date_joined", + "last_activity", + "permissions", + "privacy_level", + ) + read_only_fields = [ + "id", + "email", + "privacy_level", + "username", + "date_joined", + "last_activity", + ] + + def update(self, instance, validated_data): + instance = super().update(instance, validated_data) + permissions = validated_data.pop("permissions", {}) + if permissions: + for p, value in permissions.items(): + setattr(instance, "permission_{}".format(p), value) + instance.save( + update_fields=["permission_{}".format(p) for p in permissions.keys()] + ) + return instance diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py index 60853034f0a0552c01b67b6a0354158691d49783..f208fb857079db390c6d4d4bb63e14107b2d6c11 100644 --- a/api/funkwhale_api/manage/urls.py +++ b/api/funkwhale_api/manage/urls.py @@ -5,7 +5,10 @@ from . import views library_router = routers.SimpleRouter() library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files") +users_router = routers.SimpleRouter() +users_router.register(r"users", views.ManageUserViewSet, "users") urlpatterns = [ - url(r"^library/", include((library_router.urls, "instance"), namespace="library")) + url(r"^library/", include((library_router.urls, "instance"), namespace="library")), + url(r"^users/", include((users_router.urls, "instance"), namespace="users")), ] diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py index 8511732c96b287e7c2c82da799dc864c7a455e6a..f9b78ef87a076a95afa43dc8c57fe9d6ededffc1 100644 --- a/api/funkwhale_api/manage/views.py +++ b/api/funkwhale_api/manage/views.py @@ -1,7 +1,9 @@ from rest_framework import mixins, response, viewsets from rest_framework.decorators import list_route +from funkwhale_api.common import preferences from funkwhale_api.music import models as music_models +from funkwhale_api.users import models as users_models from funkwhale_api.users.permissions import HasUserPermission from . import filters, serializers @@ -41,3 +43,22 @@ class ManageTrackFileViewSet( serializer.is_valid(raise_exception=True) result = serializer.save() return response.Response(result, status=200) + + +class ManageUserViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + queryset = users_models.User.objects.all().order_by("-id") + serializer_class = serializers.ManageUserSerializer + filter_class = filters.ManageUserFilterSet + permission_classes = (HasUserPermission,) + required_permissions = ["settings"] + ordering_fields = ["date_joined", "last_activity", "username"] + + def get_serializer_context(self): + context = super().get_serializer_context() + context["default_permissions"] = preferences.get("users__default_permissions") + return context diff --git a/api/funkwhale_api/users/middleware.py b/api/funkwhale_api/users/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..d5e83f0809336c335db7f7c21beaa5ac2b6995f1 --- /dev/null +++ b/api/funkwhale_api/users/middleware.py @@ -0,0 +1,9 @@ +class RecordActivityMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if hasattr(request, "user") and request.user.is_authenticated: + request.user.record_activity() + return response diff --git a/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py new file mode 100644 index 0000000000000000000000000000000000000000..b731e327951573b3092d098ab9c9b3c0dfcdf9df --- /dev/null +++ b/api/funkwhale_api/users/migrations/0008_auto_20180617_1531.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.6 on 2018-06-17 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0007_auto_20180524_2009'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='last_activity', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='user', + name='permission_library', + field=models.BooleanField(default=False, help_text='Manage library, delete files, tracks, artists, albums...', verbose_name='Manage library'), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index caf1e452bbcab42ff52587a50d35c6039fdf132c..15d16db2369967cddf38310d871c6bae3733dab0 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, unicode_literals import binascii +import datetime import os import uuid @@ -9,6 +10,7 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models from django.urls import reverse +from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ @@ -75,11 +77,13 @@ class User(AbstractUser): default=False, ) + last_activity = models.DateTimeField(default=None, null=True, blank=True) + def __str__(self): return self.username - def get_permissions(self): - defaults = preferences.get("users__default_permissions") + def get_permissions(self, defaults=None): + defaults = defaults or preferences.get("users__default_permissions") perms = {} for p in PERMISSIONS: v = ( @@ -90,6 +94,10 @@ class User(AbstractUser): perms[p] = v return perms + @property + def all_permissions(self): + return self.get_permissions() + def has_permissions(self, *perms, **kwargs): operator = kwargs.pop("operator", "and") if operator not in ["and", "or"]: @@ -117,3 +125,16 @@ class User(AbstractUser): def get_activity_url(self): return settings.FUNKWHALE_URL + "/@{}".format(self.username) + + def record_activity(self): + """ + Simply update the last_activity field if current value is too old + than a threshold. This is useful to keep a track of inactive accounts. + """ + current = self.last_activity + delay = 60 * 15 # fifteen minutes + now = timezone.now() + + if current is None or current < now - datetime.timedelta(seconds=delay): + self.last_activity = now + self.save(update_fields=["last_activity"]) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 40203ee3d472693d7304d92ab68094949535fab9..aa36e1f76f60d836a33b0c82a44be55a4f3cb372 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -7,6 +7,7 @@ import pytest import requests_mock from django.contrib.auth.models import AnonymousUser from django.core.cache import cache as django_cache +from django.utils import timezone from django.test import client from dynamic_preferences.registries import global_preferences_registry from rest_framework import fields as rest_fields @@ -250,3 +251,10 @@ def to_api_date(): raise ValueError("Invalid value: {}".format(value)) return inner + + +@pytest.fixture() +def now(mocker): + now = timezone.now() + mocker.patch("django.utils.timezone.now", return_value=now) + return now diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 893cfd86e46e5720446c78dcde5457a7358206b1..2f0c6bc2568e4a2a7c9e9755a00eda50ecd9dd92 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -8,3 +8,26 @@ def test_manage_track_file_action_delete(factories): s.handle_delete(tfs.__class__.objects.all()) assert tfs.__class__.objects.count() == 0 + + +def test_user_update_permission(factories): + user = factories["users.User"]( + permission_library=False, + permission_upload=False, + permission_federation=True, + permission_settings=True, + is_active=True, + ) + s = serializers.ManageUserSerializer( + user, + data={"is_active": False, "permissions": {"federation": False, "upload": True}}, + ) + s.is_valid(raise_exception=True) + s.save() + user.refresh_from_db() + + assert user.is_active is False + assert user.permission_federation is False + assert user.permission_upload is True + assert user.permission_library is False + assert user.permission_settings is True diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py index e2bfbf3a81511dbf313cd630e1d1353840e38b83..a72bcf5af4fa77b92a25ab5dd099684bb196f13c 100644 --- a/api/tests/manage/test_views.py +++ b/api/tests/manage/test_views.py @@ -5,7 +5,11 @@ from funkwhale_api.manage import serializers, views @pytest.mark.parametrize( - "view,permissions,operator", [(views.ManageTrackFileViewSet, ["library"], "and")] + "view,permissions,operator", + [ + (views.ManageTrackFileViewSet, ["library"], "and"), + (views.ManageUserViewSet, ["settings"], "and"), + ], ) def test_permissions(assert_user_permission, view, permissions, operator): assert_user_permission(view, permissions, operator) @@ -23,3 +27,18 @@ def test_track_file_view(factories, superuser_api_client): assert response.data["count"] == len(tfs) assert response.data["results"] == expected + + +def test_user_view(factories, superuser_api_client, mocker): + mocker.patch("funkwhale_api.users.models.User.record_activity") + users = factories["users.User"].create_batch(size=5) + [superuser_api_client.user] + qs = users[0].__class__.objects.order_by("-id") + url = reverse("api:v1:manage:users:users-list") + + response = superuser_api_client.get(url, {"sort": "-id"}) + expected = serializers.ManageUserSerializer( + qs, many=True, context={"request": response.wsgi_request} + ).data + + assert response.data["count"] == len(users) + assert response.data["results"] == expected diff --git a/api/tests/users/test_middleware.py b/api/tests/users/test_middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..fd13df4b31b7e8cefb1fa61347574b43fc6ab727 --- /dev/null +++ b/api/tests/users/test_middleware.py @@ -0,0 +1,18 @@ +from funkwhale_api.users import middleware + + +def test_record_activity_middleware(factories, api_request, mocker): + m = middleware.RecordActivityMiddleware(lambda request: None) + user = factories["users.User"]() + record_activity = mocker.patch("funkwhale_api.users.models.User.record_activity") + request = api_request.get("/") + request.user = user + m(request) + + record_activity.assert_called_once_with() + + +def test_record_activity_middleware_no_user(api_request): + m = middleware.RecordActivityMiddleware(lambda request: None) + request = api_request.get("/") + m(request) diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py index c73a4a1b1a4b4cd351278d7757fb2b0307e3b193..74bb091e54270c4034050b7810e2b6ba3cb4bde3 100644 --- a/api/tests/users/test_models.py +++ b/api/tests/users/test_models.py @@ -78,3 +78,20 @@ def test_has_permissions_and(args, perms, expected, factories): def test_has_permissions_or(args, perms, expected, factories): user = factories["users.User"](**args) assert user.has_permissions(*perms, operator="or") is expected + + +def test_record_activity(factories, now): + user = factories["users.User"]() + assert user.last_activity is None + + user.record_activity() + + assert user.last_activity == now + + +def test_record_activity_does_nothing_if_already(factories, now, mocker): + user = factories["users.User"](last_activity=now) + save = mocker.patch("funkwhale_api.users.models.User.save") + user.record_activity() + + save.assert_not_called() diff --git a/changes/changelog.d/212.feature b/changes/changelog.d/212.feature new file mode 100644 index 0000000000000000000000000000000000000000..0d029856b7c808bec9f9b2da8c71e5bb1b211ceb --- /dev/null +++ b/changes/changelog.d/212.feature @@ -0,0 +1 @@ +Management interface for users (#212) diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index d46fb846cf1d76fd23265b18f1fd34a6e361dfe7..03ea4ee0773a022acd0ef5d9cbd36c554330f6af 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -96,6 +96,12 @@ :to="{path: '/manage/settings'}"> <i class="settings icon"></i>{{ $t('Settings') }} </router-link> + <router-link + class="item" + v-if="$store.state.auth.availablePermissions['settings']" + :to="{path: '/manage/users'}"> + <i class="users icon"></i>{{ $t('Users') }} + </router-link> </div> </div> </div> diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue index 5221c328292c317c199850d6c9a3de58fec188f9..f23479066e37667d3ea7dba63d504f1778a93d07 100644 --- a/front/src/components/common/ActionTable.vue +++ b/front/src/components/common/ActionTable.vue @@ -61,7 +61,7 @@ </th> </tr> <tr> - <th> + <th v-if="actions.length > 0"> <div class="ui checkbox"> <input type="checkbox" @@ -75,7 +75,7 @@ </thead> <tbody v-if="objectsData.count > 0"> <tr v-for="(obj, index) in objectsData.results"> - <td class="collapsing"> + <td v-if="actions.length > 0" class="collapsing"> <input type="checkbox" :disabled="checkable.indexOf(obj.id) === -1" @@ -184,6 +184,9 @@ export default { })[0] }, checkable () { + if (!this.currentAction) { + return [] + } let objs = this.objectsData.results let filter = this.currentAction.filterCheckable if (filter) { diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..5658583c3df1d59b51d186123beb6fa15aa6dd29 --- /dev/null +++ b/front/src/components/manage/users/UsersTable.vue @@ -0,0 +1,216 @@ +<template> + <div> + <div class="ui inline form"> + <div class="fields"> + <div class="ui field"> + <label>{{ $t('Search') }}</label> + <input type="text" v-model="search" placeholder="Search by username, email, name..." /> + </div> + <div class="field"> + <i18next tag="label" path="Ordering"/> + <select class="ui dropdown" v-model="ordering"> + <option v-for="option in orderingOptions" :value="option[0]"> + {{ option[1] }} + </option> + </select> + </div> + <div class="field"> + <i18next tag="label" path="Ordering direction"/> + <select class="ui dropdown" v-model="orderingDirection"> + <option value="+">{{ $t('Ascending') }}</option> + <option value="-">{{ $t('Descending') }}</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" + :action-url="'manage/library/track-files/action/'" + :filters="actionFilters"> + <template slot="header-cells"> + <th>{{ $t('Username') }}</th> + <th>{{ $t('Email') }}</th> + <th>{{ $t('Account status') }}</th> + <th>{{ $t('Sign-up') }}</th> + <th>{{ $t('Last activity') }}</th> + <th>{{ $t('Permissions') }}</th> + <th>{{ $t('Status') }}</th> + </template> + <template slot="row-cells" slot-scope="scope"> + <td> + <router-link :to="{name: 'manage.users.detail', params: {id: scope.obj.id }}">{{Â scope.obj.username }}</router-link> + </td> + <td> + <span>{{Â scope.obj.email }}</span> + </td> + <td> + <span v-if="scope.obj.is_active" class="ui basic green label">{{Â $t('Active') }}</span> + <span v-else class="ui basic grey label">{{Â $t('Inactive') }}</span> + </td> + <td> + <human-date :date="scope.obj.date_joined"></human-date> + </td> + <td> + <human-date v-if="scope.obj.last_activity" :date="scope.obj.last_activity"></human-date> + <template v-else>{{ $t('N/A') }}</template> + </td> + <td> + <template v-for="p in permissions"> + <span class="ui basic tiny label" v-if="scope.obj.permissions[p.code]">{{ p.label }}</span> + </template> + </td> + <td> + <span v-if="scope.obj.is_superuser" class="ui pink label">{{ $t('Admin') }}</span> + <span v-else-if="scope.obj.is_staff" class="ui purple label">{{ $t('Staff member') }}</span> + <span v-else class="ui basic label">{{ $t('regular user') }}</span> + </td> + </template> + </action-table> + </div> + <div> + <pagination + v-if="result && result.results.length > 0" + @page-changed="selectPage" + :compact="true" + :current="page" + :paginate-by="paginateBy" + :total="result.count" + ></pagination> + + <span v-if="result && result.results.length > 0"> + {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}} + </span> + </div> + </div> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' +import time from '@/utils/time' +import Pagination from '@/components/Pagination' +import ActionTable from '@/components/common/ActionTable' +import OrderingMixin from '@/components/mixins/Ordering' + +export default { + mixins: [OrderingMixin], + props: { + filters: {type: Object, required: false} + }, + components: { + Pagination, + ActionTable + }, + data () { + let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-date_joined') + return { + time, + isLoading: false, + result: null, + page: 1, + paginateBy: 50, + search: '', + orderingDirection: defaultOrdering.direction || '+', + ordering: defaultOrdering.field, + orderingOptions: [ + ['date_joined', 'Sign-up date'], + ['last_activity', 'Last activity'], + ['username', 'Username'] + ] + + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'page_size': this.paginateBy, + 'q': this.search, + 'ordering': this.getOrderingAsString() + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/manage/users/users/', {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: { + privacyLevels () { + return {} + }, + permissions () { + return [ + { + 'code': 'upload', + 'label': this.$t('Upload') + }, + { + 'code': 'library', + 'label': this.$t('Library') + }, + { + 'code': 'federation', + 'label': this.$t('Federation') + }, + { + 'code': 'settings', + 'label': this.$t('Settings') + } + ] + }, + actionFilters () { + var currentFilters = { + q: this.search + } + if (this.filters) { + return _.merge(currentFilters, this.filters) + } else { + return currentFilters + } + }, + actions () { + return [ + // { + // name: 'delete', + // label: this.$t('Delete'), + // isDangerous: true + // } + ] + } + }, + watch: { + search (newValue) { + this.page = 1 + this.fetchData() + }, + page () { + this.fetchData() + }, + ordering () { + this.fetchData() + }, + orderingDirection () { + this.fetchData() + } + } +} +</script> diff --git a/front/src/router/index.js b/front/src/router/index.js index a52070e35912b42813db85f3c8ac195f6e39e4d2..0d2ad34f98aceff40b212625bac52b9ab9be9bf7 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -31,6 +31,9 @@ import Favorites from '@/components/favorites/List' 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 FederationBase from '@/views/federation/Base' import FederationScan from '@/views/federation/Scan' import FederationLibraryDetail from '@/views/federation/LibraryDetail' @@ -180,6 +183,23 @@ export default new Router({ } ] }, + { + path: '/manage/users', + component: AdminUsersBase, + children: [ + { + path: '', + name: 'manage.users.list', + component: AdminUsersList + }, + { + path: ':id', + name: 'manage.users.detail', + component: AdminUsersDetail, + props: true + } + ] + }, { path: '/library', component: Library, diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue new file mode 100644 index 0000000000000000000000000000000000000000..e545b7f709fd63cad5154c4c0375b067f689e72f --- /dev/null +++ b/front/src/views/admin/users/Base.vue @@ -0,0 +1,28 @@ +<template> + <div class="main pusher" v-title="$t('Manage users')"> + <div class="ui secondary pointing menu"> + <router-link + class="ui item" + :to="{name: 'manage.users.list'}">{{ $t('Users') }}</router-link> + </div> + <router-view :key="$route.fullPath"></router-view> + </div> +</template> + +<script> +export default {} +</script> + +<style lang="scss"> +@import '../../../style/vendor/media'; + +.main.pusher > .ui.secondary.menu { + @include media(">tablet") { + margin: 0 2.5rem; + } + .item { + padding-top: 1.5em; + padding-bottom: 1.5em; + } +} +</style> diff --git a/front/src/views/admin/users/UsersDetail.vue b/front/src/views/admin/users/UsersDetail.vue new file mode 100644 index 0000000000000000000000000000000000000000..ea92716ca943570d77e1cd4cd91cb1d9eaa97133 --- /dev/null +++ b/front/src/views/admin/users/UsersDetail.vue @@ -0,0 +1,177 @@ +<template> + <div> + <div v-if="isLoading" class="ui vertical segment"> + <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> + </div> + <template v-if="object"> + <div :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> + {{ $t('Name') }} + </td> + <td> + {{ object.name }} + </td> + </tr> + <tr> + <td> + {{ $t('Email address') }} + </td> + <td> + {{ object.email }} + </td> + </tr> + <tr> + <td> + {{ $t('Sign-up') }} + </td> + <td> + <human-date :date="object.date_joined"></human-date> + </td> + </tr> + <tr> + <td> + {{ $t('Last activity') }} + </td> + <td> + <human-date v-if="object.last_activity" :date="object.last_activity"></human-date> + <template v-else>{{ $t('N/A') }}</template> + </td> + </tr> + <tr> + <td> + {{ $t('Account active') }} + <span :data-tooltip="$t('Determine if the user account is active or not. Inactive users cannot login or user the service.')"><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> + {{ $t('Permissions') }} + </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> + </tbody> + </table> + </div> + <div class="ui hidden divider"></div> + <button @click="fetchData" class="ui basic button">{{ $t('Refresh') }}</button> + </div> + </template> + </div> +</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) { + let newValue = this.object[attr] + 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: { + allPermissions () { + return [ + { + 'code': 'upload', + 'label': this.$t('Upload') + }, + { + 'code': 'library', + 'label': this.$t('Library') + }, + { + 'code': 'federation', + 'label': this.$t('Federation') + }, + { + 'code': 'settings', + 'label': this.$t('Settings') + } + ] + } + }, + watch: { + object () { + this.$nextTick(() => { + $('select.dropdown').dropdown() + }) + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> diff --git a/front/src/views/admin/users/UsersList.vue b/front/src/views/admin/users/UsersList.vue new file mode 100644 index 0000000000000000000000000000000000000000..b22d4aaf836f0b45be74d2a894c59963eac8a145 --- /dev/null +++ b/front/src/views/admin/users/UsersList.vue @@ -0,0 +1,23 @@ +<template> + <div v-title="$t('Users')"> + <div class="ui vertical stripe segment"> + <h2 class="ui header">{{ $t('Users') }}</h2> + <div class="ui hidden divider"></div> + <users-table></users-table> + </div> + </div> +</template> + +<script> +import UsersTable from '@/components/manage/users/UsersTable' + +export default { + components: { + UsersTable + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style>