1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-05-13 04:41:43 +00:00

Add Page Visibility

This commit is contained in:
Tsering Paljor 2024-11-26 12:06:14 +00:00
parent 70cdbf6ebf
commit 6529014cdf
39 changed files with 2691 additions and 239 deletions

View file

@ -156,7 +156,16 @@ class PublicPageSerializer(serializers.ModelSerializer):
class Meta:
model = Page
fields = ("id", "name", "path", "path_params", "shared")
fields = (
"id",
"name",
"path",
"path_params",
"shared",
"visibility",
"role_type",
"roles",
)
extra_kwargs = {
"id": {"read_only": True},
"builder_id": {"read_only": True},
@ -237,6 +246,10 @@ class PublicBuilderSerializer(serializers.ModelSerializer):
"the favicon settings."
)
login_page_id = serializers.IntegerField(
help_text=Builder._meta.get_field("login_page").help_text
)
class Meta:
model = Builder
fields = (
@ -247,6 +260,7 @@ class PublicBuilderSerializer(serializers.ModelSerializer):
"theme",
"user_sources",
"favicon_file",
"login_page_id",
)
@extend_schema_field(PublicPageSerializer(many=True))

View file

@ -28,11 +28,25 @@ class PageSerializer(serializers.ModelSerializer):
class Meta:
model = Page
fields = ("id", "name", "path", "path_params", "order", "builder_id", "shared")
fields = (
"id",
"name",
"path",
"path_params",
"order",
"builder_id",
"shared",
"visibility",
"role_type",
"roles",
)
extra_kwargs = {
"id": {"read_only": True},
"builder_id": {"read_only": True},
"shared": {"read_only": True},
"visibility": {"read_only": True},
"role_type": {"read_only": True},
"roles": {"read_only": True},
"order": {"help_text": "Lowest first."},
}
@ -50,8 +64,14 @@ class UpdatePageSerializer(serializers.ModelSerializer):
class Meta:
model = Page
fields = ("name", "path", "path_params")
extra_kwargs = {"name": {"required": False}, "path": {"required": False}}
fields = ("name", "path", "path_params", "visibility", "role_type", "roles")
extra_kwargs = {
"name": {"required": False},
"path": {"required": False},
"visibility": {"required": False},
"role_type": {"required": False},
"roles": {"required": False},
}
class OrderPagesSerializer(serializers.Serializer):

View file

@ -2,6 +2,7 @@ from typing import List
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from rest_framework.exceptions import ValidationError as DRFValidationError
from baserow.contrib.builder.api.pages.serializers import PageSerializer
from baserow.contrib.builder.api.theme.serializers import (
@ -10,6 +11,7 @@ from baserow.contrib.builder.api.theme.serializers import (
)
from baserow.contrib.builder.models import Builder
from baserow.contrib.builder.operations import ListPagesBuilderOperationType
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.core.handler import CoreHandler
@ -67,3 +69,17 @@ class BuilderSerializer(serializers.ModelSerializer):
@extend_schema_field(CombinedThemeConfigBlocksSerializer())
def get_theme(self, instance):
return serialize_builder_theme(instance)
def validate_login_page_id(self, value: int) -> int:
"""Validate the Builder's login_page."""
# Although only possible via the API, setting the login_page to the
# shared page shouldn't be allowed because the shared page isn't
# a real page.
if value and PageHandler().get_page(value).shared:
raise DRFValidationError(
detail="The login page cannot be a shared page.",
code="invalid_login_page_id",
)
return value

View file

@ -7,6 +7,8 @@ from django.db import transaction
from django.db.transaction import Atomic
from django.urls import include, path
from rest_framework import serializers
from baserow.contrib.builder.builder_beta_init_application import (
BuilderApplicationTypeInitApplication,
)
@ -46,9 +48,10 @@ class BuilderApplicationType(ApplicationType):
"pages",
"theme",
"favicon_file",
"login_page_id",
]
allowed_fields = ["favicon_file"]
request_serializer_field_names = ["favicon_file"]
allowed_fields = ["favicon_file", "login_page_id"]
request_serializer_field_names = ["favicon_file", "login_page_id"]
serializer_mixins = [lazy_get_instance_serializer_class]
# Builder applications are imported second.
@ -67,6 +70,9 @@ class BuilderApplicationType(ApplicationType):
help_text="The favicon image file",
validators=[image_file_validation],
),
"login_page_id": serializers.IntegerField(
allow_null=True, required=False, default=None
),
}
def get_api_urls(self):
@ -195,6 +201,7 @@ class BuilderApplicationType(ApplicationType):
theme=serialized_theme,
user_sources=serialized_user_sources,
favicon_file=serialized_favicon_file,
login_page=builder.login_page,
**serialized_builder,
)
@ -381,6 +388,11 @@ class BuilderApplicationType(ApplicationType):
builder.favicon_file = favicon_file
builder.save()
if login_page := serialized_values.pop("login_page", None):
if login_page_id := id_mapping["builder_pages"].get(login_page.id, None):
builder.login_page_id = login_page_id
builder.save()
ThemeHandler().import_theme(builder, serialized_theme, id_mapping)
return builder

View file

@ -1,7 +1,9 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser
from django.db.models import Q, QuerySet
from baserow.contrib.builder.elements.operations import ListElementsPageOperationType
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.workflow_actions.operations import (
DispatchBuilderWorkflowActionOperationType,
ListBuilderWorkflowActionsPageOperationType,
@ -166,6 +168,27 @@ class ElementVisibilityPermissionManager(PermissionManagerType):
return queryset
def exclude_elements_with_page_visibility(
self,
queryset: QuerySet,
actor: AbstractUser,
) -> QuerySet:
"""
Update the queryset by excluding all Elements that the user isn't
allowed to view, based on the Page visibility settings.
"""
if not getattr(actor, "is_authenticated", False):
return queryset.exclude(page__visibility=Page.VISIBILITY_TYPES.LOGGED_IN)
return queryset.exclude(
page__role_type=Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
page__roles__contains=actor.role,
).exclude(
Q(page__role_type=Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT)
& ~Q(page__roles__contains=actor.role),
)
def exclude_elements_with_visibility(
self,
queryset: QuerySet,
@ -204,6 +227,7 @@ class ElementVisibilityPermissionManager(PermissionManagerType):
"""Filters out invisible elements and their workflow actions."""
if operation_name == ListElementsPageOperationType.type:
queryset = self.exclude_elements_with_page_visibility(queryset, actor)
if getattr(actor, "is_authenticated", False):
queryset = self.exclude_elements_with_visibility(
queryset, Element.VISIBILITY_TYPES.NOT_LOGGED
@ -221,6 +245,8 @@ class ElementVisibilityPermissionManager(PermissionManagerType):
queryset, Element.VISIBILITY_TYPES.LOGGED_IN
)
elif operation_name == ListBuilderWorkflowActionsPageOperationType.type:
queryset = self.exclude_elements_with_page_visibility(queryset, actor)
prefix = "element__"
if getattr(actor, "is_authenticated", False):
queryset = self.exclude_elements_with_visibility(

View file

@ -0,0 +1,61 @@
# Generated by Django 5.0.9 on 2024-11-26 11:36
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("builder", "0040_datetimepickerelement"),
]
operations = [
migrations.AddField(
model_name="builder",
name="login_page",
field=models.OneToOneField(
help_text="The login page for this application. This is related to the visibility settings of builder pages.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="login_page",
to="builder.page",
),
),
migrations.AddField(
model_name="page",
name="role_type",
field=models.CharField(
choices=[
("allow_all", "Allow All"),
("allow_all_except", "Allow All Except"),
("disallow_all_except", "Disallow All Except"),
],
db_default="allow_all",
db_index=True,
default="allow_all",
help_text="Role type is used in conjunction with roles to control access to this page.",
max_length=19,
),
),
migrations.AddField(
model_name="page",
name="roles",
field=models.JSONField(
db_default=[],
default=list,
help_text="List of user roles that are associated with this page. Used in conjunction with role_type.",
),
),
migrations.AddField(
model_name="page",
name="visibility",
field=models.CharField(
choices=[("all", "All"), ("logged-in", "Logged In")],
db_default="all",
db_index=True,
default="all",
help_text="Controls the page's visibility. When set to 'logged-in', the builder's login_page must also be set.",
max_length=20,
),
),
]

View file

@ -32,6 +32,14 @@ class Builder(Application):
related_name="builder_favicon_file",
)
login_page = models.OneToOneField(
Page,
on_delete=models.SET_NULL,
help_text="The login page for this application. This is related to the visibility settings of builder pages.",
related_name="login_page",
null=True,
)
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)

