diff --git a/backend/src/baserow/api/admin/users/views.py b/backend/src/baserow/api/admin/users/views.py index be3ed02abd..514458be6f 100644 --- a/backend/src/baserow/api/admin/users/views.py +++ b/backend/src/baserow/api/admin/users/views.py @@ -30,6 +30,7 @@ from baserow.api.user.registries import member_data_registry from baserow.api.user.schemas import authenticate_user_schema from baserow.api.user.serializers import get_all_user_data_serialized +from baserow.core.action.registries import action_type_registry from baserow.core.admin.users.actions import AdminDisableTwoFactorAuthActionType from baserow.core.admin.users.exceptions import ( CannotDeactivateYourselfException, @@ -274,7 +275,9 @@ def delete(self, request, user_id): Removes the two-factor authentication of the specified user. """ - AdminDisableTwoFactorAuthActionType.do(request.user, int(user_id)) + action_type_registry.get_by_type(AdminDisableTwoFactorAuthActionType).do( + request.user, int(user_id) + ) return Response(status=204) diff --git a/backend/src/baserow/contrib/database/admin/__init__.py b/backend/src/baserow/contrib/database/admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/contrib/database/admin/views/__init__.py b/backend/src/baserow/contrib/database/admin/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/contrib/database/admin/views/actions.py b/backend/src/baserow/contrib/database/admin/views/actions.py new file mode 100644 index 0000000000..295dd6c337 --- /dev/null +++ b/backend/src/baserow/contrib/database/admin/views/actions.py @@ -0,0 +1,132 @@ +import dataclasses + +from django.contrib.auth.models import AbstractUser +from django.utils.translation import gettext_lazy as _ + +from baserow.contrib.database.action.scopes import ( + VIEW_ACTION_CONTEXT, + ViewActionScopeType, +) +from baserow.contrib.database.admin.views.handler import ViewsAdminHandler +from baserow.contrib.database.views.models import View +from baserow.core.action.registries import ( + ActionScopeStr, + ActionType, + ActionTypeDescription, +) + + +class UpdateViewPublicAdminActionType(ActionType): + type = "admin_update_view_public" + description = ActionTypeDescription( + _("Admin changed view public sharing"), + _( + 'Admin "%(user_email)s" (%(user_id)s) changed the publicly shared ' + "state to %(public)s " + ), + VIEW_ACTION_CONTEXT, + ) + analytics_params = [ + "view_id", + "table_id", + "database_id", + "public", + ] + + @dataclasses.dataclass + class Params: + user_id: int + user_email: str + view_id: int + view_name: str + table_id: int + table_name: str + database_id: int + database_name: str + public: bool + original_public: bool + + @classmethod + def do(cls, user: AbstractUser, view: View, public: bool) -> View: + original_public = view.public + + view = ViewsAdminHandler().update_view_public(user, view, public) + + cls.register_action( + user=user, + params=cls.Params( + user.id, + user.email, + view.id, + view.name, + view.table.id, + view.table.name, + view.table.database.id, + view.table.database.name, + view.public, + original_public, + ), + scope=cls.scope(view.id), + workspace=view.table.database.workspace, + ) + return view + + @classmethod + def scope(cls, view_id: int) -> ActionScopeStr: + return ViewActionScopeType.value(view_id) + + +class RotateViewSlugAdminActionType(ActionType): + type = "admin_rotate_view_slug" + description = ActionTypeDescription( + _("Admin rotated view slug"), + _('Admin "%(user_email)s" (%(user_id)s) rotated the public slug URL '), + VIEW_ACTION_CONTEXT, + ) + analytics_params = [ + "view_id", + "table_id", + "database_id", + ] + + @dataclasses.dataclass + class Params: + user_id: int + user_email: str + view_id: int + view_name: str + table_id: int + table_name: str + database_id: int + database_name: str + slug: str + original_slug: str + + @classmethod + def do(cls, user: AbstractUser, view: View) -> View: + original_slug = view.slug + + view = ViewsAdminHandler().rotate_view_slug(user, view) + + cls.register_action( + user=user, + params=cls.Params( + user.id, + user.email, + view.id, + view.name, + view.table.id, + view.table.name, + view.table.database.id, + view.table.database.name, + view.slug, + original_slug, + ), + scope=cls.scope(view.id), + workspace=view.table.database.workspace, + ) + return view + + @classmethod + def scope(cls, view_id: int) -> ActionScopeStr: + return ViewActionScopeType.value(view_id) diff --git a/backend/src/baserow/contrib/database/admin/views/handler.py b/backend/src/baserow/contrib/database/admin/views/handler.py new file mode 100644 index 0000000000..1eb14ae50c --- /dev/null +++ b/backend/src/baserow/contrib/database/admin/views/handler.py @@ -0,0 +1,81 @@ +from copy import deepcopy + +from django.contrib.auth.models import AbstractUser + +from baserow.contrib.database.table.cache import invalidate_table_in_model_cache +from baserow.contrib.database.views.exceptions import CannotShareViewTypeError +from baserow.contrib.database.views.models import View +from baserow.contrib.database.views.registries import view_type_registry +from baserow.contrib.database.views.signals import view_updated +from baserow.core.exceptions import IsNotAdminError + + +class ViewsAdminHandler: + def update_view_public( + self, requesting_user: AbstractUser, view: View, public: bool + ) -> View: + """ + Changes whether the provided view is publicly shared. This deliberately skips + the workspace permission checks because the requesting staff user is typically + not a member of the workspace. It's used by instance admins to intervene when a + publicly shared view is abused. + + :param requesting_user: The staff user on whose behalf the view is updated. + :param view: The view that must be updated. + :param public: Whether the view must be publicly shared. + :raises IsNotAdminError: When the requesting user is not staff. + :raises CannotShareViewTypeError: When the view type doesn't support sharing. + :return: The updated view instance. + """ + + self._raise_if_not_permitted(requesting_user) + self._raise_if_not_shareable(view) + + view = view.specific + old_view = deepcopy(view) + view.public = public + view.save() + + view_updated.send(self, view=view, user=requesting_user, old_view=old_view) + + return view + + def rotate_view_slug(self, requesting_user: AbstractUser, view: View) -> View: + """ + Rotates the slug of the provided view, permanently invalidating the current + public URL. This deliberately skips the workspace permission checks because + the requesting staff user is typically not a member of the workspace. + + :param requesting_user: The staff user on whose behalf the view is updated. + :param view: The view whose slug must be rotated. + :raises IsNotAdminError: When the requesting user is not staff. + :raises CannotShareViewTypeError: When the view type doesn't support sharing. + :return: The updated view instance. + """ + + self._raise_if_not_permitted(requesting_user) + self._raise_if_not_shareable(view) + + view = view.specific + old_view = deepcopy(view) + view.rotate_slug() + view.save() + + # Invalidate the model cache because fields could be depending on that specific + # model slug, like the edit row link field. + invalidate_table_in_model_cache(view.table_id) + + view_updated.send(self, view=view, user=requesting_user, old_view=old_view) + + return view + + @staticmethod + def _raise_if_not_permitted(requesting_user: AbstractUser): + if not requesting_user.is_staff: + raise IsNotAdminError() + + @staticmethod + def _raise_if_not_shareable(view: View): + view_type = view_type_registry.get_by_model(view.specific_class) + if not view_type.can_share: + raise CannotShareViewTypeError() diff --git a/backend/src/baserow/contrib/database/api/admin/__init__.py b/backend/src/baserow/contrib/database/api/admin/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/contrib/database/api/admin/urls.py b/backend/src/baserow/contrib/database/api/admin/urls.py new file mode 100644 index 0000000000..12f82f2ca1 --- /dev/null +++ b/backend/src/baserow/contrib/database/api/admin/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path + +from .views import urls as views_urls + +app_name = "baserow.contrib.database.api.admin" + +urlpatterns = [ + path("views/", include(views_urls, namespace="views")), +] diff --git a/backend/src/baserow/contrib/database/api/admin/views/__init__.py b/backend/src/baserow/contrib/database/api/admin/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/contrib/database/api/admin/views/serializers.py b/backend/src/baserow/contrib/database/api/admin/views/serializers.py new file mode 100644 index 0000000000..da3e68d768 --- /dev/null +++ b/backend/src/baserow/contrib/database/api/admin/views/serializers.py @@ -0,0 +1,63 @@ +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from baserow.contrib.database.views.models import View +from baserow.contrib.database.views.registries import view_type_registry + + +class AdminViewSerializer(serializers.ModelSerializer): + type = serializers.SerializerMethodField() + table_id = serializers.IntegerField(read_only=True) + table_name = serializers.CharField(source="table.name", read_only=True) + database_id = serializers.IntegerField(source="table.database_id", read_only=True) + database_name = serializers.CharField(source="table.database.name", read_only=True) + workspace_id = serializers.IntegerField( + source="table.database.workspace_id", read_only=True + ) + workspace_name = serializers.CharField( + source="table.database.workspace.name", read_only=True + ) + public_view_has_password = serializers.BooleanField(read_only=True) + owned_by_id = serializers.IntegerField(read_only=True) + owned_by_username = serializers.SerializerMethodField() + + class Meta: + model = View + fields = ( + "id", + "name", + "slug", + "type", + "table_id", + "table_name", + "database_id", + "database_name", + "workspace_id", + "workspace_name", + "public", + "public_view_has_password", + "owned_by_id", + "owned_by_username", + "ownership_type", + "created_on", + ) + + @extend_schema_field(OpenApiTypes.STR) + def get_type(self, instance): + return view_type_registry.get_by_model(instance.specific_class).type + + @extend_schema_field(OpenApiTypes.STR) + def get_owned_by_username(self, instance): + return instance.owned_by.username if instance.owned_by_id else None + + +class AdminViewUpdateSerializer(serializers.ModelSerializer): + public = serializers.BooleanField( + required=True, + help_text="Indicates whether the view must be publicly shared.", + ) + + class Meta: + model = View + fields = ("public",) diff --git a/backend/src/baserow/contrib/database/api/admin/views/urls.py b/backend/src/baserow/contrib/database/api/admin/views/urls.py new file mode 100644 index 0000000000..20ecb416ba --- /dev/null +++ b/backend/src/baserow/contrib/database/api/admin/views/urls.py @@ -0,0 +1,15 @@ +from django.urls import re_path + +from .views import AdminViewRotateSlugView, AdminViewsView, AdminViewView + +app_name = "baserow.contrib.database.api.admin.views" + +urlpatterns = [ + re_path(r"^$", AdminViewsView.as_view(), name="list"), + re_path(r"^(?P[0-9]+)/$", AdminViewView.as_view(), name="edit"), + re_path( + r"^(?P[0-9]+)/rotate-slug/$", + AdminViewRotateSlugView.as_view(), + name="rotate_slug", + ), +] diff --git a/backend/src/baserow/contrib/database/api/admin/views/views.py b/backend/src/baserow/contrib/database/api/admin/views/views.py new file mode 100644 index 0000000000..d30f259fdc --- /dev/null +++ b/backend/src/baserow/contrib/database/api/admin/views/views.py @@ -0,0 +1,187 @@ +from django.db import transaction + +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework.permissions import IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView + +from baserow.api.admin.views import AdminListingView +from baserow.api.decorators import map_exceptions, validate_body +from baserow.api.pagination import PageNumberPaginationWithApproximateCount +from baserow.api.schemas import get_error_schema +from baserow.config.settings.utils import str_to_bool +from baserow.contrib.database.admin.views.actions import ( + RotateViewSlugAdminActionType, + UpdateViewPublicAdminActionType, +) +from baserow.contrib.database.api.views.errors import ( + ERROR_CANNOT_SHARE_VIEW_TYPE, + ERROR_VIEW_DOES_NOT_EXIST, +) +from baserow.contrib.database.views.exceptions import ( + CannotShareViewTypeError, + ViewDoesNotExist, +) +from baserow.contrib.database.views.handler import ViewHandler +from baserow.contrib.database.views.models import View +from baserow.core.action.registries import action_type_registry + +from .serializers import AdminViewSerializer, AdminViewUpdateSerializer + + +class AdminViewsView(AdminListingView): + serializer_class = AdminViewSerializer + pagination_class = PageNumberPaginationWithApproximateCount + search_fields = [ + "id", + "name", + "slug", + "table__id", + "table__name", + "table__database__id", + "table__database__name", + "table__database__workspace__id", + "table__database__workspace__name", + "owned_by__id", + "owned_by__username", + ] + sort_field_mapping = { + "id": "id", + "name": "name", + "type": "content_type__model", + "database_name": "table__database__name", + "workspace_name": "table__database__workspace__name", + "public": "public", + "owned_by_username": "owned_by__username", + "created_on": "created_on", + } + default_order_by = "-id" + + def get_queryset(self, request): + queryset = View.objects.select_related( + "table__database__workspace", "owned_by", "content_type" + ).filter( + table__trashed=False, + table__database__trashed=False, + table__database__workspace__isnull=False, + table__database__workspace__trashed=False, + table__database__workspace__template__isnull=True, + ) + + if str_to_bool(str(request.GET.get("only_public"))): + queryset = queryset.filter(public=True) + + return queryset + + @extend_schema( + tags=["Admin"], + operation_id="admin_list_database_views", + description="Returns all database views in the instance with information about " + "the table, database and workspace they belong to, if the requesting user is " + "staff. This can for example be used to find and intervene with publicly " + "shared views that are being abused.", + **AdminListingView.get_extend_schema_parameters( + "views", + serializer_class, + search_fields, + sort_field_mapping, + extra_parameters=[ + OpenApiParameter( + name="only_public", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.BOOL, + description="If set to `true`, only publicly shared views are " + "returned.", + ), + ], + ), + ) + def get(self, request): + return super().get(request) + + +class AdminViewView(APIView): + permission_classes = (IsAdminUser,) + + @extend_schema( + tags=["Admin"], + request=AdminViewUpdateSerializer, + operation_id="admin_update_database_view", + description="Updates whether the specified view is publicly shared, if the " + "requesting user is staff. This works even if the requesting user is not a " + "member of the workspace the view belongs to, and can for example be used " + "to unshare a publicly shared view that's being abused.", + parameters=[ + OpenApiParameter( + name="view_id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.INT, + description="The id of the view to update.", + ), + ], + responses={ + 200: AdminViewSerializer, + 400: get_error_schema( + ["ERROR_REQUEST_BODY_VALIDATION", "ERROR_CANNOT_SHARE_VIEW_TYPE"] + ), + 401: None, + 404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]), + }, + ) + @validate_body(AdminViewUpdateSerializer) + @map_exceptions( + { + ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, + CannotShareViewTypeError: ERROR_CANNOT_SHARE_VIEW_TYPE, + } + ) + @transaction.atomic + def patch(self, request, view_id, data): + view = ViewHandler().get_view(int(view_id)) + view = action_type_registry.get_by_type(UpdateViewPublicAdminActionType).do( + request.user, view, data["public"] + ) + + return Response(AdminViewSerializer(view).data) + + +class AdminViewRotateSlugView(APIView): + permission_classes = (IsAdminUser,) + + @extend_schema( + tags=["Admin"], + operation_id="admin_rotate_database_view_slug", + description="Rotates the slug of the specified view, permanently " + "invalidating the current public URL, if the requesting user is staff. " + "This works even if the requesting user is not a member of the workspace " + "the view belongs to.", + parameters=[ + OpenApiParameter( + name="view_id", + location=OpenApiParameter.PATH, + type=OpenApiTypes.INT, + description="The id of the view whose slug must be rotated.", + ), + ], + responses={ + 200: AdminViewSerializer, + 400: get_error_schema(["ERROR_CANNOT_SHARE_VIEW_TYPE"]), + 401: None, + 404: get_error_schema(["ERROR_VIEW_DOES_NOT_EXIST"]), + }, + ) + @map_exceptions( + { + ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, + CannotShareViewTypeError: ERROR_CANNOT_SHARE_VIEW_TYPE, + } + ) + @transaction.atomic + def post(self, request, view_id): + view = ViewHandler().get_view(int(view_id)) + view = action_type_registry.get_by_type(RotateViewSlugAdminActionType).do( + request.user, view + ) + + return Response(AdminViewSerializer(view).data) diff --git a/backend/src/baserow/contrib/database/api/urls.py b/backend/src/baserow/contrib/database/api/urls.py index e27cc4680e..72263a1c74 100644 --- a/backend/src/baserow/contrib/database/api/urls.py +++ b/backend/src/baserow/contrib/database/api/urls.py @@ -1,5 +1,6 @@ from django.urls import include, path +from .admin import urls as admin_urls from .data_sync import urls as data_sync_urls from .export import urls as export_urls from .field_rules import urls as field_rules_urls @@ -24,4 +25,5 @@ path("formula/", include(formula_urls, namespace="formula")), path("data-sync/", include(data_sync_urls, namespace="data_sync")), path("field-rules/", include(field_rules_urls, namespace="field_rules")), + path("admin/", include(admin_urls, namespace="admin")), ] diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index b95ec8c238..981ba0eede 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -157,6 +157,14 @@ def ready(self): action_type_registry.register(UpdateViewFilterGroupActionType()) action_type_registry.register(DeleteViewFilterGroupActionType()) + from baserow.contrib.database.admin.views.actions import ( + RotateViewSlugAdminActionType, + UpdateViewPublicAdminActionType, + ) + + action_type_registry.register(UpdateViewPublicAdminActionType()) + action_type_registry.register(RotateViewSlugAdminActionType()) + from baserow.contrib.database.data_sync.actions import ( CreateDataSyncTableActionType, SyncDataSyncTableActionType, diff --git a/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po index e370be80ef..ab5086a975 100644 --- a/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po +++ b/backend/src/baserow/contrib/database/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-06 19:38+0000\n" +"POT-Creation-Date: 2026-07-21 18:19+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -37,6 +37,26 @@ msgid "" "(%(table_id)s) in database \"%(database_name)s\" (%(database_id)s)." msgstr "" +#: src/baserow/contrib/database/admin/views/actions.py:22 +msgid "Admin changed view public sharing" +msgstr "" + +#: src/baserow/contrib/database/admin/views/actions.py:24 +#, python-format +msgid "" +"Admin \"%(user_email)s\" (%(user_id)s) changed the publicly shared state to " +"%(public)s " +msgstr "" + +#: src/baserow/contrib/database/admin/views/actions.py:82 +msgid "Admin rotated view slug" +msgstr "" + +#: src/baserow/contrib/database/admin/views/actions.py:83 +#, python-format +msgid "Admin \"%(user_email)s\" (%(user_id)s) rotated the public slug URL " +msgstr "" + #: src/baserow/contrib/database/airtable/actions.py:23 msgid "Import database from Airtable" msgstr "" diff --git a/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po b/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po index 22dc37366e..4d241ec9da 100644 --- a/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po +++ b/backend/src/baserow/core/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-06 19:38+0000\n" +"POT-Creation-Date: 2026-07-21 18:19+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -256,7 +256,7 @@ msgstr "" msgid "Unlabeled" msgstr "" -#: src/baserow/core/handler.py:2176 src/baserow/core/user/handler.py:275 +#: src/baserow/core/handler.py:2201 src/baserow/core/user/handler.py:275 #, python-format msgid "%(name)s's workspace" msgstr "" diff --git a/backend/tests/baserow/contrib/database/api/admin/test_views_admin_views.py b/backend/tests/baserow/contrib/database/api/admin/test_views_admin_views.py new file mode 100644 index 0000000000..10d75c98ab --- /dev/null +++ b/backend/tests/baserow/contrib/database/api/admin/test_views_admin_views.py @@ -0,0 +1,520 @@ +from unittest.mock import patch + +from django.db import connection +from django.shortcuts import reverse +from django.test.utils import CaptureQueriesContext + +import pytest +from rest_framework.status import ( + HTTP_200_OK, + HTTP_400_BAD_REQUEST, + HTTP_401_UNAUTHORIZED, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, +) + +from baserow.contrib.database.views.models import View +from baserow.contrib.database.views.registries import view_type_registry +from baserow.contrib.database.views.view_types import GridViewType +from baserow.core.action.signals import action_done + + +@pytest.mark.django_db +def test_non_admin_cannot_access_admin_views_endpoints(api_client, data_fixture): + _, token = data_fixture.create_user_and_token(is_staff=False) + view = data_fixture.create_grid_view(public=True) + + urls = [ + ("get", reverse("api:database:admin:views:list")), + ( + "patch", + reverse("api:database:admin:views:edit", kwargs={"view_id": view.id}), + ), + ( + "post", + reverse( + "api:database:admin:views:rotate_slug", kwargs={"view_id": view.id} + ), + ), + ] + + for method, url in urls: + response = getattr(api_client, method)(url, format="json") + assert response.status_code == HTTP_401_UNAUTHORIZED + + response = getattr(api_client, method)( + url, format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert response.status_code == HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +def test_admin_can_list_views_with_all_fields(api_client, data_fixture): + _, token = data_fixture.create_user_and_token(is_staff=True) + owner = data_fixture.create_user(email="owner@baserow.io") + table = data_fixture.create_database_table(name="Table") + grid_view = data_fixture.create_grid_view( + table=table, + name="Grid", + public=True, + owned_by=owner, + public_view_password=View.make_password("secret"), + ) + form_view = data_fixture.create_form_view(table=table, name="Form", public=True) + + response = api_client.get( + reverse("api:database:admin:views:list"), + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert response_json["count"] == 2 + + database = table.database + workspace = database.workspace + # The default ordering is by descending id, so the form view comes first. + assert response_json["results"][0]["id"] == form_view.id + assert response_json["results"][0]["type"] == "form" + assert response_json["results"][0]["public_view_has_password"] is False + assert response_json["results"][0]["owned_by_username"] is None + grid_view_result = response_json["results"][1] + assert grid_view_result == { + "id": grid_view.id, + "name": "Grid", + "slug": str(grid_view.slug), + "type": "grid", + "table_id": table.id, + "table_name": "Table", + "database_id": database.id, + "database_name": database.name, + "workspace_id": workspace.id, + "workspace_name": workspace.name, + "public": True, + "public_view_has_password": True, + "owned_by_id": owner.id, + "owned_by_username": "owner@baserow.io", + "ownership_type": "collaborative", + "created_on": grid_view.created_on.isoformat(timespec="microseconds").replace( + "+00:00", "Z" + ), + } + + +@pytest.mark.django_db +def test_admin_can_filter_views_on_only_public(api_client, data_fixture): + _, token = data_fixture.create_user_and_token(is_staff=True) + public_view = data_fixture.create_grid_view(public=True) + private_view = data_fixture.create_grid_view(public=False) + + url = reverse("api:database:admin:views:list") + response = api_client.get( + f"{url}?only_public=true", format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + response_json = response.json() + assert response_json["count"] == 1 + assert response_json["results"][0]["id"] == public_view.id + + response = api_client.get(url, format="json", HTTP_AUTHORIZATION=f"JWT {token}") + response_json = response.json() + assert response_json["count"] == 2 + assert {result["id"] for result in response_json["results"]} == { + public_view.id, + private_view.id, + } + + +@pytest.mark.django_db +def test_admin_can_search_views(api_client, data_fixture): + _, token = data_fixture.create_user_and_token(is_staff=True) + workspace = data_fixture.create_workspace(name="Unique workspace name") + database = data_fixture.create_database_application( + workspace=workspace, name="Unique database name" + ) + table = data_fixture.create_database_table( + database=database, name="Unique table name" + ) + owner = data_fixture.create_user(email="unique-owner@baserow.io") + view = data_fixture.create_grid_view( + table=table, name="Unique view name", owned_by=owner + ) + data_fixture.create_grid_view(name="Something else") + + searches = [ + "Unique view name", + str(view.id), + str(view.slug), + "Unique table name", + str(table.id), + "Unique database name", + str(database.id), + "Unique workspace name", + str(workspace.id), + "unique-owner@baserow.io", + str(owner.id), + ] + + url = reverse("api:database:admin:views:list") + for search in searches: + response = api_client.get( + url, + {"search": search}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + result_ids = {result["id"] for result in response.json()["results"]} + assert view.id in result_ids, search + + response = api_client.get( + url, + {"search": "Unique view name"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert {result["id"] for result in response.json()["results"]} == {view.id} + + response = api_client.get( + url, + {"search": "No view matches this"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.json()["count"] == 0 + + +@pytest.mark.django_db +def test_admin_can_sort_views(api_client, data_fixture): + _, token = data_fixture.create_user_and_token(is_staff=True) + view_a = data_fixture.create_grid_view(name="A") + view_b = data_fixture.create_grid_view(name="B") + + url = reverse("api:database:admin:views:list") + response = api_client.get( + f"{url}?sorts=%2Bname", format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert [result["id"] for result in response.json()["results"]] == [ + view_a.id, + view_b.id, + ] + + response = api_client.get( + f"{url}?sorts=-name", format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert [result["id"] for result in response.json()["results"]] == [ + view_b.id, + view_a.id, + ] + + response = api_client.get( + f"{url}?sorts=%2Bunknown", format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_INVALID_SORT_ATTRIBUTE" + + +@pytest.mark.django_db +def test_admin_can_sort_views_on_all_sortable_columns(api_client, data_fixture): + _, token = data_fixture.create_user_and_token(is_staff=True) + owner_a = data_fixture.create_user(email="a-owner@baserow.io") + owner_b = data_fixture.create_user(email="b-owner@baserow.io") + workspace_a = data_fixture.create_workspace(name="A workspace") + workspace_b = data_fixture.create_workspace(name="B workspace") + database_a = data_fixture.create_database_application( + workspace=workspace_a, name="A database" + ) + database_b = data_fixture.create_database_application( + workspace=workspace_b, name="B database" + ) + table_a = data_fixture.create_database_table(database=database_a) + table_b = data_fixture.create_database_table(database=database_b) + grid_view = data_fixture.create_grid_view( + table=table_a, public=True, owned_by=owner_a + ) + gallery_view = data_fixture.create_gallery_view( + table=table_b, public=False, owned_by=owner_b + ) + + ascending_orders = { + "type": [gallery_view.id, grid_view.id], + "database_name": [grid_view.id, gallery_view.id], + "workspace_name": [grid_view.id, gallery_view.id], + "public": [gallery_view.id, grid_view.id], + "owned_by_username": [grid_view.id, gallery_view.id], + } + + url = reverse("api:database:admin:views:list") + for attribute, expected in ascending_orders.items(): + response = api_client.get( + f"{url}?sorts=%2B{attribute}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert [result["id"] for result in response.json()["results"]] == expected, ( + attribute + ) + + response = api_client.get( + f"{url}?sorts=-{attribute}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert [result["id"] for result in response.json()["results"]] == list( + reversed(expected) + ), attribute + + +@pytest.mark.django_db +def test_admin_list_views_excludes_trashed_snapshots_and_templates( + api_client, data_fixture +): + _, token = data_fixture.create_user_and_token(is_staff=True) + view = data_fixture.create_grid_view() + + data_fixture.create_grid_view(trashed=True) + trashed_table = data_fixture.create_database_table(trashed=True) + data_fixture.create_grid_view(table=trashed_table) + trashed_database = data_fixture.create_database_application(trashed=True) + data_fixture.create_grid_view( + table=data_fixture.create_database_table(database=trashed_database) + ) + trashed_workspace = data_fixture.create_workspace(trashed=True) + data_fixture.create_grid_view( + table=data_fixture.create_database_table( + database=data_fixture.create_database_application( + workspace=trashed_workspace + ) + ) + ) + snapshot_database = data_fixture.create_database_application(workspace=None) + data_fixture.create_grid_view( + table=data_fixture.create_database_table(database=snapshot_database) + ) + template_workspace = data_fixture.create_workspace() + data_fixture.create_template(workspace=template_workspace) + data_fixture.create_grid_view( + table=data_fixture.create_database_table( + database=data_fixture.create_database_application( + workspace=template_workspace + ) + ) + ) + + response = api_client.get( + reverse("api:database:admin:views:list"), + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + response_json = response.json() + assert response_json["count"] == 1 + assert response_json["results"][0]["id"] == view.id + + +@pytest.mark.django_db +def test_admin_list_views_number_of_queries_is_constant(api_client, data_fixture): + _, token = data_fixture.create_user_and_token(is_staff=True) + url = reverse("api:database:admin:views:list") + + def fetch_and_count_queries(): + with CaptureQueriesContext(connection) as queries: + response = api_client.get( + url, format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert response.status_code == HTTP_200_OK + return len(queries.captured_queries) + + data_fixture.create_grid_view(public=True) + first_count = fetch_and_count_queries() + + data_fixture.create_grid_view(public=True) + data_fixture.create_gallery_view(public=True) + data_fixture.create_form_view(public=True, owned_by=data_fixture.create_user()) + assert fetch_and_count_queries() == first_count + + +@pytest.mark.django_db +def test_admin_can_update_view_public(api_client, data_fixture): + admin_user, token = data_fixture.create_user_and_token(is_staff=True) + # The staff user is deliberately not a member of the view's workspace. + view = data_fixture.create_grid_view(public=True) + + url = reverse("api:database:admin:views:edit", kwargs={"view_id": view.id}) + received_actions = [] + + def receiver(sender, user, action_type, action_params, workspace, **kwargs): + received_actions.append((user, action_type, action_params, workspace)) + + action_done.connect(receiver) + try: + with patch( + "baserow.contrib.database.admin.views.handler.view_updated.send" + ) as mock_view_updated: + response = api_client.patch( + url, + {"public": False}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + finally: + action_done.disconnect(receiver) + + assert response.status_code == HTTP_200_OK + assert response.json()["public"] is False + view.refresh_from_db() + assert view.public is False + assert mock_view_updated.call_count == 1 + assert mock_view_updated.call_args[1]["view"].id == view.id + + assert len(received_actions) == 1 + acting_user, action_type, action_params, workspace = received_actions[0] + assert acting_user.id == admin_user.id + assert action_type.type == "admin_update_view_public" + assert action_params["view_id"] == view.id + assert action_params["public"] is False + assert action_params["original_public"] is True + assert workspace.id == view.table.database.workspace_id + + # It must also be possible to make the view public again because it could + # mistakenly have been made private. + response = api_client.patch( + url, + {"public": True}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + assert response.json()["public"] is True + view.refresh_from_db() + assert view.public is True + + +@pytest.mark.django_db +def test_admin_can_rotate_view_slug(api_client, data_fixture): + admin_user, token = data_fixture.create_user_and_token(is_staff=True) + view = data_fixture.create_grid_view(public=True) + old_slug = str(view.slug) + received_actions = [] + + def receiver(sender, user, action_type, action_params, **kwargs): + received_actions.append((user, action_type, action_params)) + + action_done.connect(receiver) + try: + response = api_client.post( + reverse( + "api:database:admin:views:rotate_slug", kwargs={"view_id": view.id} + ), + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + finally: + action_done.disconnect(receiver) + + assert response.status_code == HTTP_200_OK + view.refresh_from_db() + assert str(view.slug) != old_slug + assert response.json()["slug"] == str(view.slug) + + assert len(received_actions) == 1 + acting_user, action_type, action_params = received_actions[0] + assert acting_user.id == admin_user.id + assert action_type.type == "admin_rotate_view_slug" + assert action_params["view_id"] == view.id + assert action_params["original_slug"] == old_slug + assert action_params["slug"] == str(view.slug) + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.ws.registries.broadcast_to_channel_group") +def test_admin_view_actions_use_the_specific_view_in_realtime_events( + mock_broadcast_to_channel_group, api_client, data_fixture +): + _, token = data_fixture.create_user_and_token(is_staff=True) + table = data_fixture.create_database_table() + data_fixture.create_text_field(table=table) + view = data_fixture.create_grid_view( + table=table, public=False, create_options=False + ) + + response = api_client.patch( + reverse("api:database:admin:views:edit", kwargs={"view_id": view.id}), + {"public": True}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + + response = api_client.post( + reverse("api:database:admin:views:rotate_slug", kwargs={"view_id": view.id}), + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + + assert mock_broadcast_to_channel_group.delay.call_count > 0 + + +@pytest.mark.django_db +def test_admin_view_actions_with_unknown_view_or_unshareable_type( + api_client, data_fixture +): + class UnShareableViewType(GridViewType): + can_share = False + + _, token = data_fixture.create_user_and_token(is_staff=True) + view = data_fixture.create_grid_view(public=True) + + edit_url = reverse("api:database:admin:views:edit", kwargs={"view_id": 99999}) + response = api_client.patch( + edit_url, {"public": False}, format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_EXIST" + + rotate_url = reverse( + "api:database:admin:views:rotate_slug", kwargs={"view_id": 99999} + ) + response = api_client.post( + rotate_url, format="json", HTTP_AUTHORIZATION=f"JWT {token}" + ) + assert response.status_code == HTTP_404_NOT_FOUND + assert response.json()["error"] == "ERROR_VIEW_DOES_NOT_EXIST" + + received_actions = [] + + def receiver(sender, **kwargs): + received_actions.append(kwargs) + + # `get_for_class` is lru_cached, so the mapping to the real `GridViewType` can + # already be cached by earlier tests, making `patch.dict` on the registry + # unreliable here. + action_done.connect(receiver) + try: + with patch.object( + view_type_registry, "get_for_class", return_value=UnShareableViewType() + ): + response = api_client.patch( + reverse("api:database:admin:views:edit", kwargs={"view_id": view.id}), + {"public": False}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_CANNOT_SHARE_VIEW_TYPE" + + response = api_client.post( + reverse( + "api:database:admin:views:rotate_slug", kwargs={"view_id": view.id} + ), + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_400_BAD_REQUEST + assert response.json()["error"] == "ERROR_CANNOT_SHARE_VIEW_TYPE" + finally: + action_done.disconnect(receiver) + + # A failed operation must not be recorded in the audit log. + assert received_actions == [] diff --git a/changelog/entries/unreleased/feature/added_a_staff_only_admin_page_listing_all_database_views_for.json b/changelog/entries/unreleased/feature/added_a_staff_only_admin_page_listing_all_database_views_for.json new file mode 100644 index 0000000000..4d523dad75 --- /dev/null +++ b/changelog/entries/unreleased/feature/added_a_staff_only_admin_page_listing_all_database_views_for.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Added a staff-only admin page listing all database views.", + "issue_origin": "github", + "issue_number": null, + "domain": "database", + "bullet_points": [], + "created_at": "2026-07-20" +} diff --git a/e2e-tests/fixtures/builder/builderElement.ts b/e2e-tests/fixtures/builder/builderElement.ts index 4e551d72c2..8acf3a1267 100644 --- a/e2e-tests/fixtures/builder/builderElement.ts +++ b/e2e-tests/fixtures/builder/builderElement.ts @@ -6,14 +6,14 @@ export class BuilderElement { public page: BuilderPage, public id: number, public type: string, - public properties: Object + public properties: Object, ) {} } export async function createBuilderElement( page: BuilderPage, elementType: string, - properties: Object = {} + properties: Object = {}, ): Promise { const response: any = await getClient(page.builder.workspace.user).post( `builder/page/${page.id}/elements/`, @@ -21,8 +21,19 @@ export async function createBuilderElement( page_id: page.id, type: elementType, ...properties, - } + }, ); const { id, type, ...rest } = response.data; return new BuilderElement(page, response.data.id, elementType, rest); } + +export async function updateBuilderElement( + element: BuilderElement, + values: Object, +): Promise { + const response: any = await getClient( + element.page.builder.workspace.user, + ).patch(`builder/element/${element.id}/`, values); + const { id, type, ...rest } = response.data; + return new BuilderElement(element.page, response.data.id, element.type, rest); +} diff --git a/e2e-tests/fixtures/builder/domain.ts b/e2e-tests/fixtures/builder/domain.ts new file mode 100644 index 0000000000..2d4b59f110 --- /dev/null +++ b/e2e-tests/fixtures/builder/domain.ts @@ -0,0 +1,65 @@ +import { getClient } from "../../client"; +import { Builder } from "./builder"; + +export class PublishedBuilder { + constructor( + public id: number, + // Path of the first non-shared page, e.g. "/page". + public pagePath: string, + // The published copy of the user source (its id differs from the editor's). + public userSourceId: number, + ) {} + + // URL path of the preview route that renders the published, visibility + // filtered page without needing a real domain to resolve. + previewPath(): string { + return `/builder/${this.id}/preview${this.pagePath}`; + } +} + +/** + * Creates a custom domain for the builder, publishes it (waiting for the async + * job to finish), and returns the resulting published builder details. + */ +export async function publishBuilder( + builder: Builder, + domainName: string, +): Promise { + const client = getClient(builder.workspace.user); + + const domain: any = await client.post(`builder/${builder.id}/domains/`, { + type: "custom", + domain_name: domainName, + }); + + const job: any = await client.post( + `builder/domains/${domain.data.id}/publish/async/`, + {}, + ); + + // Poll the publish job until it finishes. + let state = job.data.state; + for (let i = 0; i < 60 && state !== "finished"; i++) { + const poll: any = await client.get(`jobs/${job.data.id}/`); + state = poll.data.state; + if (state === "failed") { + throw new Error( + `Publish job failed: ${poll.data.human_readable_error || ""}`, + ); + } + if (state !== "finished") { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + if (state !== "finished") { + throw new Error("Publish job did not finish in time"); + } + + const published: any = await client.get( + `builder/domains/published/by_name/${domainName}/`, + ); + const page = published.data.pages.find((p: any) => p.path !== "__shared__"); + const userSource = published.data.user_sources[0]; + + return new PublishedBuilder(published.data.id, page.path, userSource.id); +} diff --git a/e2e-tests/fixtures/builder/integration.ts b/e2e-tests/fixtures/builder/integration.ts new file mode 100644 index 0000000000..cf43edbaa8 --- /dev/null +++ b/e2e-tests/fixtures/builder/integration.ts @@ -0,0 +1,21 @@ +import { getClient } from "../../client"; +import { Builder } from "./builder"; + +export class Integration { + constructor( + public id: number, + public type: string, + public builder: Builder, + ) {} +} + +export async function createLocalBaserowIntegration( + builder: Builder, + name = "Local Baserow", +): Promise { + const response: any = await getClient(builder.workspace.user).post( + `application/${builder.id}/integrations/`, + { type: "local_baserow", name }, + ); + return new Integration(response.data.id, response.data.type, builder); +} diff --git a/e2e-tests/fixtures/builder/userSource.ts b/e2e-tests/fixtures/builder/userSource.ts new file mode 100644 index 0000000000..330ab64434 --- /dev/null +++ b/e2e-tests/fixtures/builder/userSource.ts @@ -0,0 +1,71 @@ +import { getClient } from "../../client"; +import { Builder } from "./builder"; +import { Integration } from "./integration"; +import { Table } from "../database/table"; +import { Field } from "../database/field"; + +export class UserSource { + constructor( + public id: number, + public type: string, + public builder: Builder, + ) {} +} + +/** + * Creates a Local Baserow user source backed by the given users table, wired to + * a password auth provider so users can authenticate with email + password. The + * `roleField` provides each user's role, used by element visibility rules. + */ +export async function createLocalBaserowUserSource( + builder: Builder, + integration: Integration, + table: Table, + fields: { email: Field; name: Field; role: Field; password: Field }, + name = "Users", +): Promise { + const response: any = await getClient(builder.workspace.user).post( + `application/${builder.id}/user-sources/`, + { + type: "local_baserow", + name, + integration_id: integration.id, + table_id: table.id, + email_field_id: fields.email.id, + name_field_id: fields.name.id, + role_field_id: fields.role.id, + auth_providers: [ + { + type: "local_baserow_password", + enabled: true, + password_field_id: fields.password.id, + }, + ], + }, + ); + return new UserSource(response.data.id, response.data.type, builder); +} + +/** + * Authenticates a user source user with email + password against the given user + * source id and returns the JWT pair. Use the returned refresh token as the + * `user_source_token` cookie to browse the published site as that user. + */ +export async function userSourceTokenAuth( + userSourceId: number, + email: string, + password: string, +): Promise<{ accessToken: string; refreshToken: string }> { + // Authenticate as a public site visitor would: no Baserow user token. A + // published user source belongs to no workspace, so a request carrying the + // builder owner's token is rejected (no permission); an unauthenticated + // request is the supported path. + const response: any = await getClient().post( + `user-source/${userSourceId}/token-auth`, + { email, password }, + ); + return { + accessToken: response.data.access_token, + refreshToken: response.data.refresh_token, + }; +} diff --git a/e2e-tests/tests/builder/elements/elementVisibility.spec.ts b/e2e-tests/tests/builder/elements/elementVisibility.spec.ts new file mode 100644 index 0000000000..610d4b1aa6 --- /dev/null +++ b/e2e-tests/tests/builder/elements/elementVisibility.spec.ts @@ -0,0 +1,211 @@ +import { expect, test } from "../../baserowTest"; +import { + createLicense, + deleteLicense, + ENTERPRISE_LICENSE, + License, +} from "../../../fixtures/licence"; +import { createBuilder } from "../../../fixtures/builder/builder"; +import { createBuilderPage } from "../../../fixtures/builder/builderPage"; +import { + createBuilderElement, + updateBuilderElement, +} from "../../../fixtures/builder/builderElement"; +import { createLocalBaserowIntegration } from "../../../fixtures/builder/integration"; +import { + createLocalBaserowUserSource, + userSourceTokenAuth, +} from "../../../fixtures/builder/userSource"; +import { publishBuilder } from "../../../fixtures/builder/domain"; +import { createDatabase } from "../../../fixtures/database/database"; +import { createTable } from "../../../fixtures/database/table"; +import { + createField, + getFieldsForTable, +} from "../../../fixtures/database/field"; +import { listRows, updateRows } from "../../../fixtures/database/rows"; +import { baserowConfig } from "../../../playwright.config"; + +const ROLE = "editor"; +const PASSWORD = "supersecret123"; + +// The five element visibility permutations. Each is a heading whose resolved +// text equals its label, so we can assert which ones render in the DOM. +const ELEMENTS = [ + { label: "E1_ALL", visibility: "all", role_type: "allow_all", roles: [] }, + { + label: "E2_LOGGED", + visibility: "logged-in", + role_type: "allow_all", + roles: [], + }, + { + label: "E3_ALLOW_EDITOR", + visibility: "logged-in", + role_type: "disallow_all_except", + roles: [ROLE], + }, + { + label: "E4_DISALLOW_EDITOR", + visibility: "logged-in", + role_type: "allow_all_except", + roles: [ROLE], + }, + { + label: "E5_LOGGEDOUT", + visibility: "not-logged", + role_type: "allow_all", + roles: [], + }, +]; +const ALL_LABELS = ELEMENTS.map((e) => e.label); + +// Which labels each visitor type is expected to see. Notably the authenticated +// visitor with no role must NOT see the same set as the anonymous visitor - +// that collision was the bug this test guards against. +const EXPECTED = { + anonymous: ["E1_ALL", "E5_LOGGEDOUT"], + loggedInNoRole: ["E1_ALL", "E2_LOGGED", "E4_DISALLOW_EDITOR"], + loggedInEditor: ["E1_ALL", "E2_LOGGED", "E3_ALLOW_EDITOR"], +}; + +test.describe("Builder element visibility on published pages", () => { + let license: License; + + test.beforeEach(async () => { + // Local Baserow user sources require an Enterprise license. + license = await createLicense(ENTERPRISE_LICENSE); + }); + + test.afterEach(async () => { + await deleteLicense(license); + }); + + test("visitors only see elements matching their visibility permissions @enterprise", async ({ + page, + workspacePage, + }) => { + const { user, workspace } = workspacePage; + + // --- Build the application: a page with the five visibility permutations. + const builder = await createBuilder("Visibility builder", workspace); + const builderPage = await createBuilderPage( + "Visibility", + "/visibility", + builder, + ); + for (const cfg of ELEMENTS) { + const element = await createBuilderElement(builderPage, "heading", { + value: `'${cfg.label}'`, + }); + // role_type/roles are only accepted on update, so configure visibility + // after creation. + await updateBuilderElement(element, { + visibility: cfg.visibility, + role_type: cfg.role_type, + roles: cfg.roles, + }); + } + + // --- Build the user source: a users table with a blank-role and an editor + // user, wired to a Local Baserow user source with password auth. + const database = await createDatabase(user, "Users db", workspace); + const table = await createTable(user, "Users", database, [ + ["Email", "Name", "Role"], + ["blank@example.com", "Blank", ""], + ["editor@example.com", "Editor", ROLE], + ]); + await createField(user, "Password", "password", {}, table); + const fields = await getFieldsForTable(user, table); + const fieldByName = Object.fromEntries(fields.map((f) => [f.name, f])); + + // Passwords are write-only, so set them on the existing rows. + const rows = await listRows(user, table); + await updateRows( + user, + table, + rows.map((r) => ({ id: r.id, Password: PASSWORD })), + ); + + const integration = await createLocalBaserowIntegration(builder); + const userSource = await createLocalBaserowUserSource( + builder, + integration, + table, + { + email: fieldByName.Email, + name: fieldByName.Name, + role: fieldByName.Role, + password: fieldByName.Password, + }, + ); + + // --- Publish. The published copy filters elements by visibility. + const published = await publishBuilder( + builder, + `e2e-visibility-${userSource.id}-${Date.now()}.example.com`, + ); + + // --- Authenticate each user source user against the published user source. + const blankAuth = await userSourceTokenAuth( + published.userSourceId, + "blank@example.com", + PASSWORD, + ); + const editorAuth = await userSourceTokenAuth( + published.userSourceId, + "editor@example.com", + PASSWORD, + ); + + const previewUrl = `${baserowConfig.PUBLIC_WEB_FRONTEND_URL}${published.previewPath()}`; + const frontendHost = new URL(baserowConfig.PUBLIC_WEB_FRONTEND_URL) + .hostname; + const cookieName = `${baserowConfig.BASEROW_FRONTEND_COOKIE_PREFIX}user_source_token`; + + // Visit the published page as a given visitor and assert exactly which of + // the five headings render. `refreshToken` null means an anonymous visit. + const assertVisibleFor = async ( + refreshToken: string | null, + expectedLabels: string[], + ) => { + // Reset to a clean visitor: no Baserow session, no user source token. + await page.context().clearCookies(); + if (refreshToken) { + await page.context().addCookies([ + { + name: cookieName, + value: refreshToken, + domain: frontendHost, + path: "/", + }, + ]); + } + + await page.goto(previewUrl, { waitUntil: "networkidle" }); + + // E1_ALL is visible to everyone, so wait for it to confirm the page rendered. + await expect( + page.locator(".ab-heading", { hasText: "E1_ALL" }), + ).toBeVisible(); + + for (const label of ALL_LABELS) { + const heading = page.locator(".ab-heading", { hasText: label }); + if (expectedLabels.includes(label)) { + await expect(heading, `${label} should be visible`).toBeVisible(); + } else { + await expect(heading, `${label} should be hidden`).toHaveCount(0); + } + } + }; + + // Order matters: the anonymous visit runs first on purpose. The original bug + // was that an anonymous request and a logged-in visitor with no role shared a + // server-side cache entry, so the first visitor's element set was served to + // both. Visiting anonymously first is what poisons that shared entry, so if + // the fix regresses the logged-in-no-role assertion below will fail. + await assertVisibleFor(null, EXPECTED.anonymous); + await assertVisibleFor(blankAuth.refreshToken, EXPECTED.loggedInNoRole); + await assertVisibleFor(editorAuth.refreshToken, EXPECTED.loggedInEditor); + }); +}); diff --git a/web-frontend/modules/core/assets/scss/components/data_table.scss b/web-frontend/modules/core/assets/scss/components/data_table.scss index 1f5e2ac320..d2744a2ea3 100644 --- a/web-frontend/modules/core/assets/scss/components/data_table.scss +++ b/web-frontend/modules/core/assets/scss/components/data_table.scss @@ -46,6 +46,22 @@ display: flex; } +.data-table__filters { + display: flex; + align-items: center; + gap: 12px; + padding: 0 40px; + margin-bottom: 20px; +} + +.data-table__cell-link { + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + .data-table__body { min-height: 0; height: 100%; diff --git a/web-frontend/modules/core/components/admin/users/UsersAdminTable.vue b/web-frontend/modules/core/components/admin/users/UsersAdminTable.vue index 87f6fc1f46..d2312fea0a 100644 --- a/web-frontend/modules/core/components/admin/users/UsersAdminTable.vue +++ b/web-frontend/modules/core/components/admin/users/UsersAdminTable.vue @@ -3,6 +3,7 @@ :columns="columns" :service="service" row-id-key="id" + :default-search="defaultSearch" @row-context="onRowContext" >