1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Merge branch '419-admin-group-management' into 'develop'

Resolve "Admin group management"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-05-29 14:01:39 +00:00
commit 884002efb8
80 changed files with 1429 additions and 762 deletions
backend/src/baserow
config/settings
ws
changelog.md
premium
web-frontend/modules/core
adminTypes.js
components/sidebar

View file

@ -203,6 +203,7 @@ SPECTACULAR_SETTINGS = {
{"name": "Database table grid view"},
{"name": "Database table rows"},
{"name": "Database tokens"},
{"name": "Admin"},
],
}

View file

@ -35,7 +35,7 @@ def group_updated(sender, group, user, **kwargs):
@receiver(signals.group_deleted)
def group_deleted(sender, group_id, group, group_users, user, **kwargs):
def group_deleted(sender, group_id, group, group_users, user=None, **kwargs):
transaction.on_commit(
lambda: broadcast_to_users.delay(
[u.id for u in group_users],

View file

@ -10,6 +10,7 @@
* Made it possible to order the applications by drag and drop.
* Made it possible to order the tables by drag and drop.
* **Premium**: Added an admin dashboard.
* **Premium**: Added group admin area allowing management of all baserow groups.
* Added today, this month and this year filter.
* Added a page containing external resources to the docs.
* Added a human-readable error message when a user tries to sign in with a deactivated

View file

@ -0,0 +1,10 @@
class InvalidSortDirectionException(Exception):
"""
Raised when an invalid sort direction is provided.
"""
class InvalidSortAttributeException(Exception):
"""
Raised when a sort is requested for an invalid or non-existent field.
"""

View file

@ -0,0 +1,30 @@
from baserow.core.handler import CoreHandler
from baserow.core.signals import group_deleted
from baserow.core.exceptions import IsNotAdminError
class GroupsAdminHandler:
def delete_group(self, user, group):
"""
Deletes an existing group and related applications if the user is staff.
:param user: The user on whose behalf the group is deleted
:type: user: User
:param group: The group instance that must be deleted.
:type: group: Group
:raises IsNotAdminError: If the user is not admin or staff.
"""
if not user.is_staff:
raise IsNotAdminError()
# Load the group users before the group is deleted so that we can pass those
# along with the signal.
group_id = group.id
group_users = list(group.users.all())
CoreHandler()._delete_group(group)
group_deleted.send(
self, group_id=group_id, group=group, group_users=group_users
)

View file

@ -14,15 +14,3 @@ class UserDoesNotExistException(Exception):
"""
Raised when a delete or update operation is attempted on an unknown user.
"""
class InvalidSortDirectionException(Exception):
"""
Raised when an invalid sort direction is provided.
"""
class InvalidSortAttributeException(Exception):
"""
Raised when a sort is requested for an invalid or non-existent field.
"""

View file

@ -0,0 +1,109 @@
from typing import Optional
from django.contrib.auth import get_user_model
from baserow.core.exceptions import IsNotAdminError
from baserow_premium.admin.users.exceptions import (
CannotDeactivateYourselfException,
CannotDeleteYourselfException,
UserDoesNotExistException,
)
User = get_user_model()
class UserAdminHandler:
def update_user(
self,
requesting_user: User,
user_id: int,
username: Optional[str] = None,
name: Optional[str] = None,
password: Optional[str] = None,
is_active: Optional[bool] = None,
is_staff: Optional[bool] = None,
):
"""
Updates a specified user with new attribute values. Will raise an exception
if a user attempts to de-activate or un-staff themselves.
:param requesting_user: The user who is making the request to update a user, the
user must be a staff member or else an exception will
be raised.
:param user_id: The id of the user to update, if they do not exist raises a
UserDoesNotExistException.
:param is_staff: Optional value used to set if the user is an admin or not.
:param is_active: Optional value to disable or enable login for the user.
:param password: Optional new password to securely set for the user.
:param name: Optional new name to set on the user.
:param username: Optional new username/email to set for the user.
"""
self._raise_if_not_permitted(requesting_user)
self._raise_if_locking_self_out_of_admin(
is_active, is_staff, requesting_user, user_id
)
try:
user = User.objects.select_for_update().get(id=user_id)
except User.DoesNotExist:
raise UserDoesNotExistException()
if is_staff is not None:
user.is_staff = is_staff
if is_active is not None:
user.is_active = is_active
if password is not None:
user.set_password(password)
if name is not None:
user.first_name = name
if username is not None:
user.email = username
user.username = username
user.save()
return user
@staticmethod
def _raise_if_locking_self_out_of_admin(
is_active, is_staff, requesting_user, user_id
):
"""
Raises an exception if the requesting_user is about to lock themselves out of
the admin area of Baserow by either turning off their staff status or disabling
their account.
"""
is_setting_staff_to_false = is_staff is not None and not is_staff
is_setting_active_to_false = is_active is not None and not is_active
if user_id == requesting_user.id and (
is_setting_staff_to_false or is_setting_active_to_false
):
raise CannotDeactivateYourselfException()
def delete_user(self, requesting_user: User, user_id: int):
"""
Deletes a specified user, raises an exception if you attempt to delete yourself.
:param requesting_user: The user who is making the delete request , the
user must be a staff member or else an exception will
be raised.
:param user_id: The id of the user to update, if they do not exist raises a
UnknownUserException.
"""
self._raise_if_not_permitted(requesting_user)
if requesting_user.id == user_id:
raise CannotDeleteYourselfException()
try:
user = User.objects.get(id=user_id)
user.delete()
except User.DoesNotExist:
raise UserDoesNotExistException()
@staticmethod
def _raise_if_not_permitted(requesting_user):
if not requesting_user.is_staff:
raise IsNotAdminError()

View file

@ -1,9 +1,9 @@
from django.conf.urls import url
from baserow_premium.api.admin_dashboard.views import AdminDashboardView
from baserow_premium.api.admin.dashboard.views import AdminDashboardView
app_name = "baserow_premium.api.admin_dashboard"
app_name = "baserow_premium.api.admin.dashboard"
urlpatterns = [
url(r"^$", AdminDashboardView.as_view(), name="dashboard"),

View file

@ -10,7 +10,8 @@ from rest_framework.views import APIView
from baserow.api.decorators import accept_timezone
from baserow.core.models import Group, Application
from baserow_premium.admin_dashboard.handler import AdminDashboardHandler
from baserow_premium.admin.dashboard.handler import AdminDashboardHandler
from .serializers import AdminDashboardSerializer
@ -22,16 +23,17 @@ class AdminDashboardView(APIView):
permission_classes = (IsAdminUser,)
@extend_schema(
tags=["Admin dashboard"],
tags=["Admin"],
operation_id="admin_dashboard",
description="Returns the new and active users for the last 24 hours, 7 days and"
" 30 days. The `previous_` values are the values of the period before, so for "
"example `previous_new_users_last_24_hours` are the new users that signed up "
"from 48 to 24 hours ago. It can be used to calculate an increase or decrease "
"in the amount of signups. A list of the new and active users for every day "
"for the last 30 days is also included.",
"for the last 30 days is also included.\n\nThis is a **premium** feature.",
responses={
200: AdminDashboardSerializer,
401: None,
},
)
@accept_timezone()

View file

@ -0,0 +1,13 @@
from rest_framework.status import HTTP_400_BAD_REQUEST
ERROR_ADMIN_LISTING_INVALID_SORT_DIRECTION = (
"ERROR_ADMIN_LISTING_INVALID_SORT_DIRECTION",
HTTP_400_BAD_REQUEST,
"Attributes to sort by must be prefixed with one of '-' or '+'.",
)
ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE = (
"ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE",
HTTP_400_BAD_REQUEST,
"Invalid attribute name provided to sort by.",
)

View file

@ -0,0 +1,34 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from baserow.core.models import Group, GroupUser
User = get_user_model()
class GroupAdminUsersSerializer(ModelSerializer):
id = serializers.IntegerField(source="user.id")
email = serializers.CharField(source="user.email")
class Meta:
model = GroupUser
fields = ("id", "email", "permissions")
class GroupsAdminResponseSerializer(ModelSerializer):
users = GroupAdminUsersSerializer(source="groupuser_set", many=True)
application_count = serializers.IntegerField()
class Meta:
model = Group
fields = (
"id",
"name",
"users",
"application_count",
"created_on",
)

View file

@ -0,0 +1,11 @@
from django.conf.urls import url
from baserow_premium.api.admin.groups.views import GroupsAdminView, GroupAdminView
app_name = "baserow_premium.api.admin.groups"
urlpatterns = [
url(r"^$", GroupsAdminView.as_view(), name="list"),
url(r"^(?P<group_id>[0-9]+)/$", GroupAdminView.as_view(), name="edit"),
]

View file

@ -0,0 +1,87 @@
from django.db import transaction
from django.db.models import Count
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
from baserow.api.decorators import map_exceptions
from baserow.api.schemas import get_error_schema
from baserow.api.errors import ERROR_GROUP_DOES_NOT_EXIST
from baserow.core.models import Group
from baserow.core.handler import CoreHandler
from baserow.core.exceptions import GroupDoesNotExist
from baserow_premium.api.admin.views import AdminListingView
from baserow_premium.admin.groups.handler import GroupsAdminHandler
from .serializers import GroupsAdminResponseSerializer
class GroupsAdminView(AdminListingView):
serializer_class = GroupsAdminResponseSerializer
search_fields = ["id", "name"]
sort_field_mapping = {
"id": "id",
"name": "name",
"application_count": "application_count",
"created_on": "created_on",
}
def get_queryset(self, request):
return Group.objects.prefetch_related(
"groupuser_set", "groupuser_set__user"
).annotate(application_count=Count("application"))
@extend_schema(
tags=["Admin"],
operation_id="admin_list_groups",
description="Returns all groups with detailed information on each group, "
"if the requesting user is staff.\n\nThis is a **premium** feature.",
**AdminListingView.get_extend_schema_parameters(
"groups", serializer_class, search_fields, sort_field_mapping
),
)
def get(self, *args, **kwargs):
return super().get(*args, **kwargs)
class GroupAdminView(APIView):
permission_classes = (IsAdminUser,)
@extend_schema(
tags=["Admin"],
operation_id="admin_delete_group",
description="Deletes the specified group and the applications inside that "
"group, if the requesting user is staff. \n\nThis is a **premium** feature.",
parameters=[
OpenApiParameter(
name="group_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The id of the group to delete",
),
],
responses={
204: None,
400: get_error_schema(["ERROR_GROUP_DOES_NOT_EXIST"]),
401: None,
},
)
@map_exceptions(
{
GroupDoesNotExist: ERROR_GROUP_DOES_NOT_EXIST,
}
)
@transaction.atomic
def delete(self, request, group_id):
"""Deletes the specified group"""
group = CoreHandler().get_group(
group_id, base_queryset=Group.objects.select_for_update()
)
handler = GroupsAdminHandler()
handler.delete_group(request.user, group)
return Response(status=204)

View file

@ -0,0 +1,13 @@
from django.urls import path, include
from .users import urls as users_urls
from .groups import urls as groups_urls
from .dashboard import urls as dashboard_urls
app_name = "baserow_premium.api.admin"
urlpatterns = [
path("dashboard/", include(dashboard_urls, namespace="dashboard")),
path("users/", include(users_urls, namespace="users")),
path("groups/", include(groups_urls, namespace="groups")),
]

View file

@ -1,18 +1,6 @@
from rest_framework.status import HTTP_400_BAD_REQUEST
USER_ADMIN_INVALID_SORT_DIRECTION = (
"USER_ADMIN_INVALID_SORT_DIRECTION",
HTTP_400_BAD_REQUEST,
"Attributes to sort by must be prefixed with one of '-' or '+'.",
)
USER_ADMIN_INVALID_SORT_ATTRIBUTE = (
"USER_ADMIN_INVALID_SORT_ATTRIBUTE",
HTTP_400_BAD_REQUEST,
"Invalid attribute name provided to sort by.",
)
USER_ADMIN_CANNOT_DEACTIVATE_SELF = (
"USER_ADMIN_CANNOT_DEACTIVATE_SELF",
HTTP_400_BAD_REQUEST,

View file

@ -24,7 +24,7 @@ _USER_ADMIN_SERIALIZER_API_DOC_KWARGS = {
}
class AdminGroupUserSerializer(ModelSerializer):
class UserAdminGroupsSerializer(ModelSerializer):
id = serializers.IntegerField(source="group.id")
name = serializers.CharField(source="group.name")
@ -46,7 +46,7 @@ class UserAdminResponseSerializer(ModelSerializer):
# Max length set to match django user models first_name fields max length
name = CharField(source="first_name", max_length=30)
username = EmailField()
groups = AdminGroupUserSerializer(source="groupuser_set", many=True)
groups = UserAdminGroupsSerializer(source="groupuser_set", many=True)
class Meta:
model = User

View file

@ -0,0 +1,11 @@
from django.conf.urls import url
from baserow_premium.api.admin.users.views import UsersAdminView, UserAdminView
app_name = "baserow_premium.api.admin.users"
urlpatterns = [
url(r"^$", UsersAdminView.as_view(), name="list"),
url(r"^(?P<user_id>[0-9]+)/$", UserAdminView.as_view(), name="edit"),
]

View file

@ -0,0 +1,158 @@
from django.db import transaction
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
from baserow.api.decorators import validate_body, map_exceptions
from baserow.api.schemas import get_error_schema
from baserow_premium.api.admin.users.errors import (
USER_ADMIN_CANNOT_DEACTIVATE_SELF,
USER_ADMIN_CANNOT_DELETE_SELF,
USER_ADMIN_UNKNOWN_USER,
)
from baserow_premium.api.admin.users.serializers import (
UserAdminUpdateSerializer,
UserAdminResponseSerializer,
)
from baserow_premium.admin.users.exceptions import (
CannotDeactivateYourselfException,
CannotDeleteYourselfException,
UserDoesNotExistException,
)
from baserow_premium.admin.users.handler import UserAdminHandler
from django.contrib.auth import get_user_model
from baserow_premium.api.admin.views import AdminListingView
User = get_user_model()
class UsersAdminView(AdminListingView):
serializer_class = UserAdminResponseSerializer
search_fields = ["username"]
sort_field_mapping = {
"id": "id",
"is_active": "is_active",
"name": "first_name",
"username": "username",
"date_joined": "date_joined",
"last_login": "last_login",
}
def get_queryset(self, request):
return User.objects.prefetch_related(
"groupuser_set", "groupuser_set__group"
).all()
@extend_schema(
tags=["Admin"],
operation_id="admin_list_users",
description="Returns all users with detailed information on each user, "
"if the requesting user is staff. \n\nThis is a **premium** feature.",
**AdminListingView.get_extend_schema_parameters(
"users", serializer_class, search_fields, sort_field_mapping
),
)
def get(self, *args, **kwargs):
return super().get(*args, **kwargs)
class UserAdminView(APIView):
permission_classes = (IsAdminUser,)
@extend_schema(
tags=["Admin"],
request=UserAdminUpdateSerializer,
operation_id="admin_edit_user",
description=f"Updates specified user attributes and returns the updated user if"
f" the requesting user is staff. You cannot update yourself to no longer be an "
f"admin or active. \n\nThis is a **premium** feature.",
parameters=[
OpenApiParameter(
name="user_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The id of the user to edit",
),
],
responses={
200: UserAdminResponseSerializer(),
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
"USER_ADMIN_CANNOT_DEACTIVATE_SELF",
"USER_ADMIN_UNKNOWN_USER",
]
),
401: None,
},
)
@validate_body(UserAdminUpdateSerializer, partial=True)
@map_exceptions(
{
CannotDeactivateYourselfException: USER_ADMIN_CANNOT_DEACTIVATE_SELF,
UserDoesNotExistException: USER_ADMIN_UNKNOWN_USER,
}
)
@transaction.atomic
def patch(self, request, user_id, data):
"""
Updates the specified user with the supplied attributes. Will raise an exception
if you attempt un-staff or de-activate yourself.
"""
user_id = int(user_id)
handler = UserAdminHandler()
user = handler.update_user(request.user, user_id, **data)
return Response(UserAdminResponseSerializer(user).data)
@extend_schema(
tags=["Admin"],
operation_id="admin_delete_user",
description="Deletes the specified user, if the requesting user has admin "
"permissions. You cannot delete yourself. \n\nThis is a **premium** feature.",
parameters=[
OpenApiParameter(
name="user_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The id of the user to delete",
),
],
responses={
200: None,
400: get_error_schema(
[
"USER_ADMIN_CANNOT_DELETE_SELF",
"USER_ADMIN_UNKNOWN_USER",
]
),
401: None,
},
)
@map_exceptions(
{
CannotDeleteYourselfException: USER_ADMIN_CANNOT_DELETE_SELF,
UserDoesNotExistException: USER_ADMIN_UNKNOWN_USER,
}
)
@transaction.atomic
def delete(self, request, user_id):
"""
Deletes the specified user. Raises an exception if you attempt to delete
yourself.
"""
user_id = int(user_id)
handler = UserAdminHandler()
handler.delete_user(request.user, user_id)
return Response(status=204)

View file

@ -0,0 +1,203 @@
from django.db.models import Q
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from rest_framework.permissions import IsAdminUser
from rest_framework.views import APIView
from baserow.api.decorators import map_exceptions
from baserow.api.schemas import get_error_schema
from baserow.api.pagination import PageNumberPagination
from baserow_premium.api.admin.errors import (
ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE,
ERROR_ADMIN_LISTING_INVALID_SORT_DIRECTION,
)
from baserow_premium.admin.exceptions import (
InvalidSortDirectionException,
InvalidSortAttributeException,
)
class AdminListingView(APIView):
permission_classes = (IsAdminUser,)
serializer_class = None
search_fields = ["id"]
sort_field_mapping = {}
@map_exceptions(
{
InvalidSortDirectionException: ERROR_ADMIN_LISTING_INVALID_SORT_DIRECTION,
InvalidSortAttributeException: ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE,
}
)
def get(self, request):
"""
Responds with paginated results related to queryset and the serializer
defined on this class.
"""
search = request.GET.get("search")
sorts = request.GET.get("sorts")
queryset = self.get_queryset(request)
queryset = self._apply_sorts_or_default_sort(sorts, queryset)
queryset = self._apply_search(search, queryset)
paginator = PageNumberPagination(limit_page_size=100)
page = paginator.paginate_queryset(queryset, request, self)
serializer = self.get_serializer(request, page, many=True)
return paginator.get_paginated_response(serializer.data)
def get_queryset(self, request):
raise NotImplementedError("The get_queryset method must be set.")
def get_serializer(self, request, *args, **kwargs):
if not self.serializer_class:
raise NotImplementedError(
"Either the serializer_class must be set or the get_serializer method "
"must be overwritten."
)
return self.serializer_class(*args, **kwargs)
def _apply_search(self, search, queryset):
"""
Applies the provided search query to the provided query. If the search query
is provided then an `icontains` lookup will be done for each field in the
search_fields property. One of the fields has to match the query.
:param search: The search query.
:type search: str or None
:param queryset: The queryset where the search query must be applied to.
:type queryset: QuerySet
:return: The queryset filtering the results by the search query.
:rtype: QuerySet
"""
if not search:
return queryset
q = Q()
for search_field in self.search_fields:
q.add(Q(**{f"{search_field}__icontains": search}), Q.OR)
return queryset.filter(q)
def _apply_sorts_or_default_sort(self, sorts: str, queryset):
"""
Takes a comma separated string in the form of +attribute,-attribute2 and
applies them to a django queryset in order.
Defaults to sorting by id if no sorts are provided.
Raises an InvalidSortDirectionException if an attribute does not begin with `+`
or `-`.
Raises an InvalidSortAttributeException if an unknown attribute is supplied to
sort by or multiple of the same attribute are provided.
:param sorts: The list of sorts to apply to the queryset.
:param queryset: The queryset to sort.
:return: The sorted queryset.
"""
if sorts is None:
return queryset.order_by("id")
parsed_django_order_bys = []
already_seen_sorts = set()
for s in sorts.split(","):
if len(s) <= 2:
raise InvalidSortAttributeException()
sort_direction_prefix = s[0]
sort_field_name = s[1:]
try:
sort_direction_to_django_prefix = {"+": "", "-": "-"}
direction = sort_direction_to_django_prefix[sort_direction_prefix]
except KeyError:
raise InvalidSortDirectionException()
try:
attribute = self.sort_field_mapping[sort_field_name]
except KeyError:
raise InvalidSortAttributeException()
if attribute in already_seen_sorts:
raise InvalidSortAttributeException()
else:
already_seen_sorts.add(attribute)
parsed_django_order_bys.append(f"{direction}{attribute}")
return queryset.order_by(*parsed_django_order_bys)
@staticmethod
def get_extend_schema_parameters(
name, serializer_class, search_fields, sort_field_mapping
):
"""
Returns the schema properties that can be used in in the @extend_schema
decorator.
"""
fields = sort_field_mapping.keys()
all_fields = ", ".join(fields)
field_name_1 = "field_1"
field_name_2 = "field_2"
for i, field in enumerate(fields):
if i == 0:
field_name_1 = field
if i == 1:
field_name_2 = field
return {
"parameters": [
OpenApiParameter(
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=f"If provided only {name} that match the query will "
f"be returned.",
),
OpenApiParameter(
name="sorts",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description=f"A comma separated string of attributes to sort by, "
f"each attribute must be prefixed with `+` for a descending "
f"sort or a `-` for an ascending sort. The accepted attribute "
f"names are: {all_fields}. For example `sorts=-{field_name_1},"
f"-{field_name_2}` will sort the {name} first by descending "
f"{field_name_1} and then ascending {field_name_2}. A sort"
f"parameter with multiple instances of the same sort attribute "
f"will respond with the ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE "
f"error.",
),
OpenApiParameter(
name="page",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Defines which page should be returned.",
),
OpenApiParameter(
name="size",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description=f"Defines how many {name} should be returned per "
f"page.",
),
],
"responses": {
200: serializer_class(many=True),
400: get_error_schema(
[
"ERROR_PAGE_SIZE_LIMIT",
"ERROR_INVALID_PAGE",
"ERROR_ADMIN_LISTING_INVALID_SORT_DIRECTION",
"ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE",
]
),
401: None,
},
}

View file

@ -1,14 +1,9 @@
from django.urls import path, include
from .user_admin import urls as user_admin_urls
from .admin_dashboard import urls as admin_dashboard_urls
from .admin import urls as admin_urls
app_name = "baserow_premium.api"
urlpatterns = [
path("admin/user/", include(user_admin_urls, namespace="admin_user")),
path(
"admin/dashboard/", include(admin_dashboard_urls, namespace="admin_dashboard")
),
path("admin/", include(admin_urls, namespace="admin")),
]

View file

@ -1,11 +0,0 @@
from django.conf.urls import url
from baserow_premium.api.user_admin.views import UsersAdminView, UserAdminView
app_name = "baserow_premium.api.user_admin"
urlpatterns = [
url(r"^$", UsersAdminView.as_view(), name="users"),
url(r"^(?P<user_id>[0-9]+)/$", UserAdminView.as_view(), name="user_edit"),
]

View file

@ -1,209 +0,0 @@
from django.db import transaction
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.permissions import IsAdminUser
from rest_framework.response import Response
from rest_framework.views import APIView
from baserow.api.decorators import validate_body, map_exceptions
from baserow.api.pagination import PageNumberPagination
from baserow.api.schemas import get_error_schema
from baserow_premium.api.user_admin.errors import (
USER_ADMIN_INVALID_SORT_DIRECTION,
USER_ADMIN_INVALID_SORT_ATTRIBUTE,
USER_ADMIN_CANNOT_DEACTIVATE_SELF,
USER_ADMIN_CANNOT_DELETE_SELF,
USER_ADMIN_UNKNOWN_USER,
)
from baserow_premium.api.user_admin.serializers import (
UserAdminUpdateSerializer,
UserAdminResponseSerializer,
)
from baserow_premium.user_admin.exceptions import (
CannotDeactivateYourselfException,
CannotDeleteYourselfException,
UserDoesNotExistException,
InvalidSortDirectionException,
InvalidSortAttributeException,
)
from baserow_premium.user_admin.handler import (
UserAdminHandler,
allowed_user_admin_sort_field_names,
)
class UsersAdminView(APIView):
permission_classes = (IsAdminUser,)
_valid_sortable_fields = ",".join(allowed_user_admin_sort_field_names())
@extend_schema(
tags=["Users"],
operation_id="list_users",
description="Returns all baserow users with detailed information on each user, "
"if the requesting user has admin permissions.",
parameters=[
OpenApiParameter(
name="search",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="If provided only users with a username that matches the "
"search query will be returned.",
),
OpenApiParameter(
name="sorts",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
description="A comma separated string of user attributes to sort by, "
"each attribute must be prefixed with `+` for a descending "
"sort or a `-` for an ascending sort. The accepted attribute names "
f"are: {_valid_sortable_fields}. "
"For example `sorts=-username,+is_active` will sort the "
"results first by descending username and then ascending is_active."
"A sort parameter with multiple instances of the same "
"sort attribute will respond with the USER_ADMIN_INVALID_SORT_ATTRIBUTE"
"error.",
),
OpenApiParameter(
name="page",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Defines which page of users should be returned.",
),
OpenApiParameter(
name="size",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
description="Defines how many users should be returned per page.",
),
],
responses={
200: UserAdminResponseSerializer(many=True),
400: get_error_schema(
[
"ERROR_PAGE_SIZE_LIMIT",
"ERROR_INVALID_PAGE",
"USER_ADMIN_INVALID_SORT_DIRECTION",
"USER_ADMIN_INVALID_SORT_ATTRIBUTE",
]
),
401: None,
},
)
@map_exceptions(
{
InvalidSortDirectionException: USER_ADMIN_INVALID_SORT_DIRECTION,
InvalidSortAttributeException: USER_ADMIN_INVALID_SORT_ATTRIBUTE,
}
)
def get(self, request):
"""
Lists all the users of a user, optionally filtering on username by the
'search' get parameter, optionally sorting by the 'sorts' get parameter.
"""
search = request.GET.get("search")
sorts = request.GET.get("sorts")
handler = UserAdminHandler()
users = handler.get_users(request.user, search, sorts)
paginator = PageNumberPagination(limit_page_size=100)
page = paginator.paginate_queryset(users, request, self)
serializer = UserAdminResponseSerializer(page, many=True)
return paginator.get_paginated_response(serializer.data)
class UserAdminView(APIView):
permission_classes = (IsAdminUser,)
@extend_schema(
tags=["Users"],
request=UserAdminUpdateSerializer,
operation_id="edit_user",
description=f"Updates specified user attributes and returns the updated user if"
f" the requesting user has admin permissions. You cannot update yourself to no "
f"longer be an admin or active.",
parameters=[
OpenApiParameter(
name="user_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The id of the user to edit",
),
],
responses={
200: UserAdminResponseSerializer(),
400: get_error_schema(
[
"ERROR_REQUEST_BODY_VALIDATION",
"USER_ADMIN_CANNOT_DEACTIVATE_SELF",
"USER_ADMIN_UNKNOWN_USER",
]
),
401: None,
},
)
@validate_body(UserAdminUpdateSerializer, partial=True)
@map_exceptions(
{
CannotDeactivateYourselfException: USER_ADMIN_CANNOT_DEACTIVATE_SELF,
UserDoesNotExistException: USER_ADMIN_UNKNOWN_USER,
}
)
@transaction.atomic
def patch(self, request, user_id, data):
"""
Updates the specified user with the supplied attributes. Will raise an exception
if you attempt un-staff or de-activate yourself.
"""
user_id = int(user_id)
handler = UserAdminHandler()
user = handler.update_user(request.user, user_id, **data)
return Response(UserAdminResponseSerializer(user).data)
@extend_schema(
tags=["Users"],
operation_id="delete_user",
description="Deletes the specified user, if the requesting user has admin "
"permissions. You cannot delete yourself.",
parameters=[
OpenApiParameter(
name="user_id",
location=OpenApiParameter.PATH,
type=OpenApiTypes.INT,
description="The id of the user to delete",
),
],
responses={
200: None,
400: get_error_schema(
[
"USER_ADMIN_CANNOT_DELETE_SELF",
"USER_ADMIN_UNKNOWN_USER",
]
),
401: None,
},
)
@map_exceptions(
{
CannotDeleteYourselfException: USER_ADMIN_CANNOT_DELETE_SELF,
UserDoesNotExistException: USER_ADMIN_UNKNOWN_USER,
}
)
def delete(self, request, user_id):
"""
Deletes the specified user. Raises an exception if you attempt to delete
yourself.
"""
user_id = int(user_id)
handler = UserAdminHandler()
handler.delete_user(request.user, user_id)
return Response()

View file

@ -1,213 +0,0 @@
from typing import Optional
from django.contrib.auth import get_user_model
from baserow.core.exceptions import IsNotAdminError
from baserow_premium.user_admin.exceptions import (
CannotDeactivateYourselfException,
CannotDeleteYourselfException,
UserDoesNotExistException,
InvalidSortDirectionException,
InvalidSortAttributeException,
)
User = get_user_model()
def sort_field_names_to_user_attributes():
return {
"id": "id",
"is_active": "is_active",
"name": "first_name",
"username": "username",
"date_joined": "date_joined",
"last_login": "last_login",
}
def allowed_user_admin_sort_field_names():
return sort_field_names_to_user_attributes().keys()
class UserAdminHandler:
def get_users(
self,
requesting_user: User,
username_search: Optional[str] = None,
sorts: Optional[str] = None,
):
"""
Looks up all users, performs an optional username search and then sorts the
resulting user queryset and returns it. By default if no sorts are provided
sorts by user id ascending.
:param requesting_user: The user who is making the request to get_users, the
user must be a staff member or else an exception will
be raised.
:param username_search: An optional icontains username search to filter the
returned users by.
:param sorts: A comma separated string like `+username,-id` to be applied as
an ordering order over the returned users. Prefix the attribute with +
for an ascending sort, - for descending.
See `allowed_user_admin_sort_field_names` for the allowed attributes to
sort by.
Raises InvalidSortAttributeException or InvalidSortAttributeException if
an invalid sort string is provided.
:return: A queryset of users in Baserow, optionally sorted and ordered by the
specified parameters.
"""
self._raise_if_not_permitted(requesting_user)
users = User.objects.prefetch_related(
"groupuser_set", "groupuser_set__group"
).all()
if username_search is not None:
users = users.filter(username__icontains=username_search)
users = self._apply_sorts_or_default_sort(sorts, users)
return users
@staticmethod
def _apply_sorts_or_default_sort(sorts: str, queryset):
"""
Takes a comma separated string in the form of +attribute,-attribute2 and
applies them to a django queryset in order.
Defaults to sorting by id if no sorts are provided.
Raises an InvalidSortDirectionException if an attribute does not begin with `+`
or `-`.
Raises an InvalidSortAttributeException if an unknown attribute is supplied to
sort by or multiple of the same attribute are provided.
:param sorts: The list of sorts to apply to the queryset.
:param queryset: The queryset to sort.
:return: The sorted queryset.
"""
if sorts is None:
return queryset.order_by("id")
parsed_django_order_bys = []
already_seen_sorts = set()
for s in sorts.split(","):
if len(s) <= 2:
raise InvalidSortAttributeException()
sort_direction_prefix = s[0]
sort_field_name = s[1:]
try:
sort_direction_to_django_prefix = {"+": "", "-": "-"}
direction = sort_direction_to_django_prefix[sort_direction_prefix]
except KeyError:
raise InvalidSortDirectionException()
try:
attribute = sort_field_names_to_user_attributes()[sort_field_name]
except KeyError:
raise InvalidSortAttributeException()
if attribute in already_seen_sorts:
raise InvalidSortAttributeException()
else:
already_seen_sorts.add(attribute)
parsed_django_order_bys.append(f"{direction}{attribute}")
return queryset.order_by(*parsed_django_order_bys)
def update_user(
self,
requesting_user: User,
user_id: int,
username: Optional[str] = None,
name: Optional[str] = None,
password: Optional[str] = None,
is_active: Optional[bool] = None,
is_staff: Optional[bool] = None,
):
"""
Updates a specified user with new attribute values. Will raise an exception
if a user attempts to de-activate or un-staff themselves.
:param requesting_user: The user who is making the request to update a user, the
user must be a staff member or else an exception will
be raised.
:param user_id: The id of the user to update, if they do not exist raises a
UserDoesNotExistException.
:param is_staff: Optional value used to set if the user is an admin or not.
:param is_active: Optional value to disable or enable login for the user.
:param password: Optional new password to securely set for the user.
:param name: Optional new name to set on the user.
:param username: Optional new username/email to set for the user.
"""
self._raise_if_not_permitted(requesting_user)
self._raise_if_locking_self_out_of_admin(
is_active, is_staff, requesting_user, user_id
)
try:
user = User.objects.select_for_update().get(id=user_id)
except User.DoesNotExist:
raise UserDoesNotExistException()
if is_staff is not None:
user.is_staff = is_staff
if is_active is not None:
user.is_active = is_active
if password is not None:
user.set_password(password)
if name is not None:
user.first_name = name
if username is not None:
user.email = username
user.username = username
user.save()
return user
@staticmethod
def _raise_if_locking_self_out_of_admin(
is_active, is_staff, requesting_user, user_id
):
"""
Raises an exception if the requesting_user is about to lock themselves out of
the admin area of Baserow by either turning off their staff status or disabling
their account.
"""
is_setting_staff_to_false = is_staff is not None and not is_staff
is_setting_active_to_false = is_active is not None and not is_active
if user_id == requesting_user.id and (
is_setting_staff_to_false or is_setting_active_to_false
):
raise CannotDeactivateYourselfException()
def delete_user(self, requesting_user: User, user_id: int):
"""
Deletes a specified user, raises an exception if you attempt to delete yourself.
:param requesting_user: The user who is making the delete request , the
user must be a staff member or else an exception will
be raised.
:param user_id: The id of the user to update, if they do not exist raises a
UnknownUserException.
"""
self._raise_if_not_permitted(requesting_user)
if requesting_user.id == user_id:
raise CannotDeleteYourselfException()
try:
user = User.objects.get(id=user_id)
user.delete()
except User.DoesNotExist:
raise UserDoesNotExistException()
@staticmethod
def _raise_if_not_permitted(requesting_user):
if not requesting_user.is_staff:
raise IsNotAdminError()

View file

@ -5,7 +5,7 @@ from datetime import timedelta, datetime, date
from baserow.core.models import UserLogEntry
from baserow_premium.admin_dashboard.handler import AdminDashboardHandler
from baserow_premium.admin.dashboard.handler import AdminDashboardHandler
@pytest.mark.django_db

View file

@ -0,0 +1,42 @@
import pytest
from unittest.mock import patch
from django.db import connection
from baserow.contrib.database.models import Database, Table
from baserow.core.exceptions import IsNotAdminError
from baserow.core.models import (
Group,
GroupUser,
)
from baserow_premium.admin.groups.handler import GroupsAdminHandler
@pytest.mark.django_db
@patch("baserow.core.signals.group_deleted.send")
def test_delete_group(send_mock, data_fixture):
staff_user = data_fixture.create_user(is_staff=True)
normal_user = data_fixture.create_user(is_staff=False)
other_user = data_fixture.create_user()
group_1 = data_fixture.create_group(user=other_user)
database = data_fixture.create_database_application(group=group_1)
table = data_fixture.create_database_table(database=database)
handler = GroupsAdminHandler()
with pytest.raises(IsNotAdminError):
handler.delete_group(normal_user, group_1)
handler.delete_group(staff_user, group_1)
send_mock.assert_called_once()
assert send_mock.call_args[1]["group"].id == group_1.id
assert "user" not in send_mock.call_args[1]
assert len(send_mock.call_args[1]["group_users"]) == 1
assert send_mock.call_args[1]["group_users"][0].id == other_user.id
assert Database.objects.all().count() == 0
assert Table.objects.all().count() == 0
assert f"database_table_{table.id}" not in connection.introspection.table_names()
assert Group.objects.all().count() == 0
assert GroupUser.objects.all().count() == 0

View file

@ -1,104 +1,19 @@
import pytest
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.datetime_safe import datetime
from baserow.core.exceptions import IsNotAdminError
from baserow_premium.user_admin.exceptions import (
from baserow_premium.admin.users.exceptions import (
CannotDeactivateYourselfException,
CannotDeleteYourselfException,
UserDoesNotExistException,
InvalidSortDirectionException,
InvalidSortAttributeException,
)
from baserow_premium.user_admin.handler import (
from baserow_premium.admin.users.handler import (
UserAdminHandler,
)
User = get_user_model()
@pytest.mark.django_db
def test_admin_can_get_users(data_fixture):
handler = UserAdminHandler()
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
assert handler.get_users(admin_user).count() == 1
@pytest.mark.django_db
def test_non_admin_cant_get_users(data_fixture):
handler = UserAdminHandler()
non_admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=False,
)
with pytest.raises(IsNotAdminError):
handler.get_users(non_admin_user)
@pytest.mark.django_db
def test_admin_can_search_by_username(data_fixture):
handler = UserAdminHandler()
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
data_fixture.create_user(
email="other_user@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
results = handler.get_users(admin_user, username_search="other_user")
assert results.count() == 1
assert results[0].username == "other_user@test.nl"
@pytest.mark.django_db
def test_admin_can_sort_by_multiple_fields_in_specified_order_and_directions(
data_fixture,
):
handler = UserAdminHandler()
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
is_active=False,
date_joined=datetime(2020, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
data_fixture.create_user(
email="other_user1@test.nl",
password="password",
first_name="Test2",
is_staff=True,
is_active=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
data_fixture.create_user(
email="other_user2@test.nl",
password="password",
first_name="Test3",
is_staff=True,
is_active=True,
date_joined=datetime(2022, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
results = handler.get_users(admin_user, sorts="-is_active,+date_joined")
assert results.count() == 3
assert results[0].first_name == "Test2"
assert results[1].first_name == "Test3"
assert results[2].first_name == "Test1"
@pytest.mark.django_db
def test_admin_can_delete_user(data_fixture):
handler = UserAdminHandler()
@ -334,83 +249,3 @@ def test_raises_exception_when_updating_an_unknown_user(data_fixture):
)
with pytest.raises(UserDoesNotExistException):
handler.update_user(admin_user, 99999, username="new_password")
@pytest.mark.django_db
def test_throws_error_if_sort_direction_not_provided(api_client, data_fixture):
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
handler = UserAdminHandler()
with pytest.raises(InvalidSortDirectionException):
handler.get_users(admin_user, sorts="username")
@pytest.mark.django_db
def test_throws_error_if_invalid_sort_direction_provided(api_client, data_fixture):
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
handler = UserAdminHandler()
with pytest.raises(InvalidSortDirectionException):
handler.get_users(admin_user, sorts="*username")
@pytest.mark.django_db
def test_throws_error_if_invalid_sorts_mixed_with_valid_ones(api_client, data_fixture):
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
handler = UserAdminHandler()
with pytest.raises(InvalidSortAttributeException):
handler.get_users(admin_user, sorts="+username,-idd")
@pytest.mark.django_db
def test_throws_error_if_multiple_of_the_same_sort_attr_are_given(
api_client, data_fixture
):
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
handler = UserAdminHandler()
with pytest.raises(InvalidSortAttributeException):
handler.get_users(admin_user, sorts="+username,-id,-username")
@pytest.mark.django_db
def test_throws_error_if_blank_sorts_provided(api_client, data_fixture):
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
handler = UserAdminHandler()
with pytest.raises(InvalidSortAttributeException):
handler.get_users(admin_user, sorts=",,")
@pytest.mark.django_db
def test_throws_error_if_empty_sorts_provided(api_client, data_fixture):
admin_user = data_fixture.create_user(
email="test@test.nl",
password="password",
first_name="Test1",
is_staff=True,
)
handler = UserAdminHandler()
with pytest.raises(InvalidSortAttributeException):
handler.get_users(admin_user, sorts="")

View file

@ -25,14 +25,14 @@ def test_admin_dashboard(api_client, data_fixture):
UserLogEntry.objects.create(actor=admin_user, action="SIGNED_IN")
response = api_client.get(
reverse("api:premium:admin_dashboard:dashboard"),
reverse("api:premium:admin:dashboard:dashboard"),
format="json",
HTTP_AUTHORIZATION=f"JWT {normal_token}",
)
assert response.status_code == HTTP_403_FORBIDDEN
response = api_client.get(
reverse("api:premium:admin_dashboard:dashboard"),
reverse("api:premium:admin:dashboard:dashboard"),
format="json",
HTTP_AUTHORIZATION=f"JWT {admin_token}",
)
@ -57,7 +57,7 @@ def test_admin_dashboard(api_client, data_fixture):
"active_users_per_day": [{"date": "2020-01-01", "count": 1}],
}
url = reverse("api:premium:admin_dashboard:dashboard")
url = reverse("api:premium:admin:dashboard:dashboard")
response = api_client.get(
f"{url}?timezone=Etc/GMT%2B1",
format="json",

View file

@ -0,0 +1,177 @@
import pytest
from django.utils.timezone import make_aware, datetime, utc
from django.shortcuts import reverse
from rest_framework.status import (
HTTP_200_OK,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
from baserow.core.models import Group
@pytest.mark.django_db
def test_list_admin_groups(api_client, data_fixture, django_assert_num_queries):
"""
This endpoint doesn't need to be tested extensively because it uses the same base
class as the list users endpoint which already has extensive tests. We only need to
test if the functionality works in a basic form.
"""
created = make_aware(datetime(2020, 4, 10, 0, 0, 0), utc)
staff_user, staff_token = data_fixture.create_user_and_token(is_staff=True)
normal_user, normal_token = data_fixture.create_user_and_token()
group_1 = data_fixture.create_group(name="A")
group_1.created_on = created
group_1.save()
group_2 = data_fixture.create_group(name="B", created_on=created)
group_2.created_on = created
group_2.save()
data_fixture.create_user_group(
group=group_1, user=normal_user, permissions="MEMBER"
)
data_fixture.create_user_group(group=group_2, user=normal_user, permissions="ADMIN")
data_fixture.create_database_application(group=group_1)
data_fixture.create_database_application(group=group_1)
response = api_client.get(
reverse("api:premium:admin:groups:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {normal_token}",
)
assert response.status_code == HTTP_403_FORBIDDEN
with django_assert_num_queries(5):
response = api_client.get(
reverse("api:premium:admin:groups:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {staff_token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": group_1.id,
"name": group_1.name,
"users": [
{
"id": normal_user.id,
"email": normal_user.email,
"permissions": "MEMBER",
}
],
"application_count": 2,
"created_on": "2020-04-10T00:00:00Z",
},
{
"id": group_2.id,
"name": group_2.name,
"users": [
{
"id": normal_user.id,
"email": normal_user.email,
"permissions": "ADMIN",
}
],
"application_count": 0,
"created_on": "2020-04-10T00:00:00Z",
},
],
}
response = api_client.get(
f'{reverse("api:premium:admin:groups:list")}?search={group_1.name}',
format="json",
HTTP_AUTHORIZATION=f"JWT {staff_token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json == {
"count": 1,
"next": None,
"previous": None,
"results": [
{
"id": group_1.id,
"name": group_1.name,
"users": [
{
"id": normal_user.id,
"email": normal_user.email,
"permissions": "MEMBER",
}
],
"application_count": 2,
"created_on": "2020-04-10T00:00:00Z",
},
],
}
response = api_client.get(
f'{reverse("api:premium:admin:groups:list")}?sorts=-name',
format="json",
HTTP_AUTHORIZATION=f"JWT {staff_token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json == {
"count": 2,
"next": None,
"previous": None,
"results": [
{
"id": group_2.id,
"name": group_2.name,
"users": [
{
"id": normal_user.id,
"email": normal_user.email,
"permissions": "ADMIN",
}
],
"application_count": 0,
"created_on": "2020-04-10T00:00:00Z",
},
{
"id": group_1.id,
"name": group_1.name,
"users": [
{
"id": normal_user.id,
"email": normal_user.email,
"permissions": "MEMBER",
}
],
"application_count": 2,
"created_on": "2020-04-10T00:00:00Z",
},
],
}
@pytest.mark.django_db
def test_delete_group(api_client, data_fixture):
normal_user, normal_token = data_fixture.create_user_and_token()
staff_user, staff_token = data_fixture.create_user_and_token(is_staff=True)
group = data_fixture.create_group()
url = reverse("api:premium:admin:groups:edit", kwargs={"group_id": 99999})
response = api_client.delete(url, HTTP_AUTHORIZATION=f"JWT {staff_token}")
assert response.status_code == HTTP_404_NOT_FOUND
assert response.json()["error"] == "ERROR_GROUP_DOES_NOT_EXIST"
url = reverse("api:premium:admin:groups:edit", kwargs={"group_id": group.id})
response = api_client.delete(url, HTTP_AUTHORIZATION=f"JWT {normal_token}")
assert response.status_code == HTTP_403_FORBIDDEN
url = reverse("api:premium:admin:groups:edit", kwargs={"group_id": group.id})
response = api_client.delete(url, HTTP_AUTHORIZATION=f"JWT {staff_token}")
assert response.status_code == 204
assert Group.objects.all().count() == 0

View file

@ -6,6 +6,7 @@ from django.utils import timezone
from django.utils.datetime_safe import datetime
from rest_framework.status import (
HTTP_200_OK,
HTTP_204_NO_CONTENT,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
@ -23,7 +24,7 @@ def test_non_admin_cannot_see_admin_users_endpoint(api_client, data_fixture):
email="test@test.nl", password="password", first_name="Test1", is_staff=False
)
response = api_client.get(
reverse("api:premium:admin_user:users"),
reverse("api:premium:admin:users:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -52,7 +53,7 @@ def test_admin_can_see_admin_users_endpoint(api_client, data_fixture):
permissions=GROUP_USER_PERMISSION_MEMBER,
)
response = api_client.get(
reverse("api:premium:admin_user:users"),
reverse("api:premium:admin:users:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -96,7 +97,7 @@ def test_admin_with_invalid_token_cannot_see_admin_users(api_client, data_fixtur
is_staff=True,
)
response = api_client.get(
reverse("api:premium:admin_user:users"),
reverse("api:premium:admin:users:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT abc123",
)
@ -114,7 +115,7 @@ def test_admin_accessing_invalid_user_admin_page_returns_error(
first_name="Test1",
is_staff=True,
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=2",
format="json",
@ -134,7 +135,7 @@ def test_admin_accessing_user_admin_with_invalid_page_size_returns_error(
first_name="Test1",
is_staff=True,
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&size=201",
format="json",
@ -158,7 +159,7 @@ def test_admin_can_search_users(api_client, data_fixture):
first_name="Test1",
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&search=specific_user",
format="json",
@ -198,7 +199,7 @@ def test_admin_can_sort_users(api_client, data_fixture):
first_name="Test1",
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&search=specific_user",
format="json",
@ -234,14 +235,14 @@ def test_returns_error_response_if_invalid_sort_field_provided(
first_name="Test1",
is_staff=True,
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&sorts=-invalid_field_name",
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "USER_ADMIN_INVALID_SORT_ATTRIBUTE"
assert response.json()["error"] == "ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE"
@pytest.mark.django_db
@ -254,14 +255,14 @@ def test_returns_error_response_if_sort_direction_not_provided(
first_name="Test1",
is_staff=True,
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&sorts=username",
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "USER_ADMIN_INVALID_SORT_DIRECTION"
assert response.json()["error"] == "ERROR_ADMIN_LISTING_INVALID_SORT_DIRECTION"
@pytest.mark.django_db
@ -274,14 +275,14 @@ def test_returns_error_response_if_invalid_sort_direction_provided(
first_name="Test1",
is_staff=True,
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&sorts=*username",
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "USER_ADMIN_INVALID_SORT_DIRECTION"
assert response.json()["error"] == "ERROR_ADMIN_LISTING_INVALID_SORT_DIRECTION"
@pytest.mark.django_db
@ -294,14 +295,14 @@ def test_returns_error_response_if_invalid_sorts_mixed_with_valid_ones(
first_name="Test1",
is_staff=True,
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&sorts=+username,username,-invalid_field",
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "USER_ADMIN_INVALID_SORT_DIRECTION"
assert response.json()["error"] == "ERROR_ADMIN_LISTING_INVALID_SORT_DIRECTION"
@pytest.mark.django_db
@ -312,14 +313,14 @@ def test_returns_error_response_if_blank_sorts_provided(api_client, data_fixture
first_name="Test1",
is_staff=True,
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&sorts=,,",
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "USER_ADMIN_INVALID_SORT_ATTRIBUTE"
assert response.json()["error"] == "ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE"
@pytest.mark.django_db
@ -330,14 +331,14 @@ def test_returns_error_response_if_no_sorts_provided(api_client, data_fixture):
first_name="Test1",
is_staff=True,
)
url = reverse("api:premium:admin_user:users")
url = reverse("api:premium:admin:users:list")
response = api_client.get(
f"{url}?page=1&sorts=",
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_400_BAD_REQUEST
assert response.json()["error"] == "USER_ADMIN_INVALID_SORT_ATTRIBUTE"
assert response.json()["error"] == "ERROR_ADMIN_LISTING_INVALID_SORT_ATTRIBUTE"
@pytest.mark.django_db
@ -353,9 +354,7 @@ def test_non_admin_cannot_delete_user(api_client, data_fixture):
password="password",
first_name="Test1",
)
url = reverse(
"api:premium:admin_user:user_edit", kwargs={"user_id": user_to_delete.id}
)
url = reverse("api:premium:admin:users:edit", kwargs={"user_id": user_to_delete.id})
response = api_client.delete(
url,
format="json",
@ -377,18 +376,16 @@ def test_admin_can_delete_user(api_client, data_fixture):
password="password",
first_name="Test1",
)
url = reverse(
"api:premium:admin_user:user_edit", kwargs={"user_id": user_to_delete.id}
)
url = reverse("api:premium:admin:users:edit", kwargs={"user_id": user_to_delete.id})
response = api_client.delete(
url,
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
assert response.status_code == HTTP_200_OK
assert response.status_code == HTTP_204_NO_CONTENT
response = api_client.get(
reverse("api:premium:admin_user:users"),
reverse("api:premium:admin:users:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -404,9 +401,7 @@ def test_non_admin_cannot_patch_user(api_client, data_fixture):
first_name="Test1",
is_staff=False,
)
url = reverse(
"api:premium:admin_user:user_edit", kwargs={"user_id": non_admin_user.id}
)
url = reverse("api:premium:admin:users:edit", kwargs={"user_id": non_admin_user.id})
response = api_client.patch(
url,
{"username": "some_other_email@test.nl"},
@ -428,7 +423,7 @@ def test_admin_can_patch_user(api_client, data_fixture):
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
url = reverse("api:premium:admin_user:user_edit", kwargs={"user_id": user.id})
url = reverse("api:premium:admin:users:edit", kwargs={"user_id": user.id})
old_password = user.password
response = api_client.patch(
url,
@ -460,7 +455,7 @@ def test_error_returned_when_invalid_field_supplied_to_edit(api_client, data_fix
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
url = reverse("api:premium:admin_user:user_edit", kwargs={"user_id": user.id})
url = reverse("api:premium:admin:users:edit", kwargs={"user_id": user.id})
# We have to provide a str as otherwise the test api client will "helpfully" try
# to serialize the dict using the endpoints serializer, which will fail before
@ -484,7 +479,7 @@ def test_error_returned_when_updating_user_with_invalid_email(api_client, data_f
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
url = reverse("api:premium:admin_user:user_edit", kwargs={"user_id": user.id})
url = reverse("api:premium:admin:users:edit", kwargs={"user_id": user.id})
# We have to provide a str as otherwise the test api client will "helpfully" try
# to serialize the dict using the endpoints serializer, which will fail before
@ -510,7 +505,7 @@ def test_error_returned_when_valid_and_invalid_fields_supplied_to_edit(
is_staff=True,
date_joined=datetime(2021, 4, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
)
url = reverse("api:premium:admin_user:user_edit", kwargs={"user_id": user.id})
url = reverse("api:premium:admin:users:edit", kwargs={"user_id": user.id})
# We have to provide a str as otherwise the test api client will "helpfully" try
# to serialize the dict using the endpoints serializer, which will fail before
@ -542,7 +537,7 @@ def test_admin_getting_view_users_only_runs_two_queries_instead_of_n(
with django_assert_num_queries(fixed_num_of_queries_unrelated_to_number_of_rows):
response = api_client.get(
reverse("api:premium:admin_user:users"),
reverse("api:premium:admin:users:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -555,7 +550,7 @@ def test_admin_getting_view_users_only_runs_two_queries_instead_of_n(
with django_assert_num_queries(fixed_num_of_queries_unrelated_to_number_of_rows):
response = api_client.get(
reverse("api:premium:admin_user:users"),
reverse("api:premium:admin:users:list"),
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)

View file

@ -18,7 +18,7 @@ export class DashboardType extends AdminType {
}
getOrder() {
return 0
return 1
}
}
@ -40,6 +40,28 @@ export class UsersAdminType extends AdminType {
}
getOrder() {
return 1
return 2
}
}
export class GroupsAdminType extends AdminType {
static getType() {
return 'groups'
}
getIconClass() {
return 'layer-group'
}
getName() {
return 'Groups'
}
getRouteName() {
return 'admin-groups'
}
getOrder() {
return 3
}
}

View file

@ -1,3 +1,4 @@
@import 'user_admin';
@import 'crud_table';
@import 'admin_dashboard';
@import 'user_admin';
@import 'group_admin';
@import 'crud_table';

View file

@ -150,7 +150,6 @@
content: '';
z-index: 4;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 6px;
@include absolute(0, 0, 0, 0);
}

View file

@ -0,0 +1,25 @@
.group-admin-name {
display: flex;
align-items: center;
justify-content: flex-start;
}
.group-admin-name__name {
@extend %ellipsis;
word-wrap: break-word;
}
.group-admin-name__menu {
@include fixed-height(24px, 14px);
border-radius: 3px;
color: $color-primary-900;
padding: 0 5px;
margin-left: auto;
&:hover {
text-decoration: none;
background-color: $color-neutral-100;
}
}

View file

@ -121,6 +121,7 @@
box-sizing: border-box;
margin-top: 10px;
margin-bottom: 9px;
user-select: none;
&:hover {
text-decoration: none;

View file

@ -0,0 +1,119 @@
<template>
<CrudTable
:left-columns="leftColumns"
:right-columns="rightColumns"
:service="service"
row-id-key="id"
@edit-group="displayEditGroupContext"
@show-hidden-groups="displayHiddenUsers"
@row-context="onRowContext"
>
<template #header>
<div class="crudtable__header-title">All groups</div>
</template>
<template #menus="slotProps">
<EditGroupContext
ref="editGroupContext"
:group="editGroup"
@group-deleted="slotProps.deleteRow"
>
</EditGroupContext>
<HiddenUsersContext
ref="hiddenUsersContext"
:hidden-values="hiddenUsers"
></HiddenUsersContext>
</template>
</CrudTable>
</template>
<script>
import GroupsAdminService from '@baserow_premium/services/admin/groups'
import CrudTable from '@baserow_premium/components/crud_table/CrudTable'
import SimpleField from '@baserow_premium/components/crud_table/fields/SimpleField'
import LocalDateField from '@baserow_premium/components/crud_table/fields/LocalDateField'
import GroupUsersField from '@baserow_premium/components/admin/groups/fields/GroupUsersField'
import GroupNameField from '@baserow_premium/components/admin/groups/fields/GroupNameField'
import EditGroupContext from '@baserow_premium/components/admin/groups/contexts/EditGroupContext'
import HiddenUsersContext from '@baserow_premium/components/admin/groups/contexts/HiddenUsersContext'
import CrudTableColumn from '@baserow_premium/crud_table/crudTableColumn'
export default {
name: 'GroupsAdminTable',
components: {
CrudTable,
HiddenUsersContext,
EditGroupContext,
},
data() {
this.leftColumns = [
new CrudTableColumn(
'id',
'ID',
SimpleField,
'min-content',
'max-content',
true
),
new CrudTableColumn(
'name',
'Name',
GroupNameField,
'200px',
'max-content',
true
),
]
this.rightColumns = [
new CrudTableColumn(
'users',
'Members',
GroupUsersField,
'100px',
'500px'
),
new CrudTableColumn(
'application_count',
'Applications',
SimpleField,
'min-content',
'max-content',
true
),
new CrudTableColumn(
'created_on',
'Created',
LocalDateField,
'min-content',
'200px',
true
),
]
this.service = GroupsAdminService(this.$client)
return {
editGroup: {},
hiddenUsers: [],
}
},
methods: {
displayEditGroupContext(event) {
const action = event.group.id === this.editGroup.id ? 'toggle' : 'show'
this.editGroup = event.group
this.$refs.editGroupContext[action](event.target, 'bottom', 'left', 4)
},
onRowContext({ row, event }) {
this.displayEditGroupContext({
group: row,
target: {
left: event.clientX,
top: event.clientY,
},
})
},
displayHiddenUsers(event) {
const action = this.hiddenUsers === event.hiddenValues ? 'toggle' : 'show'
this.hiddenUsers = event.hiddenValues
this.$refs.hiddenUsersContext[action](event.target, 'bottom', 'left', 4)
},
},
}
</script>

View file

@ -0,0 +1,42 @@
<template>
<Context>
<template v-if="Object.keys(group).length > 0">
<ul class="context__menu">
<li>
<a @click.prevent="showDeleteModal">
<i class="context__menu-icon fas fa-fw fa-trash-alt"></i>
Permanently delete
</a>
</li>
</ul>
<DeleteGroupModal
ref="deleteGroupModal"
:group="group"
@group-deleted="$emit('group-deleted', $event)"
></DeleteGroupModal>
</template>
</Context>
</template>
<script>
import context from '@baserow/modules/core/mixins/context'
import DeleteGroupModal from '@baserow_premium/components/admin/groups/modals/DeleteGroupModal'
export default {
name: 'EditUserContext',
components: { DeleteGroupModal },
mixins: [context],
props: {
group: {
required: true,
type: Object,
},
},
methods: {
showDeleteModal() {
this.hide()
this.$refs.deleteGroupModal.show()
},
},
}
</script>

View file

@ -0,0 +1,13 @@
<script>
import HiddenGroupsContext from '@baserow_premium/components/admin/users/contexts/HiddenGroupsContext'
export default {
name: 'HiddenUsersContext',
extends: HiddenGroupsContext,
data() {
return {
nameKey: 'email',
}
},
}
</script>

View file

@ -0,0 +1,36 @@
<template functional>
<div class="group-admin-name" :class="[data.staticClass, data.class]">
<div class="group-admin-name__name" :title="props.row[props.column.key]">
{{ props.row[props.column.key] }}
</div>
<a
class="group-admin-name__menu"
@click.prevent="
listeners['edit-group'] &&
listeners['edit-group']({
group: props.row,
target: $event.currentTarget,
})
"
>
<i class="fas fa-ellipsis-h"></i>
</a>
</div>
</template>
<script>
export default {
name: 'GroupNameField',
functional: true,
props: {
row: {
required: true,
type: Object,
},
column: {
required: true,
type: Object,
},
},
}
</script>

View file

@ -0,0 +1,14 @@
<script>
import UserGroupsField from '@baserow_premium/components/admin/users/fields/UserGroupsField'
export default {
name: 'GroupUsersField',
extends: UserGroupsField,
data() {
return {
eventName: 'show-hidden-groups',
nameKey: 'email',
}
},
}
</script>

View file

@ -0,0 +1,68 @@
<template>
<Modal>
<h2 class="box__title">Delete {{ group.name }}</h2>
<Error :error="error"></Error>
<div>
<p>
Are you sure you want to delete the group:
<strong>{{ group.name }}</strong
>?
</p>
<p>
The group will be permanently deleted, including the related
applications. It is not possible to undo this action.
</p>
<div class="actions">
<div class="align-right">
<a
class="button button--large button--error button--overflow"
:class="{ 'button--loading': loading }"
:disabled="loading"
:title="group.name"
@click.prevent="deleteGroup()"
>
Delete group {{ group.name }}
</a>
</div>
</div>
</div>
</Modal>
</template>
<script>
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import GroupsAdminService from '@baserow_premium/services/admin/groups'
export default {
name: 'DeleteGroupModal',
mixins: [modal, error],
props: {
group: {
type: Object,
required: true,
},
},
data() {
return {
loading: false,
}
},
methods: {
async deleteGroup() {
this.hideError()
this.loading = true
try {
await GroupsAdminService(this.$client).delete(this.group.id)
this.$emit('group-deleted', this.group.id)
this.hide()
} catch (error) {
this.handleError(error, 'group')
}
this.loading = false
},
},
}
</script>

View file

@ -5,11 +5,11 @@
:service="service"
row-id-key="id"
@edit-user="displayEditUserContext"
@show-group="displayHiddenGroups"
@show-hidden-groups="displayHiddenGroups"
@row-context="onRowContext"
>
<template #header>
<div class="crudtable__header-title">User Settings</div>
<div class="crudtable__header-title">All users</div>
</template>
<template #menus="slotProps">
<EditUserContext
@ -21,26 +21,26 @@
</EditUserContext>
<HiddenGroupsContext
ref="hiddenGroupsContext"
:hidden-groups="hiddenGroups"
:hidden-values="hiddenGroups"
></HiddenGroupsContext>
</template>
</CrudTable>
</template>
<script>
import UserAdminService from '@baserow_premium/services/userAdmin'
import UsernameField from '@baserow_premium/components/admin/user/fields/UsernameField'
import UserGroupsField from '@baserow_premium/components/admin/user/fields/UserGroupsField'
import UserAdminService from '@baserow_premium/services/admin/users'
import UsernameField from '@baserow_premium/components/admin/users/fields/UsernameField'
import UserGroupsField from '@baserow_premium/components/admin/users/fields/UserGroupsField'
import CrudTable from '@baserow_premium/components/crud_table/CrudTable'
import SimpleField from '@baserow_premium/components/crud_table/fields/SimpleField'
import LocalDateField from '@baserow_premium/components/crud_table/fields/LocalDateField'
import ActiveField from '@baserow_premium/components/admin/user/fields/ActiveField'
import EditUserContext from '@baserow_premium/components/admin/user/contexts/EditUserContext'
import HiddenGroupsContext from '@baserow_premium/components/admin/user/contexts/HiddenGroupsContext'
import CrudTableColumn from '@baserow_premium/crud_table/CrudTableColumn'
import ActiveField from '@baserow_premium/components/admin/users/fields/ActiveField'
import EditUserContext from '@baserow_premium/components/admin/users/contexts/EditUserContext'
import HiddenGroupsContext from '@baserow_premium/components/admin/users/contexts/HiddenGroupsContext'
import CrudTableColumn from '@baserow_premium/crud_table/crudTableColumn'
export default {
name: 'UserAdminTable',
name: 'UsersAdminTable',
components: {
HiddenGroupsContext,
CrudTable,
@ -107,8 +107,9 @@ export default {
},
methods: {
displayEditUserContext(event) {
const action = event.user.id === this.editUser.id ? 'toggle' : 'show'
this.editUser = event.user
this.$refs.editUserContext.show(event.target, 'bottom', 'left', 4)
this.$refs.editUserContext[action](event.target, 'bottom', 'left', 4)
},
onRowContext({ row, event }) {
this.displayEditUserContext({
@ -120,8 +121,10 @@ export default {
})
},
displayHiddenGroups(event) {
this.hiddenGroups = event.hiddenGroups
this.$refs.hiddenGroupsContext.show(event.target, 'bottom', 'left', 4)
const action =
this.hiddenGroups === event.hiddenValues ? 'toggle' : 'show'
this.hiddenGroups = event.hiddenValues
this.$refs.hiddenGroupsContext[action](event.target, 'bottom', 'left', 4)
},
},
}

View file

@ -64,12 +64,12 @@
</template>
<script>
import ChangePasswordModal from '@baserow_premium/components/admin/user/modals/ChangeUserPasswordModal'
import context from '@baserow/modules/core/mixins/context'
import DeleteUserModal from '@baserow_premium/components/admin/user/modals/DeleteUserModal'
import EditUserModal from '@baserow_premium/components/admin/user/modals/EditUserModal'
import UserAdminService from '@baserow_premium/services/userAdmin'
import { notifyIf } from '@baserow/modules/core/utils/error'
import ChangePasswordModal from '@baserow_premium/components/admin/users/modals/ChangeUserPasswordModal'
import DeleteUserModal from '@baserow_premium/components/admin/users/modals/DeleteUserModal'
import EditUserModal from '@baserow_premium/components/admin/users/modals/EditUserModal'
import UserAdminService from '@baserow_premium/services/admin/users'
export default {
name: 'EditUserContext',

View file

@ -2,11 +2,11 @@
<Context>
<ul class="context__menu">
<li
v-for="group in hiddenGroups"
v-for="group in hiddenValues"
:key="'hidden-admin-row-group' + group.id"
class="user-admin-group__dropdown-item"
>
{{ group.name }}
{{ group[nameKey] }}
<i
v-if="group.permissions == 'ADMIN'"
v-tooltip="'is group admin'"
@ -24,10 +24,15 @@ export default {
name: 'HiddenGroupsContext',
mixins: [context],
props: {
hiddenGroups: {
hiddenValues: {
required: true,
type: Array,
},
},
data() {
return {
nameKey: 'name',
}
},
}
</script>

View file

@ -11,7 +11,7 @@
order: index,
}"
>
{{ group.name }}
{{ group[nameKey] }}
<i
v-if="group.permissions == 'ADMIN'"
v-tooltip="'is group admin'"
@ -52,6 +52,8 @@ export default {
},
data() {
return {
eventName: 'show-hidden-groups',
nameKey: 'name',
overflowing: false,
numHiddenGroups: 0,
renderContext: false,
@ -81,8 +83,8 @@ export default {
},
methods: {
showContext(event) {
this.$emit('show-group', {
hiddenGroups: this.hiddenGroups,
this.$emit(this.eventName, {
hiddenValues: this.hiddenGroups,
target: event.currentTarget,
time: Date.now(),
})

View file

@ -12,8 +12,8 @@
<script>
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import UserAdminService from '@baserow_premium/services/userAdmin'
import ChangePasswordForm from '@baserow_premium/components/admin/user/forms/ChangePasswordForm'
import UserAdminService from '@baserow_premium/services/admin/users'
import ChangePasswordForm from '@baserow_premium/components/admin/users/forms/ChangePasswordForm'
export default {
name: 'ChangePasswordModal',

View file

@ -39,7 +39,7 @@
<script>
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import UserAdminService from '@baserow_premium/services/userAdmin'
import UserAdminService from '@baserow_premium/services/admin/users'
export default {
name: 'DeleteUserModal',

View file

@ -29,9 +29,9 @@
<script>
import modal from '@baserow/modules/core/mixins/modal'
import error from '@baserow/modules/core/mixins/error'
import UserAdminService from '@baserow_premium/services/userAdmin'
import UserForm from '@baserow_premium/components/admin/user/forms/UserForm'
import DeleteUserModal from '@baserow_premium/components/admin/user/modals/DeleteUserModal'
import UserAdminService from '@baserow_premium/services/admin/users'
import UserForm from '@baserow_premium/components/admin/users/forms/UserForm'
import DeleteUserModal from '@baserow_premium/components/admin/users/modals/DeleteUserModal'
export default {
name: 'EditUserModal',

View file

@ -70,7 +70,7 @@
import { notifyIf } from '@baserow/modules/core/utils/error'
import CrudTableSearch from '@baserow_premium/components/crud_table/CrudTableSearch'
import Paginator from '@baserow/modules/core/components/Paginator'
import CrudTableColumn from '@baserow_premium/crud_table/CrudTableColumn'
import CrudTableColumn from '@baserow_premium/crud_table/crudTableColumn'
/**
* This component is a generic wrapper for a basic crud service which displays its

View file

@ -1,10 +1,10 @@
export default (client) => {
export default (client, baseUrl) => {
return {
/**
* @param {number} page The page number to fetch.
* @param {{searchQuery}} searchQuery A search term to filter users by username.
* @param {{searchQuery}} searchQuery A search term to filter results.
* @param {[{direction:string, key:string}]} sorts An ordered list of sorts to
* apply to the users.
* apply to the results.
*/
fetchPage(page, searchQuery, sorts) {
const params = { page }
@ -19,13 +19,7 @@ export default (client) => {
})
.join(',')
}
return client.get(`/admin/user/`, { params })
},
update(userId, values) {
return client.patch(`/admin/user/${userId}/`, values)
},
delete(userId) {
return client.delete(`/admin/user/${userId}/`)
return client.get(baseUrl, { params })
},
}
}

View file

@ -21,7 +21,9 @@
<div class="admin-dashboard__numbers-value">
{{ data.total_groups }}
</div>
<div class="admin-dashboard__numbers-percentage"></div>
<div class="admin-dashboard__numbers-percentage">
<nuxt-link :to="{ name: 'admin-groups' }">view all</nuxt-link>
</div>
</div>
<div class="admin-dashboard__numbers">
<div class="admin-dashboard__numbers-name">
@ -173,7 +175,7 @@
<script>
import ActiveUsers from '@baserow_premium/components/admin/dashboard/charts/ActiveUsers'
import AdminDashboardService from '@baserow_premium/services/adminDashboard'
import AdminDashboardService from '@baserow_premium/services/admin/dashboard'
export default {
components: { ActiveUsers },
@ -183,6 +185,9 @@ export default {
return {
loading: true,
data: {
total_users: 0,
total_groups: 0,
total_applications: 0,
new_users_last_24_hours: 0,
new_users_last_7_days: 0,
new_users_last_30_days: 0,

View file

@ -0,0 +1,13 @@
<template>
<GroupsAdminTable></GroupsAdminTable>
</template>
<script>
import GroupsAdminTable from '@baserow_premium/components/admin/groups/GroupsAdminTable'
export default {
components: { GroupsAdminTable },
layout: 'app',
middleware: 'staff',
}
</script>

View file

@ -1,13 +0,0 @@
<template>
<UserAdminTable></UserAdminTable>
</template>
<script>
import UserAdminTable from '@baserow_premium/components/admin/user/UserAdminTable'
export default {
components: { UserAdminTable },
layout: 'app',
middleware: 'staff',
}
</script>

View file

@ -0,0 +1,13 @@
<template>
<UsersAdminTable></UsersAdminTable>
</template>
<script>
import UsersAdminTable from '@baserow_premium/components/admin/users/UsersAdminTable'
export default {
components: { UsersAdminTable },
layout: 'app',
middleware: 'staff',
}
</script>

View file

@ -1,8 +1,13 @@
import { PremPlugin } from '@baserow_premium/plugins'
import { DashboardType, UsersAdminType } from '@baserow_premium/adminTypes'
import { PremiumPlugin } from '@baserow_premium/plugins'
import {
DashboardType,
UsersAdminType,
GroupsAdminType,
} from '@baserow_premium/adminTypes'
export default ({ app }) => {
app.$registry.register('plugin', new PremPlugin())
app.$registry.register('plugin', new PremiumPlugin())
app.$registry.register('admin', new DashboardType())
app.$registry.register('admin', new UsersAdminType())
app.$registry.register('admin', new GroupsAdminType())
}

View file

@ -1,6 +1,6 @@
import { BaserowPlugin } from '@baserow/modules/core/plugins'
export class PremPlugin extends BaserowPlugin {
export class PremiumPlugin extends BaserowPlugin {
static getType() {
return 'plugin'
}

View file

@ -9,6 +9,11 @@ export const routes = [
{
name: 'admin-users',
path: '/admin/users',
component: path.resolve(__dirname, 'pages/admin/userAdmin.vue'),
component: path.resolve(__dirname, 'pages/admin/users.vue'),
},
{
name: 'admin-groups',
path: '/admin/groups',
component: path.resolve(__dirname, 'pages/admin/groups.vue'),
},
]

View file

@ -0,0 +1,9 @@
import baseService from '@baserow_premium/crud_table/baseService'
export default (client) => {
return Object.assign(baseService(client, '/admin/groups/'), {
delete(groupId) {
return client.delete(`/admin/groups/${groupId}/`)
},
})
}

View file

@ -0,0 +1,12 @@
import baseService from '@baserow_premium/crud_table/baseService'
export default (client) => {
return Object.assign(baseService(client, '/admin/users/'), {
update(userId, values) {
return client.patch(`/admin/users/${userId}/`, values)
},
delete(userId) {
return client.delete(`/admin/users/${userId}/`)
},
})
}

View file

@ -1,10 +1,10 @@
import EditUserContext from '@baserow_premium/components/admin/user/contexts/EditUserContext'
import EditUserContext from '@baserow_premium/components/admin/users/contexts/EditUserContext'
import Error from '@baserow/modules/core/components/Error'
import ChangeUserPasswordModal from '@baserow_premium/components/admin/user/modals/ChangeUserPasswordModal'
import EditUserModal from '@baserow_premium/components/admin/user/modals/EditUserModal'
import ChangeUserPasswordModal from '@baserow_premium/components/admin/users/modals/ChangeUserPasswordModal'
import EditUserModal from '@baserow_premium/components/admin/users/modals/EditUserModal'
import CrudTableSearchContext from '@baserow_premium/components/crud_table/CrudTableSearchContext'
import CrudTableSearch from '@baserow_premium/components/crud_table/CrudTableSearch'
import DeleteUserModal from '@baserow_premium/components/admin/user/modals/DeleteUserModal'
import DeleteUserModal from '@baserow_premium/components/admin/users/modals/DeleteUserModal'
export default class UserAdminUserHelpers {
constructor(userAdminComponent) {

View file

@ -39,22 +39,22 @@ export function createUsersForAdmin(
if (sorts !== null) {
params.sorts = sorts
}
mock.onGet(`/admin/user/`, { params }).reply(200, {
mock.onGet(`/admin/users/`, { params }).reply(200, {
count: count === null ? users.length : count,
results: users,
})
}
export function expectUserDeleted(mock, userId) {
mock.onDelete(`/admin/user/${userId}/`).reply(200)
mock.onDelete(`/admin/users/${userId}/`).reply(200)
}
export function expectUserUpdated(mock, user, changes) {
mock
.onPatch(`/admin/user/${user.id}/`, changes)
.onPatch(`/admin/users/${user.id}/`, changes)
.reply(200, Object.assign({}, user, changes))
}
export function expectUserUpdatedRespondsWithError(mock, user, error) {
mock.onPatch(`/admin/user/${user.id}/`).reply(500, error)
mock.onPatch(`/admin/users/${user.id}/`).reply(500, error)
}

View file

@ -1,5 +1,5 @@
import { TestApp } from '@baserow/test/helpers/testApp'
import UserAdminTable from '@baserow_premium/components/admin/user/UserAdminTable'
import UsersAdminTable from '@baserow_premium/components/admin/users/UsersAdminTable'
import moment from 'moment'
import flushPromises from 'flush-promises'
import UserAdminUserHelpers from '../../../../fixtures/uiHelpers'
@ -406,7 +406,7 @@ describe('User Admin Component Tests', () => {
mockPremiumServer.thereAreUsers([firstPageUser], 1, { count: 150 })
mockPremiumServer.thereAreUsers([secondPageUser], 2, { count: 150 })
const userAdmin = await testApp.mount(UserAdminTable, {})
const userAdmin = await testApp.mount(UsersAdminTable, {})
const ui = new UserAdminUserHelpers(userAdmin)
expect(ui.getSingleRowUsernameText()).toContain(firstPageUser.username)
@ -436,7 +436,7 @@ describe('User Admin Component Tests', () => {
})
mockPremiumServer.thereAreUsers([secondPageUser], 2, { count: 150 })
const userAdmin = await testApp.mount(UserAdminTable, {})
const userAdmin = await testApp.mount(UsersAdminTable, {})
const ui = new UserAdminUserHelpers(userAdmin)
expect(ui.getSingleRowUsernameText()).toContain(firstPageUser.username)
@ -459,7 +459,7 @@ describe('User Admin Component Tests', () => {
mockPremiumServer.thereAreUsers([firstUser, secondUser], 1)
mockPremiumServer.thereAreUsers([firstUser], 1, { search: 'firstUser' })
const userAdmin = await testApp.mount(UserAdminTable, {})
const userAdmin = await testApp.mount(UsersAdminTable, {})
const ui = new UserAdminUserHelpers(userAdmin)
const cells = ui.findCells(14)
@ -490,7 +490,7 @@ describe('User Admin Component Tests', () => {
sorts: '+username',
})
const userAdmin = await testApp.mount(UserAdminTable, {})
const userAdmin = await testApp.mount(UsersAdminTable, {})
const ui = new UserAdminUserHelpers(userAdmin)
const cells = ui.findCells(14)
@ -528,7 +528,7 @@ describe('User Admin Component Tests', () => {
})
mockPremiumServer.thereAreUsers([firstUser, secondUser, thirdUser], 1)
const userAdmin = await testApp.mount(UserAdminTable, {})
const userAdmin = await testApp.mount(UsersAdminTable, {})
const ui = new UserAdminUserHelpers(userAdmin)
let usernameCellsText = ui.findUsernameColumnCellsText()
@ -632,7 +632,7 @@ describe('User Admin Component Tests', () => {
const user = mockPremiumServer.aUser(userSetup)
mockPremiumServer.thereAreUsers([user], 1)
const userAdmin = await testApp.mount(UserAdminTable, {})
const userAdmin = await testApp.mount(UsersAdminTable, {})
const ui = new UserAdminUserHelpers(userAdmin)
return { user, userAdmin, ui }
}

View file

@ -28,7 +28,7 @@ export class AdminType extends Registerable {
* The order value used to sort admin types in the sidebar menu.
*/
getOrder() {
return 1
return 0
}
getRouteName() {
@ -82,4 +82,8 @@ export class SettingsAdminType extends AdminType {
getRouteName() {
return 'admin-settings'
}
getOrder() {
return 9999
}
}

View file

@ -259,7 +259,7 @@ export default {
sortedAdminTypes() {
return Object.values(this.adminTypes)
.slice()
.sort((x) => x.getOrder())
.sort((a, b) => a.getOrder() - b.getOrder())
},
/**
* Indicates whether the current user is visiting an admin page.