View file

@ -434,6 +434,9 @@ class PageHandler:
elements=serialized_elements,
data_sources=serialized_data_sources,
workflow_actions=serialized_workflow_actions,
visibility=page.visibility,
role_type=page.role_type,
roles=page.roles,
)
def _ops_count_for_import_page(
@ -593,6 +596,9 @@ class PageHandler:
path=serialized_page["path"],
path_params=serialized_page["path_params"],
shared=False,
visibility=serialized_page["visibility"],
role_type=serialized_page["role_type"],
roles=serialized_page["roles"],
)
id_mapping["builder_pages"][serialized_page["id"]] = page_instance.id

View file

@ -27,6 +27,15 @@ class Page(
OrderableMixin,
models.Model,
):
class VISIBILITY_TYPES(models.TextChoices):
ALL = "all"
LOGGED_IN = "logged-in"
class ROLE_TYPES(models.TextChoices):
ALLOW_ALL = "allow_all"
ALLOW_ALL_EXCEPT = "allow_all_except"
DISALLOW_ALL_EXCEPT = "disallow_all_except"
builder = models.ForeignKey("builder.Builder", on_delete=models.CASCADE)
order = models.PositiveIntegerField()
name = models.CharField(max_length=255)
@ -40,6 +49,29 @@ class Page(
# directly. They are created on demand when a shared element is created.
shared = models.BooleanField(default=False, db_default=False)
visibility = models.CharField(
choices=VISIBILITY_TYPES.choices,
max_length=20,
db_index=True,
default=VISIBILITY_TYPES.ALL,
db_default=VISIBILITY_TYPES.ALL,
help_text="Controls the page's visibility. When set to 'logged-in', the builder's login_page must also be set.",
)
role_type = models.CharField(
choices=ROLE_TYPES.choices,
max_length=19,
db_index=True,
default=ROLE_TYPES.ALLOW_ALL,
db_default=ROLE_TYPES.ALLOW_ALL,
help_text="Role type is used in conjunction with roles to control access to this page.",
)
roles = models.JSONField(
default=list,
db_default=[],
help_text="List of user roles that are associated with this page. Used in conjunction with role_type.",
)
class Meta:
ordering = (
"-shared", # First page is the shared one if any.

View file

@ -119,7 +119,9 @@ class PageService:
context=page,
)
allowed_updates = extract_allowed(kwargs, ["name", "path", "path_params"])
allowed_updates = extract_allowed(
kwargs, ["name", "path", "path_params", "visibility", "role_type", "roles"]
)
self.handler.update_page(page, **allowed_updates)

View file

@ -56,6 +56,9 @@ class PageDict(TypedDict):
elements: List[ElementDict]
data_sources: List[DataSourceDict]
workflow_actions: List[WorkflowAction]
visibility: Optional[str]
role_type: Optional[str]
roles: Optional[List[str]]
class BuilderDict(TypedDict):
@ -68,3 +71,4 @@ class BuilderDict(TypedDict):
user_sources: List[UserSourceDictSubClass]
theme: object
favicon_file: Optional[dict]
login_page: Optional[PageDict]

View file

@ -4,6 +4,7 @@ import pytest
from rest_framework.status import HTTP_200_OK
from baserow.contrib.builder.elements.models import Element
from baserow.contrib.builder.pages.models import Page
from baserow.core.user_sources.registries import user_source_type_registry
from baserow.core.user_sources.user_source_user import UserSourceUser
@ -13,6 +14,9 @@ def data_source_fixture(data_fixture):
"""A fixture to help test the PublicDispatchDataSourceView view."""
user, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(user=user)
database = data_fixture.create_database_application(workspace=workspace)
table, fields, rows = data_fixture.build_table(
user=user,
columns=[
@ -24,21 +28,29 @@ def data_source_fixture(data_fixture):
["Banana", "Yellow"],
["Cherry", "Purple"],
],
database=database,
)
builder = data_fixture.create_builder_application(user=user)
builder = data_fixture.create_builder_application(user=user, workspace=workspace)
builder_to = data_fixture.create_builder_application(workspace=None)
data_fixture.create_builder_custom_domain(builder=builder, published_to=builder_to)
public_page = data_fixture.create_builder_page(builder=builder_to)
integration = data_fixture.create_local_baserow_integration(
user=user, application=builder
)
page = data_fixture.create_builder_page(user=user, builder=builder)
return {
"user": user,
"token": token,
"page": page,
"public_page": public_page,
"integration": integration,
"table": table,
"rows": rows,
"fields": fields,
"builder_to": builder_to,
}
@ -78,7 +90,9 @@ def data_source_element_roles_fixture(data_fixture):
}
def create_user_table_and_role(data_fixture, user, builder, user_role):
def create_user_table_and_role(
data_fixture, user, builder, user_role, integration=None
):
"""Helper to create a User table with a particular user role."""
# Create the user table for the user_source
@ -96,9 +110,11 @@ def create_user_table_and_role(data_fixture, user, builder, user_role):
)
email_field, name_field, password_field, role_field = user_fields
integration = data_fixture.create_local_baserow_integration(
user=user, application=builder
)
if integration is None:
integration = data_fixture.create_local_baserow_integration(
user=user, application=builder
)
user_source = data_fixture.create_user_source(
user_source_type_registry.get("local_baserow").model_class,
application=builder,
@ -532,3 +548,322 @@ def test_dispatch_data_sources_list_rows_with_elements_and_role(
"results": expected_results,
},
}
@pytest.mark.django_db
def test_dispatch_data_sources_page_visibility_all_returns_elements(
api_client, data_fixture, data_source_fixture
):
"""
Test the DispatchDataSourcesView endpoint when the Page visibility allows
access to the user.
If the page's visibility is set to 'all' and the user is anonymous, the
endpoint should return elements.
"""
page = data_source_fixture["page"]
page.visibility = Page.VISIBILITY_TYPES.ALL
page.save()
data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
user=data_source_fixture["user"],
page=page,
integration=data_source_fixture["integration"],
table=data_source_fixture["table"],
)
# Create an element containing a formula
field = data_source_fixture["fields"][0]
data_fixture.create_builder_heading_element(
page=page,
value=f"get('data_source.{data_source.id}.0.field_{field.id}')",
)
url = reverse(
"api:builder:domains:public_dispatch_all",
kwargs={"page_id": page.id},
)
response = api_client.post(url, {}, format="json")
# Since Page visiblity is 'all', the response should contain the resolved
# formula even if the user is not logged in.
assert response.status_code == HTTP_200_OK
assert response.json() == {
str(data_source.id): {
"has_next_page": False,
"results": [
{f"field_{field.id}": "Apple"},
{f"field_{field.id}": "Banana"},
{f"field_{field.id}": "Cherry"},
],
},
}
@pytest.mark.django_db
@pytest.mark.parametrize(
"roles",
[
[],
["foo_role"],
],
)
def test_dispatch_data_sources_page_visibility_logged_in_allow_all_returns_elements(
api_client, data_fixture, data_source_fixture, roles
):
"""
Test the DispatchDataSourcesView endpoint when the Page visibility allows
access to the user.
If the page's visibility is set to 'logged-in' and the role_type is set to
'allow_all', the endpoint should return elements regardless of the user's
current role.
"""
page = data_source_fixture["public_page"]
page.visibility = Page.VISIBILITY_TYPES.LOGGED_IN
page.role_type = Page.ROLE_TYPES.ALLOW_ALL
page.roles = roles
page.save()
integration = data_source_fixture["integration"]
user = data_source_fixture["user"]
user_source, _ = create_user_table_and_role(
data_fixture,
user,
data_source_fixture["builder_to"],
"foo_role",
integration=integration,
)
user_source_user = UserSourceUser(
user_source, None, 1, "foo_username", "foo@bar.com"
)
user_token = user_source_user.get_refresh_token().access_token
data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
user=data_source_fixture["user"],
page=page,
integration=data_source_fixture["integration"],
table=data_source_fixture["table"],
)
# Create an element containing a formula
field = data_source_fixture["fields"][0]
data_fixture.create_builder_heading_element(
page=page,
value=f"get('data_source.{data_source.id}.0.field_{field.id}')",
)
url = reverse(
"api:builder:domains:public_dispatch_all",
kwargs={"page_id": page.id},
)
response = api_client.post(
url, {}, format="json", HTTP_AUTHORIZATION=f"JWT {user_token}"
)
# Since the request was made with an anonymous user and the Page visiblity
# is 'logged-in', the response should *not* contain any resolved formulas.
assert response.status_code == HTTP_200_OK
assert response.json() == {
str(data_source.id): {
"has_next_page": False,
"results": [
{f"field_{field.id}": "Apple"},
{f"field_{field.id}": "Banana"},
{f"field_{field.id}": "Cherry"},
],
},
}
@pytest.mark.django_db
def test_dispatch_data_sources_page_visibility_logged_in_returns_no_elements_for_anon(
api_client, data_fixture, data_source_fixture
):
"""
Test the DispatchDataSourcesView endpoint when the Page visibility denies
access to the user.
If the page's visibility is set to 'logged-in' and the user is anonymous, the
endpoint should return zero elements.
"""
page = data_source_fixture["page"]
page.visibility = Page.VISIBILITY_TYPES.LOGGED_IN
page.save()
data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
user=data_source_fixture["user"],
page=page,
integration=data_source_fixture["integration"],
table=data_source_fixture["table"],
)
# Create an element containing a formula
data_fixture.create_builder_heading_element(
page=page,
value=f"get('data_source.{data_source.id}.0.field_{data_source_fixture['fields'][0].id}')",
)
url = reverse(
"api:builder:domains:public_dispatch_all",
kwargs={"page_id": page.id},
)
response = api_client.post(url, {}, format="json")
# Since the request was made with an anonymous user and the Page visiblity
# is 'logged-in', the response should *not* contain any resolved formulas.
assert response.status_code == HTTP_200_OK
assert response.json() == {
str(data_source.id): {
"has_next_page": False,
"results": [{}] * 3,
},
}
@pytest.mark.django_db
@pytest.mark.parametrize(
"user_role,role_type,roles,is_allowed",
[
(
"foo_role",
Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
[],
# Allowed because "foo_role" isn't excluded
True,
),
(
"foo_role",
Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
["bar_role"],
# Allowed because "foo_role" isn't excluded
True,
),
(
"",
Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
["bar_role"],
# Allowed because "" isn't excluded
True,
),
(
"foo_role",
Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
["foo_role"],
# Disallowed because "foo_role" is excluded
False,
),
(
"foo_role",
Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
["foo_role"],
# Allowed because "foo_role" isn't excluded
True,
),
(
"",
Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
["foo_role"],
# Disallowed because "" isn't excluded
False,
),
(
"foo_role",
Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
[],
# Disallowed because "foo_role" isn't excluded
False,
),
(
"foo_role",
Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
["foo_role"],
# Allowed because "foo_role" is excluded
True,
),
],
)
def test_dispatch_data_sources_page_visibility_logged_in_allow_all_except(
api_client,
data_fixture,
data_source_fixture,
user_role,
role_type,
roles,
is_allowed,
):
"""
Test the DispatchDataSourcesView endpoint when the Page visibility allows
access to the user.
If the page's visibility is set to 'logged-in' and the role_type is set to
'allow_all', the endpoint should return elements regardless of the user's
current role.
"""
page = data_source_fixture["public_page"]
page.visibility = Page.VISIBILITY_TYPES.LOGGED_IN
page.role_type = role_type
page.roles = roles
page.save()
integration = data_source_fixture["integration"]
user = data_source_fixture["user"]
user_source, _ = create_user_table_and_role(
data_fixture,
user,
data_source_fixture["builder_to"],
user_role,
integration=integration,
)
user_source_user = UserSourceUser(
user_source, None, 1, "foo_username", "foo@bar.com"
)
user_token = user_source_user.get_refresh_token().access_token
data_source = data_fixture.create_builder_local_baserow_list_rows_data_source(
user=user,
page=page,
integration=integration,
table=data_source_fixture["table"],
)
# Create an element containing a formula
field = data_source_fixture["fields"][0]
data_fixture.create_builder_heading_element(
page=page,
value=f"get('data_source.{data_source.id}.0.field_{field.id}')",
)
url = reverse(
"api:builder:domains:public_dispatch_all",
kwargs={"page_id": page.id},
)
response = api_client.post(
url, {}, format="json", HTTP_AUTHORIZATION=f"JWT {user_token}"
)
assert response.status_code == HTTP_200_OK
if is_allowed:
expected_results = [
{f"field_{field.id}": "Apple"},
{f"field_{field.id}": "Banana"},
{f"field_{field.id}": "Cherry"},
]
else:
expected_results = [{}, {}, {}]
assert response.json() == {
str(data_source.id): {
"has_next_page": False,
"results": expected_results,
},
}

View file

@ -7,6 +7,7 @@ from rest_framework.status import HTTP_200_OK
from baserow.api.user_files.serializers import UserFileSerializer
from baserow.contrib.builder.models import Builder
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.theme.registries import theme_config_block_registry
@ -170,6 +171,7 @@ def test_get_builder_application(api_client, data_fixture):
"name": workspace.name,
"generative_ai_models_enabled": {},
},
"login_page_id": None,
"pages": [
{
"id": application.page_set.get(shared=True).id,
@ -179,6 +181,9 @@ def test_get_builder_application(api_client, data_fixture):
"path": "__shared__",
"path_params": [],
"shared": True,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
},
],
}
@ -225,6 +230,7 @@ def test_list_builder_applications(api_client, data_fixture):
"name": workspace.name,
"generative_ai_models_enabled": {},
},
"login_page_id": None,
"pages": [
{
"id": application.page_set.get(shared=True).id,
@ -234,6 +240,9 @@ def test_list_builder_applications(api_client, data_fixture):
"path": "__shared__",
"path_params": [],
"shared": True,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
},
],
}

View file

@ -0,0 +1,90 @@
"""Test the BuilderSerializer serializer."""
from django.shortcuts import reverse
import pytest
from baserow.contrib.builder.api.serializers import BuilderSerializer
@pytest.fixture()
def builder_fixture(data_fixture):
"""A fixture to help test the BuilderSerializer."""
user, token = data_fixture.create_user_and_token()
workspace = data_fixture.create_workspace(user=user)
builder = data_fixture.create_builder_application(workspace=workspace)
page = data_fixture.create_builder_page(user=user, builder=builder)
return {
"builder": builder,
"page": page,
"user": user,
"token": token,
}
@pytest.mark.django_db
def test_serializer_has_expected_fields(builder_fixture):
"""Ensure the serializer returns the expected fields."""
serializer = BuilderSerializer(instance=builder_fixture["builder"])
assert sorted(serializer.data.keys()) == [
"id",
"integrations",
"name",
"pages",
"theme",
"user_sources",
]
@pytest.mark.django_db
def test_validate_login_page_id_raises_error_if_shared_page(
api_client, builder_fixture
):
"""Ensure that only non-shared pages can be used as the login_page."""
builder = builder_fixture["builder"]
# Set the builder's page to be the shared page
shared_page = builder.page_set.get(shared=True)
response = api_client.patch(
reverse("api:applications:item", kwargs={"application_id": builder.id}),
{"login_page_id": shared_page.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {builder_fixture['token']}",
)
assert response.status_code == 400
assert response.json() == {
"detail": {
"login_page_id": [
{
"code": "invalid_login_page_id",
"error": "The login page cannot be a shared page.",
},
],
},
"error": "ERROR_REQUEST_BODY_VALIDATION",
}
@pytest.mark.django_db
def test_login_page_is_saved(api_client, builder_fixture):
"""Ensure that a valid page can be set as the Builder's login_page."""
builder = builder_fixture["builder"]
assert builder.login_page is None
page = builder_fixture["page"]
response = api_client.patch(
reverse("api:applications:item", kwargs={"application_id": builder.id}),
{"login_page_id": page.id},
format="json",
HTTP_AUTHORIZATION=f"JWT {builder_fixture['token']}",
)
assert response.status_code == 200
builder.refresh_from_db()
assert builder.login_page == page

View file

@ -7,6 +7,7 @@ from baserow.contrib.builder.elements.operations import ListElementsPageOperatio
from baserow.contrib.builder.elements.permission_manager import (
ElementVisibilityPermissionManager,
)
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.workflow_actions.models import BuilderWorkflowAction
from baserow.contrib.builder.workflow_actions.operations import (
DispatchBuilderWorkflowActionOperationType,
@ -590,25 +591,25 @@ def test_queryset_only_includes_elements_allowed_by_role(
)
# Create a workflow action connected to the element that requires the role
workflow_action_logged_in = (
data_fixture.create_local_baserow_create_row_workflow_action(
page=public_page, element=element
)
data_fixture.create_local_baserow_create_row_workflow_action(
page=public_page, element=element
)
perm_manager = ElementVisibilityPermissionManager()
for operation_type in [
ListBuilderWorkflowActionsPageOperationType.type,
elements = perm_manager.filter_queryset(
public_user_source_user,
ListElementsPageOperationType.type,
]:
elements = perm_manager.filter_queryset(
public_user_source_user,
ListElementsPageOperationType.type,
Element.objects.all(),
)
Element.objects.all(),
)
assert len(elements) == element_count
assert len(elements) == element_count
actions = perm_manager.filter_queryset(
public_user_source_user,
ListBuilderWorkflowActionsPageOperationType.type,
BuilderWorkflowAction.objects.all(),
)
assert len(actions) == element_count
@pytest.mark.django_db
@ -970,3 +971,167 @@ def test_auth_user_can_view_element_returns_true(
result = perm_manager.auth_user_can_view_element(user_source_user, element)
assert result is True
@pytest.mark.django_db
@pytest.mark.parametrize(
"page_visibility,page_role_type,page_roles,element_visibility,element_role_type,element_roles,user_role,expected_count",
[
# Both Page and Element visibility is permissive, so the Element
# is returned.
(
Page.VISIBILITY_TYPES.ALL,
Page.ROLE_TYPES.ALLOW_ALL,
[],
Element.VISIBILITY_TYPES.ALL,
Element.ROLE_TYPES.ALLOW_ALL,
[],
"",
1,
),
(
Page.VISIBILITY_TYPES.ALL,
Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
[],
Element.VISIBILITY_TYPES.LOGGED_IN,
Element.ROLE_TYPES.ALLOW_ALL_EXCEPT,
[],
"foo_role",
1,
),
# Page allows visibility but Element does not, thus the Element
# shouldn't be returned.
(
Page.VISIBILITY_TYPES.ALL,
Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
[],
Element.VISIBILITY_TYPES.LOGGED_IN,
Element.ROLE_TYPES.ALLOW_ALL_EXCEPT,
["foo_role"],
"foo_role",
0,
),
(
Page.VISIBILITY_TYPES.ALL,
Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
["foo_role"],
Element.VISIBILITY_TYPES.LOGGED_IN,
Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
[],
"foo_role",
0,
),
# Page disallows visibility due to role, so despite the Element allowing
# access, it shouldn't be returned.
(
Page.VISIBILITY_TYPES.LOGGED_IN,
Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
["foo_role"],
Element.VISIBILITY_TYPES.LOGGED_IN,
Element.ROLE_TYPES.ALLOW_ALL,
[],
"foo_role",
0,
),
(
Page.VISIBILITY_TYPES.LOGGED_IN,
Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
[],
Element.VISIBILITY_TYPES.LOGGED_IN,
Element.ROLE_TYPES.ALLOW_ALL,
[],
"foo_role",
0,
),
# Page allows visibility and the Element role matches the user role, so
# the Element should be returned.
(
Page.VISIBILITY_TYPES.LOGGED_IN,
Page.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
["foo_role"],
Element.VISIBILITY_TYPES.LOGGED_IN,
Element.ROLE_TYPES.ALLOW_ALL_EXCEPT,
[],
"foo_role",
1,
),
(
Page.VISIBILITY_TYPES.LOGGED_IN,
Page.ROLE_TYPES.ALLOW_ALL_EXCEPT,
[],
Element.VISIBILITY_TYPES.LOGGED_IN,
Element.ROLE_TYPES.DISALLOW_ALL_EXCEPT,
["foo_role"],
"foo_role",
1,
),
],
)
def test_page_visibility_applied_to_workflow_actions_queryset(
ab_builder_user_page,
data_fixture,
page_visibility,
page_role_type,
page_roles,
element_visibility,
element_role_type,
element_roles,
user_role,
expected_count,
):
"""
Test the ElementVisibilityPermissionManager. Ensure that both Elements
and Workflow Actions are filtered based on the Page visibility settings.
"""
public_user_source, _, public_page = ab_builder_user_page
builder = public_page.builder
# Set the login page
login_page = data_fixture.create_builder_page(builder=builder)
builder.login_page = login_page
# Ensure the page is hidden
public_page.visibility = page_visibility
public_page.role_type = page_role_type
public_page.roles = page_roles
public_page.save()
public_user_source_user = UserSourceUser(
public_user_source,
None,
1,
"foo_username",
"foo@bar.com",
user_role,
)
# Create an element
element = data_fixture.create_builder_button_element(
page=public_page,
visibility=element_visibility,
roles=element_roles,
role_type=element_role_type,
)
# Create a workflow action connected to the element that requires the role
data_fixture.create_local_baserow_create_row_workflow_action(
page=public_page, element=element
)
perm_manager = ElementVisibilityPermissionManager()
elements = perm_manager.filter_queryset(
public_user_source_user,
ListElementsPageOperationType.type,
Element.objects.all(),
)
assert len(elements) == expected_count
actions = perm_manager.filter_queryset(
public_user_source_user,
ListBuilderWorkflowActionsPageOperationType.type,
BuilderWorkflowAction.objects.all(),
)
assert len(actions) == expected_count

View file

@ -11,6 +11,7 @@ from baserow.contrib.builder.elements.models import (
LinkElement,
)
from baserow.contrib.builder.pages.handler import PageHandler
from baserow.contrib.builder.pages.models import Page
from baserow.contrib.builder.workflow_actions.models import NotificationWorkflowAction
from baserow.contrib.database.rows.handler import RowHandler
from baserow.core.utils import MirrorDict
@ -43,6 +44,9 @@ def test_repeat_element_import_child_with_formula_with_current_record(data_fixtu
"order": 8,
"path": "/page",
"path_params": [],
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
"elements": [
{
"id": 23,

View file

@ -194,6 +194,9 @@ def test_builder_application_export(data_fixture):
"path": shared_page.path,
"path_params": shared_page.path_params,
"shared": True,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
"workflow_actions": [],
"data_sources": [
{
@ -222,6 +225,9 @@ def test_builder_application_export(data_fixture):
"path": page1.path,
"path_params": page1.path_params,
"shared": False,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
"workflow_actions": [
{
"id": workflow_action_1.id,
@ -398,6 +404,9 @@ def test_builder_application_export(data_fixture):
"order": page2.order,
"path": page2.path,
"path_params": page2.path_params,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
"shared": False,
"workflow_actions": [],
"data_sources": [
@ -661,6 +670,7 @@ def test_builder_application_export(data_fixture):
"order": builder.order,
"type": "builder",
"favicon_file": None,
"login_page": None,
}
assert serialized == reference
@ -674,6 +684,9 @@ IMPORT_REFERENCE = {
"order": 1,
"path": "/test",
"path_params": {},
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
"workflow_actions": [
{
"id": 123,
@ -855,6 +868,9 @@ IMPORT_REFERENCE = {
"path_params": {},
"workflow_actions": [],
"shared": False,
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
"elements": [
{
"id": 997,
@ -1063,6 +1079,9 @@ IMPORT_REFERENCE_COMPLEX = {
"path": "/test2",
"path_params": {},
"workflow_actions": [],
"visibility": Page.VISIBILITY_TYPES.ALL.value,
"role_type": Page.ROLE_TYPES.ALLOW_ALL.value,
"roles": [],
"elements": [
{
"id": 997,
@ -1222,6 +1241,48 @@ def test_builder_application_imports_favicon_file(data_fixture, tmpdir):
assert builder.favicon_file == user_file
@pytest.mark.django_db
def test_builder_application_does_not_import_login_page(data_fixture):
"""
Ensure the importer doesn't attempt to import the login_page if it
doesn't exist in the serialized values.
"""
user = data_fixture.create_user(email="test@baserow.io")
workspace = data_fixture.create_workspace(user=user)
config = ImportExportConfig(include_permission_data=True)
serialized_values = IMPORT_REFERENCE.copy()
serialized_values.pop("login_page", None)
builder = BuilderApplicationType().import_serialized(
workspace, serialized_values, config, {}
)
assert builder.login_page is None
@pytest.mark.django_db
def test_builder_application_imports_login_page(data_fixture):
"""Ensure the login_page is imported and saved to the builder."""
user = data_fixture.create_user()
workspace = data_fixture.create_workspace(user=user)
builder = data_fixture.create_builder_application(user=user, workspace=workspace)
page = data_fixture.create_builder_page(builder=builder, name="foo_login_page")
builder.login_page = page
builder.save()
config = ImportExportConfig(include_permission_data=True)
serialized = BuilderApplicationType().export_serialized(builder, config)
new_builder = BuilderApplicationType().import_serialized(
workspace, serialized, config, {}
)
assert new_builder.login_page.name == "foo_login_page"
@pytest.mark.django_db
def test_delete_builder_application_with_published_builder(data_fixture):
builder = data_fixture.create_builder_application()

View file

@ -305,15 +305,19 @@ def test_get_element_property_names_returns_property_names(data_fixture):
data_source_1.service.id,
data_source_2.service.id,
]
assert sorted(list(result["external"][data_source_1.service.id])) == [
# Since only the first two fields are used by elements in this page,
# we expect to see _only_ those two fields.
f"field_{fields[0].id}",
f"field_{fields[1].id}",
]
assert sorted(list(result["external"][data_source_2.service.id])) == [
f"field_{fields[2].id}",
]
assert sorted(list(result["external"][data_source_1.service.id])) == sorted(
[
# Since only the first two fields are used by elements in this page,
# we expect to see _only_ those two fields.
f"field_{fields[0].id}",
f"field_{fields[1].id}",
]
)
assert sorted(list(result["external"][data_source_2.service.id])) == sorted(
[
f"field_{fields[2].id}",
]
)
@pytest.mark.django_db

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "[Builder] Improve Builder security with Page level Visibility.",
"issue_number": 2392,
"bullet_points": [],
"created_at": "2024-11-06"
}

View file

@ -77,9 +77,10 @@ export class BuilderApplicationType extends ApplicationType {
delete(application) {
const { store, router } = this.app
const pageSelected = store.getters['page/getAllPages'](application).some(
(page) => page._.selected
)
const pageSelected = store.getters['page/getVisiblePages'](
application
).some((page) => page._.selected)
if (pageSelected) {
router.push({ name: 'dashboard' })
}
@ -141,6 +142,14 @@ export class BuilderApplicationType extends ApplicationType {
}
prepareForStoreUpdate(application, data) {
if (Object.prototype.hasOwnProperty.call(data, 'pages')) {
delete data.pages
}
if (Object.prototype.hasOwnProperty.call(data, 'theme')) {
delete data.theme
}
return data
}

View file

@ -78,217 +78,24 @@
</template>
<script>
import { mapGetters } from 'vuex'
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import UserSourceService from '@baserow/modules/core/services/userSource'
import visibilityForm from '@baserow/modules/builder/mixins/visibilityForm'
import {
DEFAULT_USER_ROLE_PREFIX,
VISIBILITY_ALL,
VISIBILITY_NOT_LOGGED,
VISIBILITY_LOGGED_IN,
ROLE_TYPE_ALLOW_ALL,
ROLE_TYPE_ALLOW_EXCEPT,
ROLE_TYPE_DISALLOW_EXCEPT,
} from '@baserow/modules/builder/constants'
export default {
name: 'VisibilityForm',
mixins: [elementForm],
mixins: [visibilityForm],
data() {
return {
allRoles: [],
values: {
visibility: VISIBILITY_ALL,
roles: [],
role_type: ROLE_TYPE_ALLOW_ALL,
},
visibilityLoggedIn: VISIBILITY_LOGGED_IN,
visibilityAll: VISIBILITY_ALL,
visibilityNotLogged: VISIBILITY_NOT_LOGGED,
loadingRoles: true,
}
},
computed: {
...mapGetters({
elementSelected: 'element/getSelected',
}),
allowAllRolesExceptSelected() {
return this.values.role_type === ROLE_TYPE_ALLOW_EXCEPT
},
disallowAllRolesExceptSelected() {
return this.values.role_type === ROLE_TYPE_DISALLOW_EXCEPT
},
roleTypeOptions() {
return {
[ROLE_TYPE_ALLOW_ALL]: this.$t('visibilityForm.roleTypeAllowAllRoles'),
[ROLE_TYPE_DISALLOW_EXCEPT]: this.$t(
'visibilityForm.roleTypeDisallowAllRolesExcept'
),
[ROLE_TYPE_ALLOW_EXCEPT]: this.$t(
'visibilityForm.roleTypeAllowAllRolesExcept'
),
}
},
userSources() {
return this.$store.getters['userSource/getUserSources'](this.builder)
},
/**
* When the user changes the role_type, e.g. 'Allow roles...',
* reset the element's roles.
*/
selectedRoleType: {
get() {
return this.values.role_type
},
set(newValue) {
this.values.role_type = newValue
if (
[ROLE_TYPE_ALLOW_EXCEPT, ROLE_TYPE_DISALLOW_EXCEPT].includes(newValue)
) {
this.values.roles = []
}
},
},
/**
* When the user changes the visibility, e.g. 'All visitors' radio button,
* reset the element's roles.
*/
selectedVisibility: {
get() {
return this.values.visibility
},
set(newValue) {
this.values.visibility = newValue
this.resetElementRoles()
},
},
},
watch: {
/**
* If the userSource changes, we want to ensure the roles for the element
* are still valid.
*/
userSources: {
handler() {
this.fetchAndSyncUserRoles()
},
deep: true,
},
},
async mounted() {
await this.fetchAndSyncUserRoles()
},
methods: {
checkRole(roleName) {
if (this.values.roles.includes(roleName)) {
this.values.roles = this.values.roles.filter(
(role) => role !== roleName
)
} else {
this.values.roles.push(roleName)
}
},
isChecked(roleName) {
return this.values.roles.includes(roleName)
},
async fetchAndSyncUserRoles() {
await this.fetchUserRoles()
this.syncUserRoles()
},
/**
* Fetch all valid user roles.
*/
async fetchUserRoles() {
this.loadingRoles = true
let result
try {
result = await UserSourceService(this.$client).fetchUserRoles(
this.builder.id
)
} catch (error) {
this.$store.dispatch('toast/error', {
title: this.$t('visibilityForm.errorFetchingRolesTitle'),
message: this.$t('visibilityForm.errorFetchingRolesMessage'),
})
return
} finally {
this.loadingRoles = false
}
let noRole = false
let allRoles = Array.from(
new Set(
result.data
.map((userSourceData) => {
return userSourceData.roles.filter((role) => {
if (role === '') {
noRole = true
}
return role !== ''
})
})
.flat()
)
).sort()
// If noRole exists, prefix to array
if (noRole) {
allRoles = ['', ...allRoles]
}
this.allRoles = allRoles
},
getRoleName(roleName) {
if (roleName === '') {
return this.$t('visibilityForm.noRole')
} else if (roleName.startsWith(DEFAULT_USER_ROLE_PREFIX)) {
const userSourceId = parseInt(
roleName.split(DEFAULT_USER_ROLE_PREFIX)[1]
)
const userSource = this.$store.getters['userSource/getUserSourceById'](
this.builder,
userSourceId
)
if (userSource) {
return this.$t('visibilityForm.rolesAllMembersOf', {
name: userSource.name,
})
}
}
return roleName
},
/**
* Ensure that an element's roles never contains any old or otherwise
* invalid roles. This is done by getting the latest valid roles from the
* backend and comparing it to the element's roles.
*/
syncUserRoles() {
const validRoles = this.allRoles.filter((role) => {
return this.values.roles.includes(role)
})
this.values.roles = validRoles
},
resetElementRoles() {
this.values.roles = []
this.values.role_type = ROLE_TYPE_ALLOW_ALL
},
selectAllRoles() {
this.values.roles = [...this.allRoles]
},
deselectAllRoles() {
this.values.roles = []
},
},
}
</script>

View file

@ -0,0 +1,50 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<FormGroup
horizontal-narrow
:label="$t('builderLoginPageForm.pageDropdownLabel')"
required
class="margin-top-4"
:help-icon-tooltip="$t('builderLoginPageForm.pageDropdownDescription')"
>
<Dropdown
v-model="values.login_page_id"
:placeholder="$t('builderLoginPageForm.pageDropdownPlaceholder')"
>
<DropdownItem
v-for="page in builderPages"
:key="page.id"
:name="page.name"
:value="page.id"
/>
</Dropdown>
</FormGroup>
</form>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
export default {
name: 'BuilderLoginPageForm',
mixins: [form],
props: {
builder: {
type: Object,
required: true,
},
},
data() {
return {
values: { login_page_id: null },
allowedValues: ['login_page_id'],
}
},
computed: {
builderPages() {
return this.$store.getters['page/getVisiblePages'](this.builder)
},
},
methods: {},
}
</script>

View file

@ -0,0 +1,165 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<FormGroup
:label="$t('pageVisibilitySettings.description')"
small-label
class="margin-bottom-2"
required
>
<div>
<Alert v-if="showLoginPageAlert && showLogInPageWarning" type="warning">
<slot name="title">{{
$t('pageVisibilitySettingsTypes.logInPageWarningTitle')
}}</slot>
<!-- eslint-disable-next-line vue/no-v-html vue/no-v-text-v-html-on-component -->
<p
v-html="$t('pageVisibilitySettingsTypes.logInPagewarningMessage')"
></p>
</Alert>
<Alert
v-else-if="showLoginPageAlert && !showLogInPageWarning"
type="info-primary"
>
<slot name="title">{{
$t('pageVisibilitySettingsTypes.logInPageInfoTitle')
}}</slot>
<!-- eslint-disable-next-line vue/no-v-html vue/no-v-text-v-html-on-component -->
<p
v-html="
$t('pageVisibilitySettingsTypes.logInPageInfoMessage', {
logInPageName: loginPageName,
})
"
></p>
</Alert>
</div>
<div class="margin-top-1 visibility-form__visibility-all">
<div>
<Radio v-model="values.visibility" :value="visibilityAll">
{{ $t('pageVisibilitySettings.allVisitors') }}
</Radio>
</div>
<div class="margin-left-2 visibility-form__visibility-logged-in">
<Expandable
:force-expanded="selectedVisibility === visibilityLoggedIn"
>
<template #header="{ toggle }">
<Radio
v-model="selectedVisibility"
:value="visibilityLoggedIn"
@click="toggle"
>
{{ $t('pageVisibilitySettings.loggedInVisitors') }}
</Radio>
</template>
<template #default>
<FormElement
class="control visibility-form__expanded-form-element"
>
<Dropdown
v-model="selectedRoleType"
:placeholder="$t('visibilityForm.roleTypesHint')"
>
<DropdownItem
v-for="(value, key) in roleTypeOptions"
:key="key"
:name="value"
:value="key"
>
</DropdownItem>
</Dropdown>
<div
v-if="
allowAllRolesExceptSelected ||
disallowAllRolesExceptSelected
"
class="visibility-form__role-checkbox-container"
>
<template v-if="loadingRoles">
<div class="loading margin-bottom-1"></div>
</template>
<template v-else>
<div
v-for="roleName in allRoles"
:key="roleName"
class="visibility-form__role-checkbox-div"
>
<Checkbox
:checked="isChecked(roleName)"
@input="checkRole(roleName)"
>
{{ getRoleName(roleName) }}
</Checkbox>
</div>
<div class="visibility-form__role-links">
<a @click.prevent="selectAllRoles">
{{ $t('visibilityForm.rolesSelectAll') }}
</a>
<a
class="visibility-form__role-links-deselect-all"
@click.prevent="deselectAllRoles"
>
{{ $t('visibilityForm.rolesDeselectAll') }}
</a>
</div>
</template>
</div>
</FormElement>
</template>
</Expandable>
</div>
</div>
</FormGroup>
</form>
</template>
<script>
import { StoreItemLookupError } from '@baserow/modules/core/errors'
import visibilityForm from '@baserow/modules/builder/mixins/visibilityForm'
import { VISIBILITY_LOGGED_IN } from '@baserow/modules/builder/constants'
export default {
name: 'PageVisibilityForm',
mixins: [visibilityForm],
data() {
return {
values: {
visibility: this.defaultValues.visibility,
roles: this.defaultValues.roles,
role_type: this.defaultValues.role_type,
},
allowedValues: ['visibility'],
}
},
computed: {
showLoginPageAlert() {
return this.selectedVisibility === VISIBILITY_LOGGED_IN
},
showLogInPageWarning() {
return !this.loginPageName
},
/**
* Return the login page's name or null if the page doesn't exist.
*/
loginPageName() {
try {
const loginPage = this.$store.getters['page/getById'](
this.builder,
this.builder.login_page_id
)
return loginPage.name
} catch (e) {
if (e instanceof StoreItemLookupError) {
return null
}
throw e
}
},
},
}
</script>

View file

@ -0,0 +1,43 @@
<template>
<div>
<h2 class="box__title">{{ $t('pageVisibilitySettings.title') }}</h2>
<div>
<PageVisibilityForm
:default-values="page"
@values-changed="updatePageVisibility"
/>
</div>
</div>
</template>
<script>
import error from '@baserow/modules/core/mixins/error'
import PageVisibilityForm from '@baserow/modules/builder/components/page/settings/PageVisibilityForm'
import { mapActions } from 'vuex'
export default {
name: 'PageVisibilitySettings',
components: { PageVisibilityForm },
mixins: [error],
inject: ['builder', 'page', 'workspace'],
data() {
return {}
},
methods: {
...mapActions({ actionUpdatePage: 'page/update' }),
async updatePageVisibility(values) {
this.hideError()
try {
await this.actionUpdatePage({
builder: this.builder,
page: this.page,
values,
})
} catch (error) {
this.handleError(error)
}
},
},
}
</script>

View file

@ -7,17 +7,25 @@
:default-values="builder"
@values-changed="updateApplication($event)"
/>
<BuilderLoginPageForm
:default-values="builder"
:builder="builder"
@values-changed="updateApplication($event)"
/>
</div>
</template>
<script>
import error from '@baserow/modules/core/mixins/error'
import BuilderGeneralSettingsForm from '@baserow/modules/builder/components/form/BuilderGeneralSettingsForm'
import BuilderLoginPageForm from '@baserow/modules/builder/components/form/BuilderLoginPageForm'
import _ from 'lodash'
export default {
name: 'GeneralSettings',
components: { BuilderGeneralSettingsForm },
components: { BuilderGeneralSettingsForm, BuilderLoginPageForm },
mixins: [error],
provide() {
return {

View file

@ -10,8 +10,13 @@
<Editable
ref="rename"
:value="page.name"
class="side-bar-builder__link-text"
@change="renamePage(builder, page, $event)"
></Editable>
<i
v-if="page.visibility === visibilityLoggedIn"
class="iconoir-eye-off"
></i>
</a>
<a
@ -79,6 +84,7 @@
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import { mapGetters } from 'vuex'
import { VISIBILITY_LOGGED_IN } from '@baserow/modules/builder/constants'
export default {
name: 'SidebarItemBuilder',
@ -118,6 +124,9 @@ export default {
)
)
},
visibilityLoggedIn() {
return VISIBILITY_LOGGED_IN
},
...mapGetters({ duplicateJob: 'page/getDuplicateJob' }),
},
watch: {

View file

@ -338,11 +338,24 @@
"pageSettingsTypes": {
"pageName": "Page"
},
"pageVisibilitySettingsTypes": {
"pageName": "Visibility",
"logInPageWarningTitle": "The Login Page is not set",
"logInPagewarningMessage": "When an anonymous user attempts to access this page, they will be redirected to a login page. Please set the Login Page in the Application's General settings.",
"logInPageInfoTitle": "Anonymous users will be redirected",
"logInPageInfoMessage": "Anonymous users attempting to access this page will be redirected to the {logInPageName} page."
},
"pageSettings": {
"title": "Page",
"pageUpdatedTitle": "Changed",
"pageUpdatedDescription": "The page settings have been updated."
},
"pageVisibilitySettings": {
"title": "Visibility",
"allVisitors": "All visitors",
"loggedInVisitors": "Logged-in visitors",
"description": "Who can see this page"
},
"pageForm": {
"defaultName": "Page",
"nameTitle": "Name",
@ -701,6 +714,11 @@
"authTitle": "Authentication",
"integrationFieldLabel": "Integration"
},
"builderLoginPageForm": {
"pageDropdownLabel": "Login Page",
"pageDropdownPlaceholder": "Select a page",
"pageDropdownDescription": "Select the Login Page that non-logged in users should be redirected to when they attempt to access a restricted page."
},
"formContainerElement": {
"submitDeactivatedText": "Complete all required fields to submit",
"defaultSubmitButtonLabel": "Submit"

View file

@ -0,0 +1,210 @@
import UserSourceService from '@baserow/modules/core/services/userSource'
import elementForm from '@baserow/modules/builder/mixins/elementForm'
import {
DEFAULT_USER_ROLE_PREFIX,
ROLE_TYPE_ALLOW_ALL,
ROLE_TYPE_ALLOW_EXCEPT,
ROLE_TYPE_DISALLOW_EXCEPT,
VISIBILITY_ALL,
VISIBILITY_LOGGED_IN,
VISIBILITY_NOT_LOGGED,
} from '@baserow/modules/builder/constants'
export default {
mixins: [elementForm],
data() {
return {
allRoles: [],
visibilityLoggedIn: VISIBILITY_LOGGED_IN,
visibilityAll: VISIBILITY_ALL,
visibilityNotLogged: VISIBILITY_NOT_LOGGED,
loadingRoles: true,
}
},
computed: {
allowAllRolesExceptSelected() {
return this.values.role_type === ROLE_TYPE_ALLOW_EXCEPT
},
disallowAllRolesExceptSelected() {
return this.values.role_type === ROLE_TYPE_DISALLOW_EXCEPT
},
roleTypeOptions() {
return {
[ROLE_TYPE_ALLOW_ALL]: this.$t('visibilityForm.roleTypeAllowAllRoles'),
[ROLE_TYPE_DISALLOW_EXCEPT]: this.$t(
'visibilityForm.roleTypeDisallowAllRolesExcept'
),
[ROLE_TYPE_ALLOW_EXCEPT]: this.$t(
'visibilityForm.roleTypeAllowAllRolesExcept'
),
}
},
userSources() {
return this.$store.getters['userSource/getUserSources'](this.builder)
},
/**
* When the user changes the role_type, e.g. 'Allow roles...',
* reset the Page or Element roles.
*/
selectedRoleType: {
get() {
return this.values.role_type
},
set(newValue) {
this.values.role_type = newValue
if (
[ROLE_TYPE_ALLOW_EXCEPT, ROLE_TYPE_DISALLOW_EXCEPT].includes(newValue)
) {
this.values.roles = []
}
},
},
/**
* When the user changes the visibility, e.g. 'All visitors' radio button,
* reset the Page or Element roles.
*/
selectedVisibility: {
get() {
return this.values.visibility
},
set(newValue) {
this.values.visibility = newValue
this.resetElementRoles()
},
},
},
watch: {
/**
* If the userSource changes, we want to ensure the roles for the
* Page/Element are still valid.
*/
userSources: {
handler() {
this.fetchAndSyncUserRoles()
},
deep: true,
},
},
async mounted() {
await this.fetchAndSyncUserRoles()
},
methods: {
checkRole(roleName) {
if (this.values.roles.includes(roleName)) {
this.values.roles = this.values.roles.filter(
(role) => role !== roleName
)
} else {
this.values.roles.push(roleName)
}
},
isChecked(roleName) {
return this.values.roles.includes(roleName)
},
async fetchAndSyncUserRoles() {
await this.fetchUserRoles()
this.syncUserRoles()
},
/**
* Fetch all valid user roles.
*/
async fetchUserRoles() {
this.loadingRoles = true
let result
try {
result = await UserSourceService(this.$client).fetchUserRoles(
this.builder.id
)
} catch (error) {
this.$store.dispatch('toast/error', {
title: this.$t('visibilityForm.errorFetchingRolesTitle'),
message: this.$t('visibilityForm.errorFetchingRolesMessage'),
})
return
} finally {
this.loadingRoles = false
}
let noRole = false
let allRoles = Array.from(
new Set(
result.data
.map((userSourceData) => {
return userSourceData.roles.filter((role) => {
if (role === '') {
noRole = true
}
return role !== ''
})
})
.flat()
)
).sort()
// If noRole exists, prefix to array
if (noRole) {
allRoles = ['', ...allRoles]
}
this.allRoles = allRoles
},
getRoleName(roleName) {
if (roleName === '') {
return this.$t('visibilityForm.noRole')
} else if (roleName.startsWith(DEFAULT_USER_ROLE_PREFIX)) {
const userSourceId = parseInt(
roleName.split(DEFAULT_USER_ROLE_PREFIX)[1]
)
const userSource = this.$store.getters['userSource/getUserSourceById'](
this.builder,
userSourceId
)
if (userSource) {
return this.$t('visibilityForm.rolesAllMembersOf', {
name: userSource.name,
})
}
}
return roleName
},
/**
* Ensure that the roles of a Page or Element never contain any old or
* otherwise invalid roles. This is done by getting the latest valid roles
* from the backend and comparing it to the Page/Element roles.
*/
syncUserRoles() {
const validRoles = this.allRoles.filter((role) => {
return this.values.roles.includes(role)
})
this.values.roles = validRoles
},
resetElementRoles() {
this.values.roles = []
this.values.role_type = ROLE_TYPE_ALLOW_ALL
},
selectAllRoles() {
this.values.roles = [...this.allRoles]
},
deselectAllRoles() {
this.values.roles = []
},
},
}

View file

@ -1,5 +1,6 @@
import { Registerable } from '@baserow/modules/core/registry'
import PageSettings from '@baserow/modules/builder/components/page/settings/PageSettings'
import PageVisibilitySettings from '@baserow/modules/builder/components/page/settings/PageVisibilitySettings'
export class PageSettingType extends Registerable {
static getType() {
@ -17,6 +18,10 @@ export class PageSettingType extends Registerable {
get component() {
return null
}
getOrder() {
return this.order
}
}
export class PagePageSettingsType extends PageSettingType {
@ -35,4 +40,30 @@ export class PagePageSettingsType extends PageSettingType {
get component() {
return PageSettings
}
getOrder() {
return 10
}
}
export class PageVisibilitySettingsType extends PageSettingType {
static getType() {
return 'page_visibility'
}
get name() {
return this.app.i18n.t('pageVisibilitySettingsTypes.pageName')
}
get icon() {
return 'iconoir-eye-empty'
}
get component() {
return PageVisibilitySettings
}
getOrder() {
return 20
}
}

View file

@ -2,6 +2,7 @@
<div>
<Toasts></Toasts>
<PageContent
v-if="canViewPage"
:page="page"
:path="path"
:params="params"
@ -18,6 +19,8 @@ import { DataProviderType } from '@baserow/modules/core/dataProviderTypes'
import Toasts from '@baserow/modules/core/components/toasts/Toasts'
import ApplicationBuilderFormulaInput from '@baserow/modules/builder/components/ApplicationBuilderFormulaInput'
import _ from 'lodash'
import { prefixInternalResolvedUrl } from '@baserow/modules/builder/utils/urlResolution'
import { userCanViewPage } from '@baserow/modules/builder/utils/visibility'
import {
getTokenIfEnoughTimeLeft,
@ -48,6 +51,7 @@ export default {
applicationContext: this.applicationContext,
}
},
async asyncData({
store,
params,
@ -204,6 +208,7 @@ export default {
mode,
})
// TODO: This doesn't appear to be doing anything...
// And finally select the page to display it
await store.dispatch('page/selectById', {
builder,
@ -240,6 +245,17 @@ export default {
mode: this.mode,
}
},
/**
* Returns true if the current user is allowed to view this page,
* otherwise returns false.
*/
canViewPage() {
return userCanViewPage(
this.$store.getters['userSourceUser/getUser'](this.builder),
this.$store.getters['userSourceUser/isAuthenticated'](this.builder),
this.page
)
},
dispatchContext() {
return DataProviderType.getAllDataSourceDispatchContext(
this.$registry.getAll('builderDataProvider'),
@ -316,11 +332,41 @@ export default {
}
},
},
isAuthenticated() {
// When the user login or logout, we need to refetch the elements and actions
// as they might have changed
async isAuthenticated() {
// When the user logs in or out, we need to refetch the elements and actions
// as they might have changed.
this.$store.dispatch('element/fetchPublished', { page: this.page })
this.$store.dispatch('workflowAction/fetchPublished', { page: this.page })
// If the user is on a hidden page, redirect them to the Login page if possible.
await this.maybeRedirectUserToLoginPage()
},
},
async mounted() {
await this.maybeRedirectUserToLoginPage()
},
methods: {
/**
* If the user does not have access to the current page, redirect them to
* the Login page if possible.
*/
async maybeRedirectUserToLoginPage() {
if (!this.canViewPage && this.builder.login_page_id) {
const loginPage = await this.$store.getters['page/getById'](
this.builder,
this.builder.login_page_id
)
const url = prefixInternalResolvedUrl(
loginPage.path,
this.builder,
'page',
this.mode
)
if (url !== this.$router.history.current?.fullPath) {
this.$router.push(url)
}
}
},
},
}

View file

@ -66,7 +66,10 @@ import {
CustomDomainType,
SubDomainType,
} from '@baserow/modules/builder/domainTypes'
import { PagePageSettingsType } from '@baserow/modules/builder/pageSettingsTypes'
import {
PagePageSettingsType,
PageVisibilitySettingsType,
} from '@baserow/modules/builder/pageSettingsTypes'
import {
TextPathParamType,
NumericPathParamType,
@ -240,6 +243,10 @@ export default (context) => {
app.$registry.register('domain', new SubDomainType(context))
app.$registry.register('pageSettings', new PagePageSettingsType(context))
app.$registry.register(
'pageSettings',
new PageVisibilitySettingsType(context)
)
app.$registry.register('pathParamType', new TextPathParamType(context))
app.$registry.register('pathParamType', new NumericPathParamType(context))

View file

@ -0,0 +1,41 @@
import {
VISIBILITY_ALL,
ROLE_TYPE_ALLOW_EXCEPT,
ROLE_TYPE_DISALLOW_EXCEPT,
ROLE_TYPE_ALLOW_ALL,
} from '@baserow/modules/builder/constants'
/**
* Evaluates the Page's visibility settings and the user's role. Returns true
* if the user is allowed to view the page. Otherwise, returns false.
*
* @param {Object} user The user object.
* @param {Boolean} isAuthenticated Whether the user is authenticated.
* @param {Object} page The Page to be evaluated.
* @returns {Boolean} True if the user is allowed to view the page, false otherwise.
*/
export function userCanViewPage(user, isAuthenticated, page) {
if (page.visibility === VISIBILITY_ALL) {
return true
}
// If visibility is 'logged-in' (i.e. not 'all') *and* the user isn't
// authenticated, disallow access.
if (!isAuthenticated) {
return false
}
if (page.role_type === ROLE_TYPE_ALLOW_EXCEPT) {
// Allow if the user's role isn't explicitly excluded
return !page.roles.includes(user.role)
} else if (page.role_type === ROLE_TYPE_DISALLOW_EXCEPT) {
// Allow if the user's role is explicitly included
return page.roles.includes(user.role)
} else if (page.role_type === ROLE_TYPE_ALLOW_ALL) {
// Allow if there are no page level role restrictions
return true
}
// Disallow access to the page by default
return false
}

View file

@ -35,3 +35,4 @@
@import 'page';
@import 'data_source_item';
@import 'collection_element_header';
@import 'side_bar';

View file

@ -22,3 +22,12 @@
display: flex;
margin-bottom: 5px;
}
.visibility-form__visibility-all {
display: flex;
justify-content: start;
}
.visibility-form__visibility-logged-in {
min-width: 200px;
}

View file

@ -0,0 +1,4 @@
/** Only display ellipsis when the user is *not* editing the page name in-place */
.side-bar-builder__link-text:not([contenteditable='true']) {
@extend %ellipsis;
}

View file

@ -197,9 +197,12 @@
@extend %ellipsis;
color: $palette-neutral-900;
display: block;
font-weight: 500;
padding: 0 32px 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
&:hover {
cursor: pointer;
@ -210,6 +213,7 @@
color: $palette-neutral-1200;
}
/* stylelint-disable-next-line */
&--empty::before {
@extend %empty;

View file

@ -209,6 +209,7 @@ export const actions = {
forceUpdate({ commit }, { application, data }) {
const type = this.$registry.get('application', application.type)
data = type.prepareForStoreUpdate(application, data)
commit('UPDATE_ITEM', { id: application.id, values: data })
},
/**

View file

@ -0,0 +1,163 @@
import { userCanViewPage } from '@baserow/modules/builder/utils/visibility'
import {
VISIBILITY_ALL,
VISIBILITY_LOGGED_IN,
ROLE_TYPE_ALLOW_EXCEPT,
ROLE_TYPE_DISALLOW_EXCEPT,
ROLE_TYPE_ALLOW_ALL,
} from '@baserow/modules/builder/constants'
describe('userCanViewPage tests', () => {
const testCasesVisibilityAll = [
{
userRole: '',
isAuthenticated: false,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_ALL,
},
{
userRole: 'fooRole',
isAuthenticated: false,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_ALL,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_ALL,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: ['fooRole'],
pageRoleType: ROLE_TYPE_ALLOW_ALL,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: ['fooRole'],
pageRoleType: ROLE_TYPE_ALLOW_EXCEPT,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: ['fooRole'],
pageRoleType: ROLE_TYPE_DISALLOW_EXCEPT,
},
]
test.each(testCasesVisibilityAll)(
'Allow access if visibility is "all", regardless of other page visibility settings.',
(testCase) => {
const user = { role: testCase.userRole }
const page = {
visibility: VISIBILITY_ALL,
role_type: testCase.pageRoleType,
roles: testCase.pageRoles,
}
const result = userCanViewPage(user, testCase.isAuthenticated, page)
expect(result).toEqual(true)
}
)
const testCasesVisibilityLoggedIn = [
{
userRole: '',
isAuthenticated: false,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_ALL,
expectedResult: false,
},
{
userRole: '',
isAuthenticated: false,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_EXCEPT,
expectedResult: false,
},
{
userRole: '',
isAuthenticated: false,
pageRoles: [],
pageRoleType: ROLE_TYPE_DISALLOW_EXCEPT,
expectedResult: false,
},
{
userRole: '',
isAuthenticated: true,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_ALL,
expectedResult: true,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_ALL,
expectedResult: true,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: ['fooRole'],
pageRoleType: ROLE_TYPE_ALLOW_ALL,
expectedResult: true,
},
{
userRole: '',
isAuthenticated: true,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_EXCEPT,
expectedResult: true,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: [],
pageRoleType: ROLE_TYPE_ALLOW_EXCEPT,
expectedResult: true,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: ['fooRole'],
pageRoleType: ROLE_TYPE_ALLOW_EXCEPT,
expectedResult: false,
},
{
userRole: '',
isAuthenticated: true,
pageRoles: [],
pageRoleType: ROLE_TYPE_DISALLOW_EXCEPT,
expectedResult: false,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: [],
pageRoleType: ROLE_TYPE_DISALLOW_EXCEPT,
expectedResult: false,
},
{
userRole: 'fooRole',
isAuthenticated: true,
pageRoles: ['fooRole'],
pageRoleType: ROLE_TYPE_DISALLOW_EXCEPT,
expectedResult: true,
},
]
test.each(testCasesVisibilityLoggedIn)(
'Allow access if visibility is "logged-in" and page settings permit access.',
(testCase) => {
const user = { role: testCase.userRole }
const page = {
visibility: VISIBILITY_LOGGED_IN,
role_type: testCase.pageRoleType,
roles: testCase.pageRoles,
}
const result = userCanViewPage(user, testCase.isAuthenticated, page)
expect(result).toEqual(testCase.expectedResult)
}
)
})