From f025d0d1d5d9d24157024ab64d1f344d7700e3bf Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Wed, 18 Jun 2025 12:23:00 +0200 Subject: [PATCH 1/7] enterprise/search: ability to use more precise search queries (#7698) * api: use DjangoQL for searches Signed-off-by: Jens Langhammer * expand search input and use textarea for multiline Signed-off-by: Jens Langhammer * start implementing autocomplete Signed-off-by: Jens Langhammer * only use ql for events Signed-off-by: Jens Langhammer * make QL search opt in Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * make pretend json relation work Signed-off-by: Jens Langhammer * fix schema Signed-off-by: Jens Langhammer * test Signed-off-by: Jens Langhammer * format Signed-off-by: Jens Langhammer * make autocomplete l1 work Signed-off-by: Jens Langhammer * use forked js lib with types, separate QL Signed-off-by: Jens Langhammer * first attempt at making it fit our UI Signed-off-by: Jens Langhammer * make dark theme somewhat work, fix search Signed-off-by: Jens Langhammer * make more parts work Signed-off-by: Jens Langhammer * make auto complete box be under cursor Signed-off-by: Jens Langhammer Co-authored-by: ripplefcl * remove django autocomplete for now Signed-off-by: Jens Langhammer * re-add event filtering Signed-off-by: Jens Langhammer * fix search when no ql is enabled Signed-off-by: Jens Langhammer * make meta+enter submit, fix colour Signed-off-by: Jens Langhammer * make dark theme Signed-off-by: Jens Langhammer * formatting Signed-off-by: Jens Langhammer * enterprise Signed-off-by: Jens Langhammer * fix tests Signed-off-by: Jens Langhammer * add tests Signed-off-by: Jens Langhammer * Update authentik/enterprise/search/apps.py Co-authored-by: Marc 'risson' Schmitt Signed-off-by: Jens L. * add json element autocomplete Signed-off-by: Jens Langhammer Co-authored-by: Marc 'risson' Schmitt Co-authored-by: ripplefcl * format Signed-off-by: Jens Langhammer * fix Signed-off-by: Jens Langhammer * fix query Signed-off-by: Jens Langhammer * fix search reset Signed-off-by: Jens Langhammer * fix dark theme Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer Signed-off-by: Jens L. Co-authored-by: ripplefcl Co-authored-by: Marc 'risson' Schmitt --- authentik/core/api/users.py | 17 +- authentik/core/tests/test_applications_api.py | 2 + .../unique_password/tests/test_tasks.py | 6 +- authentik/enterprise/search/__init__.py | 0 authentik/enterprise/search/apps.py | 12 + authentik/enterprise/search/fields.py | 125 ++++++ authentik/enterprise/search/pagination.py | 53 +++ authentik/enterprise/search/ql.py | 78 ++++ authentik/enterprise/search/schema.py | 29 ++ authentik/enterprise/search/settings.py | 17 + authentik/enterprise/search/tests.py | 78 ++++ authentik/enterprise/settings.py | 1 + authentik/events/api/events.py | 15 + .../providers/rac/tests/test_endpoints_api.py | 2 + .../rbac/tests/test_api_assigned_by_roles.py | 1 + .../rbac/tests/test_api_assigned_by_users.py | 1 + authentik/rbac/tests/test_api_filters.py | 2 + authentik/root/settings.py | 4 + blueprints/schema.json | 1 + pyproject.toml | 3 +- schema.yml | 412 ++++++++++++++++++ uv.lock | 22 + web/package-lock.json | 17 + web/package.json | 1 + web/src/admin/events/EventListPage.ts | 1 + web/src/admin/users/UserListPage.ts | 1 + web/src/components/ak-search-ql/index.ts | 306 +++++++++++++ web/src/elements/table/Table.ts | 36 +- web/src/elements/table/TableSearch.ts | 63 ++- 29 files changed, 1276 insertions(+), 30 deletions(-) create mode 100644 authentik/enterprise/search/__init__.py create mode 100644 authentik/enterprise/search/apps.py create mode 100644 authentik/enterprise/search/fields.py create mode 100644 authentik/enterprise/search/pagination.py create mode 100644 authentik/enterprise/search/ql.py create mode 100644 authentik/enterprise/search/schema.py create mode 100644 authentik/enterprise/search/settings.py create mode 100644 authentik/enterprise/search/tests.py create mode 100644 web/src/components/ak-search-ql/index.ts diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index 7a560959e0..c7fa0764c9 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -386,8 +386,23 @@ class UserViewSet(UsedByMixin, ModelViewSet): queryset = User.objects.none() ordering = ["username"] serializer_class = UserSerializer - search_fields = ["username", "name", "is_active", "email", "uuid", "attributes"] filterset_class = UsersFilter + search_fields = ["username", "name", "is_active", "email", "uuid", "attributes"] + + def get_ql_fields(self): + from djangoql.schema import BoolField, StrField + + from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField + + return [ + StrField(User, "username"), + StrField(User, "name"), + StrField(User, "email"), + StrField(User, "path"), + BoolField(User, "is_active", nullable=True), + ChoiceSearchField(User, "type"), + JSONSearchField(User, "attributes"), + ] def get_queryset(self): base_qs = User.objects.all().exclude_anonymous() diff --git a/authentik/core/tests/test_applications_api.py b/authentik/core/tests/test_applications_api.py index 192adc458b..e5c2b287c2 100644 --- a/authentik/core/tests/test_applications_api.py +++ b/authentik/core/tests/test_applications_api.py @@ -114,6 +114,7 @@ class TestApplicationsAPI(APITestCase): self.assertJSONEqual( response.content.decode(), { + "autocomplete": {}, "pagination": { "next": 0, "previous": 0, @@ -167,6 +168,7 @@ class TestApplicationsAPI(APITestCase): self.assertJSONEqual( response.content.decode(), { + "autocomplete": {}, "pagination": { "next": 0, "previous": 0, diff --git a/authentik/enterprise/policies/unique_password/tests/test_tasks.py b/authentik/enterprise/policies/unique_password/tests/test_tasks.py index 16a573c706..3e46c67b9d 100644 --- a/authentik/enterprise/policies/unique_password/tests/test_tasks.py +++ b/authentik/enterprise/policies/unique_password/tests/test_tasks.py @@ -119,17 +119,17 @@ class TestTrimPasswordHistory(TestCase): [ UserPasswordHistory( user=self.user, - old_password="hunter1", # nosec B106 + old_password="hunter1", # nosec created_at=_now - timedelta(days=3), ), UserPasswordHistory( user=self.user, - old_password="hunter2", # nosec B106 + old_password="hunter2", # nosec created_at=_now - timedelta(days=2), ), UserPasswordHistory( user=self.user, - old_password="hunter3", # nosec B106 + old_password="hunter3", # nosec created_at=_now, ), ] diff --git a/authentik/enterprise/search/__init__.py b/authentik/enterprise/search/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/search/apps.py b/authentik/enterprise/search/apps.py new file mode 100644 index 0000000000..0fc58b0b86 --- /dev/null +++ b/authentik/enterprise/search/apps.py @@ -0,0 +1,12 @@ +"""Enterprise app config""" + +from authentik.enterprise.apps import EnterpriseConfig + + +class AuthentikEnterpriseSearchConfig(EnterpriseConfig): + """Enterprise app config""" + + name = "authentik.enterprise.search" + label = "authentik_search" + verbose_name = "authentik Enterprise.Search" + default = True diff --git a/authentik/enterprise/search/fields.py b/authentik/enterprise/search/fields.py new file mode 100644 index 0000000000..4b8d5c57d1 --- /dev/null +++ b/authentik/enterprise/search/fields.py @@ -0,0 +1,125 @@ +"""DjangoQL search""" + +from collections import OrderedDict, defaultdict +from collections.abc import Generator + +from django.db import connection +from django.db.models import Model, Q +from djangoql.compat import text_type +from djangoql.schema import StrField + + +class JSONSearchField(StrField): + """JSON field for DjangoQL""" + + model: Model + + def __init__(self, model=None, name=None, nullable=None): + # Set this in the constructor to not clobber the type variable + self.type = "relation" + super().__init__(model, name, nullable) + + def get_lookup(self, path, operator, value): + search = "__".join(path) + op, invert = self.get_operator(operator) + q = Q(**{f"{search}{op}": self.get_lookup_value(value)}) + return ~q if invert else q + + def json_field_keys(self) -> Generator[tuple[str]]: + with connection.cursor() as cursor: + cursor.execute( + f""" + WITH RECURSIVE "{self.name}_keys" AS ( + SELECT + ARRAY[jsonb_object_keys("{self.name}")] AS key_path_array, + "{self.name}" -> jsonb_object_keys("{self.name}") AS value + FROM {self.model._meta.db_table} + WHERE "{self.name}" IS NOT NULL + AND jsonb_typeof("{self.name}") = 'object' + + UNION ALL + + SELECT + ck.key_path_array || jsonb_object_keys(ck.value), + ck.value -> jsonb_object_keys(ck.value) AS value + FROM "{self.name}_keys" ck + WHERE jsonb_typeof(ck.value) = 'object' + ), + + unique_paths AS ( + SELECT DISTINCT key_path_array + FROM "{self.name}_keys" + ) + + SELECT key_path_array FROM unique_paths; + """ # nosec + ) + return (x[0] for x in cursor.fetchall()) + + def get_nested_options(self) -> OrderedDict: + """Get keys of all nested objects to show autocomplete""" + base_model_name = f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}" + + def recursive_function(parts: list[str], parent_parts: list[str] | None = None): + if not parent_parts: + parent_parts = [] + path = parts.pop(0) + parent_parts.append(path) + relation_key = "_".join(parent_parts) + if len(parts) > 1: + out_dict = { + relation_key: { + parts[0]: { + "type": "relation", + "relation": f"{relation_key}_{parts[0]}", + } + } + } + child_paths = recursive_function(parts.copy(), parent_parts.copy()) + child_paths.update(out_dict) + return child_paths + else: + return {relation_key: {parts[0]: {}}} + + relation_structure = defaultdict(dict) + + for relations in self.json_field_keys(): + result = recursive_function([base_model_name] + relations) + for relation_key, value in result.items(): + for sub_relation_key, sub_value in value.items(): + if not relation_structure[relation_key].get(sub_relation_key, None): + relation_structure[relation_key][sub_relation_key] = sub_value + else: + relation_structure[relation_key][sub_relation_key].update(sub_value) + + final_dict = defaultdict(dict) + + for key, value in relation_structure.items(): + for sub_key, sub_value in value.items(): + if not sub_value: + final_dict[key][sub_key] = { + "type": "str", + "nullable": True, + } + else: + final_dict[key][sub_key] = sub_value + return OrderedDict(final_dict) + + def relation(self) -> str: + return f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}" + + +class ChoiceSearchField(StrField): + def __init__(self, model=None, name=None, nullable=None): + super().__init__(model, name, nullable, suggest_options=True) + + def get_options(self, search): + result = [] + choices = self._field_choices() + if choices: + search = search.lower() + for c in choices: + choice = text_type(c[0]) + if search in choice.lower(): + result.append(choice) + return result diff --git a/authentik/enterprise/search/pagination.py b/authentik/enterprise/search/pagination.py new file mode 100644 index 0000000000..c1c82437ae --- /dev/null +++ b/authentik/enterprise/search/pagination.py @@ -0,0 +1,53 @@ +from rest_framework.response import Response + +from authentik.api.pagination import Pagination +from authentik.enterprise.search.ql import AUTOCOMPLETE_COMPONENT_NAME, QLSearch + + +class AutocompletePagination(Pagination): + + def paginate_queryset(self, queryset, request, view=None): + self.view = view + return super().paginate_queryset(queryset, request, view) + + def get_autocomplete(self): + schema = QLSearch().get_schema(self.request, self.view) + introspections = {} + if hasattr(self.view, "get_ql_fields"): + from authentik.enterprise.search.schema import AKQLSchemaSerializer + + introspections = AKQLSchemaSerializer().serialize( + schema(self.page.paginator.object_list.model) + ) + return introspections + + def get_paginated_response(self, data): + previous_page_number = 0 + if self.page.has_previous(): + previous_page_number = self.page.previous_page_number() + next_page_number = 0 + if self.page.has_next(): + next_page_number = self.page.next_page_number() + return Response( + { + "pagination": { + "next": next_page_number, + "previous": previous_page_number, + "count": self.page.paginator.count, + "current": self.page.number, + "total_pages": self.page.paginator.num_pages, + "start_index": self.page.start_index(), + "end_index": self.page.end_index(), + }, + "results": data, + "autocomplete": self.get_autocomplete(), + } + ) + + def get_paginated_response_schema(self, schema): + final_schema = super().get_paginated_response_schema(schema) + final_schema["properties"]["autocomplete"] = { + "$ref": f"#/components/schemas/{AUTOCOMPLETE_COMPONENT_NAME}" + } + final_schema["required"].append("autocomplete") + return final_schema diff --git a/authentik/enterprise/search/ql.py b/authentik/enterprise/search/ql.py new file mode 100644 index 0000000000..b076194d35 --- /dev/null +++ b/authentik/enterprise/search/ql.py @@ -0,0 +1,78 @@ +"""DjangoQL search""" + +from django.apps import apps +from django.db.models import QuerySet +from djangoql.ast import Name +from djangoql.exceptions import DjangoQLError +from djangoql.queryset import apply_search +from djangoql.schema import DjangoQLSchema +from rest_framework.filters import SearchFilter +from rest_framework.request import Request +from structlog.stdlib import get_logger + +from authentik.enterprise.search.fields import JSONSearchField + +LOGGER = get_logger() +AUTOCOMPLETE_COMPONENT_NAME = "Autocomplete" +AUTOCOMPLETE_SCHEMA = { + "type": "object", + "additionalProperties": {}, +} + + +class BaseSchema(DjangoQLSchema): + """Base Schema which deals with JSON Fields""" + + def resolve_name(self, name: Name): + model = self.model_label(self.current_model) + root_field = name.parts[0] + field = self.models[model].get(root_field) + # If the query goes into a JSON field, return the root + # field as the JSON field will do the rest + if isinstance(field, JSONSearchField): + # This is a workaround; build_filter will remove the right-most + # entry in the path as that is intended to be the same as the field + # however for JSON that is not the case + if name.parts[-1] != root_field: + name.parts.append(root_field) + return field + return super().resolve_name(name) + + +class QLSearch(SearchFilter): + """rest_framework search filter which uses DjangoQL""" + + @property + def enabled(self): + return apps.get_app_config("authentik_enterprise").enabled() + + def get_search_terms(self, request) -> str: + """ + Search terms are set by a ?search=... query parameter, + and may be comma and/or whitespace delimited. + """ + params = request.query_params.get(self.search_param, "") + params = params.replace("\x00", "") # strip null characters + return params + + def get_schema(self, request: Request, view) -> BaseSchema: + ql_fields = [] + if hasattr(view, "get_ql_fields"): + ql_fields = view.get_ql_fields() + + class InlineSchema(BaseSchema): + def get_fields(self, model): + return ql_fields or [] + + return InlineSchema + + def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: + search_query = self.get_search_terms(request) + schema = self.get_schema(request, view) + if len(search_query) == 0 or not self.enabled: + return super().filter_queryset(request, queryset, view) + try: + return apply_search(queryset, search_query, schema=schema) + except DjangoQLError as exc: + LOGGER.debug("Failed to parse search expression", exc=exc) + return super().filter_queryset(request, queryset, view) diff --git a/authentik/enterprise/search/schema.py b/authentik/enterprise/search/schema.py new file mode 100644 index 0000000000..f20eabe573 --- /dev/null +++ b/authentik/enterprise/search/schema.py @@ -0,0 +1,29 @@ +from djangoql.serializers import DjangoQLSchemaSerializer +from drf_spectacular.generators import SchemaGenerator + +from authentik.api.schema import create_component +from authentik.enterprise.search.fields import JSONSearchField +from authentik.enterprise.search.ql import AUTOCOMPLETE_COMPONENT_NAME, AUTOCOMPLETE_SCHEMA + + +class AKQLSchemaSerializer(DjangoQLSchemaSerializer): + def serialize(self, schema): + serialization = super().serialize(schema) + for _, fields in schema.models.items(): + for _, field in fields.items(): + if not isinstance(field, JSONSearchField): + continue + serialization["models"].update(field.get_nested_options()) + return serialization + + def serialize_field(self, field): + result = super().serialize_field(field) + if isinstance(field, JSONSearchField): + result["relation"] = field.relation() + return result + + +def postprocess_schema_search_autocomplete(result, generator: SchemaGenerator, **kwargs): + create_component(generator, AUTOCOMPLETE_COMPONENT_NAME, AUTOCOMPLETE_SCHEMA) + + return result diff --git a/authentik/enterprise/search/settings.py b/authentik/enterprise/search/settings.py new file mode 100644 index 0000000000..959ee1b710 --- /dev/null +++ b/authentik/enterprise/search/settings.py @@ -0,0 +1,17 @@ +SPECTACULAR_SETTINGS = { + "POSTPROCESSING_HOOKS": [ + "authentik.api.schema.postprocess_schema_responses", + "authentik.enterprise.search.schema.postprocess_schema_search_autocomplete", + "drf_spectacular.hooks.postprocess_schema_enums", + ], +} + +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "authentik.enterprise.search.pagination.AutocompletePagination", + "DEFAULT_FILTER_BACKENDS": [ + "authentik.enterprise.search.ql.QLSearch", + "authentik.rbac.filters.ObjectFilter", + "django_filters.rest_framework.DjangoFilterBackend", + "rest_framework.filters.OrderingFilter", + ], +} diff --git a/authentik/enterprise/search/tests.py b/authentik/enterprise/search/tests.py new file mode 100644 index 0000000000..55d4fbef19 --- /dev/null +++ b/authentik/enterprise/search/tests.py @@ -0,0 +1,78 @@ +from json import loads +from unittest.mock import PropertyMock, patch +from urllib.parse import urlencode + +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.tests.utils import create_test_admin_user + + +@patch( + "authentik.enterprise.audit.middleware.EnterpriseAuditMiddleware.enabled", + PropertyMock(return_value=True), +) +class QLTest(APITestCase): + + def setUp(self): + self.user = create_test_admin_user() + # ensure we have more than 1 user + create_test_admin_user() + + def test_search(self): + """Test simple search query""" + self.client.force_login(self.user) + query = f'username = "{self.user.username}"' + res = self.client.get( + reverse( + "authentik_api:user-list", + ) + + f"?{urlencode({"search": query})}" + ) + self.assertEqual(res.status_code, 200) + content = loads(res.content) + self.assertEqual(content["pagination"]["count"], 1) + self.assertEqual(content["results"][0]["username"], self.user.username) + + def test_no_search(self): + """Ensure works with no search query""" + self.client.force_login(self.user) + res = self.client.get( + reverse( + "authentik_api:user-list", + ) + ) + self.assertEqual(res.status_code, 200) + content = loads(res.content) + self.assertNotEqual(content["pagination"]["count"], 1) + + def test_search_no_ql(self): + """Test simple search query (no QL)""" + self.client.force_login(self.user) + res = self.client.get( + reverse( + "authentik_api:user-list", + ) + + f"?{urlencode({"search": self.user.username})}" + ) + self.assertEqual(res.status_code, 200) + content = loads(res.content) + self.assertEqual(content["pagination"]["count"], 1) + self.assertEqual(content["results"][0]["username"], self.user.username) + + def test_search_json(self): + """Test search query with a JSON attribute""" + self.user.attributes = {"foo": {"bar": "baz"}} + self.user.save() + self.client.force_login(self.user) + query = 'attributes.foo.bar = "baz"' + res = self.client.get( + reverse( + "authentik_api:user-list", + ) + + f"?{urlencode({"search": query})}" + ) + self.assertEqual(res.status_code, 200) + content = loads(res.content) + self.assertEqual(content["pagination"]["count"], 1) + self.assertEqual(content["results"][0]["username"], self.user.username) diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index 676b6dc7c4..59b8a0e8ca 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -18,6 +18,7 @@ TENANT_APPS = [ "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.ssf", + "authentik.enterprise.search", "authentik.enterprise.stages.authenticator_endpoint_gdtc", "authentik.enterprise.stages.mtls", "authentik.enterprise.stages.source", diff --git a/authentik/events/api/events.py b/authentik/events/api/events.py index 73f3f2593d..c073ae62bb 100644 --- a/authentik/events/api/events.py +++ b/authentik/events/api/events.py @@ -132,6 +132,21 @@ class EventViewSet(ModelViewSet): ] filterset_class = EventsFilter + def get_ql_fields(self): + from djangoql.schema import DateTimeField, StrField + + from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField + + return [ + StrField(Event, "app", suggest_options=True), + StrField(Event, "client_ip"), + JSONSearchField(Event, "user"), + JSONSearchField(Event, "brand"), + ChoiceSearchField(Event, "action"), + JSONSearchField(Event, "context"), + DateTimeField(Event, "created", suggest_options=True), + ] + @extend_schema( methods=["GET"], responses={200: EventTopPerUserSerializer(many=True)}, diff --git a/authentik/providers/rac/tests/test_endpoints_api.py b/authentik/providers/rac/tests/test_endpoints_api.py index 9a2469bbba..d4421192df 100644 --- a/authentik/providers/rac/tests/test_endpoints_api.py +++ b/authentik/providers/rac/tests/test_endpoints_api.py @@ -49,6 +49,7 @@ class TestEndpointsAPI(APITestCase): self.assertJSONEqual( response.content.decode(), { + "autocomplete": {}, "pagination": { "next": 0, "previous": 0, @@ -101,6 +102,7 @@ class TestEndpointsAPI(APITestCase): self.assertJSONEqual( response.content.decode(), { + "autocomplete": {}, "pagination": { "next": 0, "previous": 0, diff --git a/authentik/rbac/tests/test_api_assigned_by_roles.py b/authentik/rbac/tests/test_api_assigned_by_roles.py index 3161917834..69fa0b2d71 100644 --- a/authentik/rbac/tests/test_api_assigned_by_roles.py +++ b/authentik/rbac/tests/test_api_assigned_by_roles.py @@ -44,6 +44,7 @@ class TestRBACRoleAPI(APITestCase): self.assertJSONEqual( res.content.decode(), { + "autocomplete": {}, "pagination": { "next": 0, "previous": 0, diff --git a/authentik/rbac/tests/test_api_assigned_by_users.py b/authentik/rbac/tests/test_api_assigned_by_users.py index 191bf0f602..90474a8bf2 100644 --- a/authentik/rbac/tests/test_api_assigned_by_users.py +++ b/authentik/rbac/tests/test_api_assigned_by_users.py @@ -46,6 +46,7 @@ class TestRBACUserAPI(APITestCase): self.assertJSONEqual( res.content.decode(), { + "autocomplete": {}, "pagination": { "next": 0, "previous": 0, diff --git a/authentik/rbac/tests/test_api_filters.py b/authentik/rbac/tests/test_api_filters.py index 42d1e13acb..b045bd778a 100644 --- a/authentik/rbac/tests/test_api_filters.py +++ b/authentik/rbac/tests/test_api_filters.py @@ -38,6 +38,7 @@ class TestAPIPerms(APITestCase): self.assertJSONEqual( res.content.decode(), { + "autocomplete": {}, "pagination": { "next": 0, "previous": 0, @@ -73,6 +74,7 @@ class TestAPIPerms(APITestCase): self.assertJSONEqual( res.content.decode(), { + "autocomplete": {}, "pagination": { "next": 0, "previous": 0, diff --git a/authentik/root/settings.py b/authentik/root/settings.py index be55981a17..74ce472101 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -446,6 +446,8 @@ _DISALLOWED_ITEMS = [ "MIDDLEWARE", "AUTHENTICATION_BACKENDS", "CELERY", + "SPECTACULAR_SETTINGS", + "REST_FRAMEWORK", ] SILENCED_SYSTEM_CHECKS = [ @@ -468,6 +470,8 @@ def _update_settings(app_path: str): TENANT_APPS.extend(getattr(settings_module, "TENANT_APPS", [])) MIDDLEWARE.extend(getattr(settings_module, "MIDDLEWARE", [])) AUTHENTICATION_BACKENDS.extend(getattr(settings_module, "AUTHENTICATION_BACKENDS", [])) + SPECTACULAR_SETTINGS.update(getattr(settings_module, "SPECTACULAR_SETTINGS", {})) + REST_FRAMEWORK.update(getattr(settings_module, "REST_FRAMEWORK", {})) CELERY["beat_schedule"].update(getattr(settings_module, "CELERY_BEAT_SCHEDULE", {})) for _attr in dir(settings_module): if not _attr.startswith("__") and _attr not in _DISALLOWED_ITEMS: diff --git a/blueprints/schema.json b/blueprints/schema.json index bbb5f804b0..d42896098b 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -7340,6 +7340,7 @@ "authentik.enterprise.providers.google_workspace", "authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.ssf", + "authentik.enterprise.search", "authentik.enterprise.stages.authenticator_endpoint_gdtc", "authentik.enterprise.stages.mtls", "authentik.enterprise.stages.source", diff --git a/pyproject.toml b/pyproject.toml index cc1a795128..8f9349b3c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,8 +24,9 @@ dependencies = [ "django-redis==5.4.0", "django-storages[s3]==1.14.6", "django-tenants==3.8.0", - "djangorestframework==3.16.0", + "djangoql==0.18.1", "djangorestframework-guardian==0.3.0", + "djangorestframework==3.16.0", "docker==7.1.0", "drf-orjson-renderer==1.7.3", "drf-spectacular==0.28.0", diff --git a/schema.yml b/schema.yml index 8ff8970bea..d8c3990ecd 100644 --- a/schema.yml +++ b/schema.yml @@ -41211,6 +41211,7 @@ components: - authentik.enterprise.providers.google_workspace - authentik.enterprise.providers.microsoft_entra - authentik.enterprise.providers.ssf + - authentik.enterprise.search - authentik.enterprise.stages.authenticator_endpoint_gdtc - authentik.enterprise.stages.mtls - authentik.enterprise.stages.source @@ -42689,6 +42690,9 @@ components: type: string minLength: 1 default: ak-stage-autosubmit + Autocomplete: + type: object + additionalProperties: {} AutosubmitChallenge: type: object description: Autosubmit challenge used to send and navigate a POST request @@ -49843,9 +49847,12 @@ components: type: array items: $ref: '#/components/schemas/ApplicationEntitlement' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedApplicationList: type: object properties: @@ -49855,9 +49862,12 @@ components: type: array items: $ref: '#/components/schemas/Application' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatedSessionList: type: object properties: @@ -49867,9 +49877,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatedSession' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatorDuoStageList: type: object properties: @@ -49879,9 +49892,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatorDuoStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatorEmailStageList: type: object properties: @@ -49891,9 +49907,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatorEmailStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatorEndpointGDTCStageList: type: object properties: @@ -49903,9 +49922,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatorEndpointGDTCStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatorSMSStageList: type: object properties: @@ -49915,9 +49937,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatorSMSStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatorStaticStageList: type: object properties: @@ -49927,9 +49952,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatorStaticStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatorTOTPStageList: type: object properties: @@ -49939,9 +49967,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatorTOTPStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatorValidateStageList: type: object properties: @@ -49951,9 +49982,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatorValidateStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedAuthenticatorWebAuthnStageList: type: object properties: @@ -49963,9 +49997,12 @@ components: type: array items: $ref: '#/components/schemas/AuthenticatorWebAuthnStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedBlueprintInstanceList: type: object properties: @@ -49975,9 +50012,12 @@ components: type: array items: $ref: '#/components/schemas/BlueprintInstance' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedBrandList: type: object properties: @@ -49987,9 +50027,12 @@ components: type: array items: $ref: '#/components/schemas/Brand' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedCaptchaStageList: type: object properties: @@ -49999,9 +50042,12 @@ components: type: array items: $ref: '#/components/schemas/CaptchaStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedCertificateKeyPairList: type: object properties: @@ -50011,9 +50057,12 @@ components: type: array items: $ref: '#/components/schemas/CertificateKeyPair' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedConnectionTokenList: type: object properties: @@ -50023,9 +50072,12 @@ components: type: array items: $ref: '#/components/schemas/ConnectionToken' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedConsentStageList: type: object properties: @@ -50035,9 +50087,12 @@ components: type: array items: $ref: '#/components/schemas/ConsentStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedDenyStageList: type: object properties: @@ -50047,9 +50102,12 @@ components: type: array items: $ref: '#/components/schemas/DenyStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedDockerServiceConnectionList: type: object properties: @@ -50059,9 +50117,12 @@ components: type: array items: $ref: '#/components/schemas/DockerServiceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedDomainList: type: object properties: @@ -50071,9 +50132,12 @@ components: type: array items: $ref: '#/components/schemas/Domain' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedDummyPolicyList: type: object properties: @@ -50083,9 +50147,12 @@ components: type: array items: $ref: '#/components/schemas/DummyPolicy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedDummyStageList: type: object properties: @@ -50095,9 +50162,12 @@ components: type: array items: $ref: '#/components/schemas/DummyStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedDuoDeviceList: type: object properties: @@ -50107,9 +50177,12 @@ components: type: array items: $ref: '#/components/schemas/DuoDevice' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedEmailDeviceList: type: object properties: @@ -50119,9 +50192,12 @@ components: type: array items: $ref: '#/components/schemas/EmailDevice' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedEmailStageList: type: object properties: @@ -50131,9 +50207,12 @@ components: type: array items: $ref: '#/components/schemas/EmailStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedEndpointDeviceList: type: object properties: @@ -50143,9 +50222,12 @@ components: type: array items: $ref: '#/components/schemas/EndpointDevice' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedEndpointList: type: object properties: @@ -50155,9 +50237,12 @@ components: type: array items: $ref: '#/components/schemas/Endpoint' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedEventList: type: object properties: @@ -50167,9 +50252,12 @@ components: type: array items: $ref: '#/components/schemas/Event' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedEventMatcherPolicyList: type: object properties: @@ -50179,9 +50267,12 @@ components: type: array items: $ref: '#/components/schemas/EventMatcherPolicy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedExpiringBaseGrantModelList: type: object properties: @@ -50191,9 +50282,12 @@ components: type: array items: $ref: '#/components/schemas/ExpiringBaseGrantModel' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedExpressionPolicyList: type: object properties: @@ -50203,9 +50297,12 @@ components: type: array items: $ref: '#/components/schemas/ExpressionPolicy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedExtraRoleObjectPermissionList: type: object properties: @@ -50239,9 +50336,12 @@ components: type: array items: $ref: '#/components/schemas/Flow' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedFlowStageBindingList: type: object properties: @@ -50251,9 +50351,12 @@ components: type: array items: $ref: '#/components/schemas/FlowStageBinding' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGeoIPPolicyList: type: object properties: @@ -50263,9 +50366,12 @@ components: type: array items: $ref: '#/components/schemas/GeoIPPolicy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGoogleWorkspaceProviderGroupList: type: object properties: @@ -50275,9 +50381,12 @@ components: type: array items: $ref: '#/components/schemas/GoogleWorkspaceProviderGroup' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGoogleWorkspaceProviderList: type: object properties: @@ -50287,9 +50396,12 @@ components: type: array items: $ref: '#/components/schemas/GoogleWorkspaceProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGoogleWorkspaceProviderMappingList: type: object properties: @@ -50299,9 +50411,12 @@ components: type: array items: $ref: '#/components/schemas/GoogleWorkspaceProviderMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGoogleWorkspaceProviderUserList: type: object properties: @@ -50311,9 +50426,12 @@ components: type: array items: $ref: '#/components/schemas/GoogleWorkspaceProviderUser' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGroupKerberosSourceConnectionList: type: object properties: @@ -50323,9 +50441,12 @@ components: type: array items: $ref: '#/components/schemas/GroupKerberosSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGroupLDAPSourceConnectionList: type: object properties: @@ -50335,9 +50456,12 @@ components: type: array items: $ref: '#/components/schemas/GroupLDAPSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGroupList: type: object properties: @@ -50347,9 +50471,12 @@ components: type: array items: $ref: '#/components/schemas/Group' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGroupOAuthSourceConnectionList: type: object properties: @@ -50359,9 +50486,12 @@ components: type: array items: $ref: '#/components/schemas/GroupOAuthSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGroupPlexSourceConnectionList: type: object properties: @@ -50371,9 +50501,12 @@ components: type: array items: $ref: '#/components/schemas/GroupPlexSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGroupSAMLSourceConnectionList: type: object properties: @@ -50383,9 +50516,12 @@ components: type: array items: $ref: '#/components/schemas/GroupSAMLSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedGroupSourceConnectionList: type: object properties: @@ -50395,9 +50531,12 @@ components: type: array items: $ref: '#/components/schemas/GroupSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedIdentificationStageList: type: object properties: @@ -50407,9 +50546,12 @@ components: type: array items: $ref: '#/components/schemas/IdentificationStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedInitialPermissionsList: type: object properties: @@ -50419,9 +50561,12 @@ components: type: array items: $ref: '#/components/schemas/InitialPermissions' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedInvitationList: type: object properties: @@ -50431,9 +50576,12 @@ components: type: array items: $ref: '#/components/schemas/Invitation' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedInvitationStageList: type: object properties: @@ -50443,9 +50591,12 @@ components: type: array items: $ref: '#/components/schemas/InvitationStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedKerberosSourceList: type: object properties: @@ -50455,9 +50606,12 @@ components: type: array items: $ref: '#/components/schemas/KerberosSource' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedKerberosSourcePropertyMappingList: type: object properties: @@ -50467,9 +50621,12 @@ components: type: array items: $ref: '#/components/schemas/KerberosSourcePropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedKubernetesServiceConnectionList: type: object properties: @@ -50479,9 +50636,12 @@ components: type: array items: $ref: '#/components/schemas/KubernetesServiceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedLDAPOutpostConfigList: type: object properties: @@ -50491,9 +50651,12 @@ components: type: array items: $ref: '#/components/schemas/LDAPOutpostConfig' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedLDAPProviderList: type: object properties: @@ -50503,9 +50666,12 @@ components: type: array items: $ref: '#/components/schemas/LDAPProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedLDAPSourceList: type: object properties: @@ -50515,9 +50681,12 @@ components: type: array items: $ref: '#/components/schemas/LDAPSource' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedLDAPSourcePropertyMappingList: type: object properties: @@ -50527,9 +50696,12 @@ components: type: array items: $ref: '#/components/schemas/LDAPSourcePropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedLicenseList: type: object properties: @@ -50539,9 +50711,12 @@ components: type: array items: $ref: '#/components/schemas/License' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedMicrosoftEntraProviderGroupList: type: object properties: @@ -50551,9 +50726,12 @@ components: type: array items: $ref: '#/components/schemas/MicrosoftEntraProviderGroup' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedMicrosoftEntraProviderList: type: object properties: @@ -50563,9 +50741,12 @@ components: type: array items: $ref: '#/components/schemas/MicrosoftEntraProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedMicrosoftEntraProviderMappingList: type: object properties: @@ -50575,9 +50756,12 @@ components: type: array items: $ref: '#/components/schemas/MicrosoftEntraProviderMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedMicrosoftEntraProviderUserList: type: object properties: @@ -50587,9 +50771,12 @@ components: type: array items: $ref: '#/components/schemas/MicrosoftEntraProviderUser' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedMutualTLSStageList: type: object properties: @@ -50599,9 +50786,12 @@ components: type: array items: $ref: '#/components/schemas/MutualTLSStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedNotificationList: type: object properties: @@ -50611,9 +50801,12 @@ components: type: array items: $ref: '#/components/schemas/Notification' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedNotificationRuleList: type: object properties: @@ -50623,9 +50816,12 @@ components: type: array items: $ref: '#/components/schemas/NotificationRule' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedNotificationTransportList: type: object properties: @@ -50635,9 +50831,12 @@ components: type: array items: $ref: '#/components/schemas/NotificationTransport' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedNotificationWebhookMappingList: type: object properties: @@ -50647,9 +50846,12 @@ components: type: array items: $ref: '#/components/schemas/NotificationWebhookMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedOAuth2ProviderList: type: object properties: @@ -50659,9 +50861,12 @@ components: type: array items: $ref: '#/components/schemas/OAuth2Provider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedOAuthSourceList: type: object properties: @@ -50671,9 +50876,12 @@ components: type: array items: $ref: '#/components/schemas/OAuthSource' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedOAuthSourcePropertyMappingList: type: object properties: @@ -50683,9 +50891,12 @@ components: type: array items: $ref: '#/components/schemas/OAuthSourcePropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedOutpostList: type: object properties: @@ -50695,9 +50906,12 @@ components: type: array items: $ref: '#/components/schemas/Outpost' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPasswordExpiryPolicyList: type: object properties: @@ -50707,9 +50921,12 @@ components: type: array items: $ref: '#/components/schemas/PasswordExpiryPolicy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPasswordPolicyList: type: object properties: @@ -50719,9 +50936,12 @@ components: type: array items: $ref: '#/components/schemas/PasswordPolicy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPasswordStageList: type: object properties: @@ -50731,9 +50951,12 @@ components: type: array items: $ref: '#/components/schemas/PasswordStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPermissionList: type: object properties: @@ -50743,9 +50966,12 @@ components: type: array items: $ref: '#/components/schemas/Permission' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPlexSourceList: type: object properties: @@ -50755,9 +50981,12 @@ components: type: array items: $ref: '#/components/schemas/PlexSource' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPlexSourcePropertyMappingList: type: object properties: @@ -50767,9 +50996,12 @@ components: type: array items: $ref: '#/components/schemas/PlexSourcePropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPolicyBindingList: type: object properties: @@ -50779,9 +51011,12 @@ components: type: array items: $ref: '#/components/schemas/PolicyBinding' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPolicyList: type: object properties: @@ -50791,9 +51026,12 @@ components: type: array items: $ref: '#/components/schemas/Policy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPromptList: type: object properties: @@ -50803,9 +51041,12 @@ components: type: array items: $ref: '#/components/schemas/Prompt' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPromptStageList: type: object properties: @@ -50815,9 +51056,12 @@ components: type: array items: $ref: '#/components/schemas/PromptStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedPropertyMappingList: type: object properties: @@ -50827,9 +51071,12 @@ components: type: array items: $ref: '#/components/schemas/PropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedProviderList: type: object properties: @@ -50839,9 +51086,12 @@ components: type: array items: $ref: '#/components/schemas/Provider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedProxyOutpostConfigList: type: object properties: @@ -50851,9 +51101,12 @@ components: type: array items: $ref: '#/components/schemas/ProxyOutpostConfig' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedProxyProviderList: type: object properties: @@ -50863,9 +51116,12 @@ components: type: array items: $ref: '#/components/schemas/ProxyProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedRACPropertyMappingList: type: object properties: @@ -50875,9 +51131,12 @@ components: type: array items: $ref: '#/components/schemas/RACPropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedRACProviderList: type: object properties: @@ -50887,9 +51146,12 @@ components: type: array items: $ref: '#/components/schemas/RACProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedRadiusOutpostConfigList: type: object properties: @@ -50899,9 +51161,12 @@ components: type: array items: $ref: '#/components/schemas/RadiusOutpostConfig' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedRadiusProviderList: type: object properties: @@ -50911,9 +51176,12 @@ components: type: array items: $ref: '#/components/schemas/RadiusProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedRadiusProviderPropertyMappingList: type: object properties: @@ -50923,9 +51191,12 @@ components: type: array items: $ref: '#/components/schemas/RadiusProviderPropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedRedirectStageList: type: object properties: @@ -50935,9 +51206,12 @@ components: type: array items: $ref: '#/components/schemas/RedirectStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedReputationList: type: object properties: @@ -50947,9 +51221,12 @@ components: type: array items: $ref: '#/components/schemas/Reputation' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedReputationPolicyList: type: object properties: @@ -50959,9 +51236,12 @@ components: type: array items: $ref: '#/components/schemas/ReputationPolicy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedRoleAssignedObjectPermissionList: type: object properties: @@ -50971,9 +51251,12 @@ components: type: array items: $ref: '#/components/schemas/RoleAssignedObjectPermission' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedRoleList: type: object properties: @@ -50983,9 +51266,12 @@ components: type: array items: $ref: '#/components/schemas/Role' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSAMLPropertyMappingList: type: object properties: @@ -50995,9 +51281,12 @@ components: type: array items: $ref: '#/components/schemas/SAMLPropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSAMLProviderList: type: object properties: @@ -51007,9 +51296,12 @@ components: type: array items: $ref: '#/components/schemas/SAMLProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSAMLSourceList: type: object properties: @@ -51019,9 +51311,12 @@ components: type: array items: $ref: '#/components/schemas/SAMLSource' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSAMLSourcePropertyMappingList: type: object properties: @@ -51031,9 +51326,12 @@ components: type: array items: $ref: '#/components/schemas/SAMLSourcePropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSCIMMappingList: type: object properties: @@ -51043,9 +51341,12 @@ components: type: array items: $ref: '#/components/schemas/SCIMMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSCIMProviderGroupList: type: object properties: @@ -51055,9 +51356,12 @@ components: type: array items: $ref: '#/components/schemas/SCIMProviderGroup' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSCIMProviderList: type: object properties: @@ -51067,9 +51371,12 @@ components: type: array items: $ref: '#/components/schemas/SCIMProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSCIMProviderUserList: type: object properties: @@ -51079,9 +51386,12 @@ components: type: array items: $ref: '#/components/schemas/SCIMProviderUser' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSCIMSourceGroupList: type: object properties: @@ -51091,9 +51401,12 @@ components: type: array items: $ref: '#/components/schemas/SCIMSourceGroup' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSCIMSourceList: type: object properties: @@ -51103,9 +51416,12 @@ components: type: array items: $ref: '#/components/schemas/SCIMSource' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSCIMSourcePropertyMappingList: type: object properties: @@ -51115,9 +51431,12 @@ components: type: array items: $ref: '#/components/schemas/SCIMSourcePropertyMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSCIMSourceUserList: type: object properties: @@ -51127,9 +51446,12 @@ components: type: array items: $ref: '#/components/schemas/SCIMSourceUser' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSMSDeviceList: type: object properties: @@ -51139,9 +51461,12 @@ components: type: array items: $ref: '#/components/schemas/SMSDevice' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSSFProviderList: type: object properties: @@ -51151,9 +51476,12 @@ components: type: array items: $ref: '#/components/schemas/SSFProvider' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSSFStreamList: type: object properties: @@ -51163,9 +51491,12 @@ components: type: array items: $ref: '#/components/schemas/SSFStream' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedScopeMappingList: type: object properties: @@ -51175,9 +51506,12 @@ components: type: array items: $ref: '#/components/schemas/ScopeMapping' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedServiceConnectionList: type: object properties: @@ -51187,9 +51521,12 @@ components: type: array items: $ref: '#/components/schemas/ServiceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSourceList: type: object properties: @@ -51199,9 +51536,12 @@ components: type: array items: $ref: '#/components/schemas/Source' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSourceStageList: type: object properties: @@ -51211,9 +51551,12 @@ components: type: array items: $ref: '#/components/schemas/SourceStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedStageList: type: object properties: @@ -51223,9 +51566,12 @@ components: type: array items: $ref: '#/components/schemas/Stage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedStaticDeviceList: type: object properties: @@ -51235,9 +51581,12 @@ components: type: array items: $ref: '#/components/schemas/StaticDevice' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedSystemTaskList: type: object properties: @@ -51247,9 +51596,12 @@ components: type: array items: $ref: '#/components/schemas/SystemTask' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedTOTPDeviceList: type: object properties: @@ -51259,9 +51611,12 @@ components: type: array items: $ref: '#/components/schemas/TOTPDevice' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedTenantList: type: object properties: @@ -51271,9 +51626,12 @@ components: type: array items: $ref: '#/components/schemas/Tenant' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedTokenList: type: object properties: @@ -51283,9 +51641,12 @@ components: type: array items: $ref: '#/components/schemas/Token' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedTokenModelList: type: object properties: @@ -51295,9 +51656,12 @@ components: type: array items: $ref: '#/components/schemas/TokenModel' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUniquePasswordPolicyList: type: object properties: @@ -51307,9 +51671,12 @@ components: type: array items: $ref: '#/components/schemas/UniquePasswordPolicy' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserAssignedObjectPermissionList: type: object properties: @@ -51319,9 +51686,12 @@ components: type: array items: $ref: '#/components/schemas/UserAssignedObjectPermission' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserConsentList: type: object properties: @@ -51331,9 +51701,12 @@ components: type: array items: $ref: '#/components/schemas/UserConsent' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserDeleteStageList: type: object properties: @@ -51343,9 +51716,12 @@ components: type: array items: $ref: '#/components/schemas/UserDeleteStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserKerberosSourceConnectionList: type: object properties: @@ -51355,9 +51731,12 @@ components: type: array items: $ref: '#/components/schemas/UserKerberosSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserLDAPSourceConnectionList: type: object properties: @@ -51367,9 +51746,12 @@ components: type: array items: $ref: '#/components/schemas/UserLDAPSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserList: type: object properties: @@ -51379,9 +51761,12 @@ components: type: array items: $ref: '#/components/schemas/User' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserLoginStageList: type: object properties: @@ -51391,9 +51776,12 @@ components: type: array items: $ref: '#/components/schemas/UserLoginStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserLogoutStageList: type: object properties: @@ -51403,9 +51791,12 @@ components: type: array items: $ref: '#/components/schemas/UserLogoutStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserOAuthSourceConnectionList: type: object properties: @@ -51415,9 +51806,12 @@ components: type: array items: $ref: '#/components/schemas/UserOAuthSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserPlexSourceConnectionList: type: object properties: @@ -51427,9 +51821,12 @@ components: type: array items: $ref: '#/components/schemas/UserPlexSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserSAMLSourceConnectionList: type: object properties: @@ -51439,9 +51836,12 @@ components: type: array items: $ref: '#/components/schemas/UserSAMLSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserSourceConnectionList: type: object properties: @@ -51451,9 +51851,12 @@ components: type: array items: $ref: '#/components/schemas/UserSourceConnection' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedUserWriteStageList: type: object properties: @@ -51463,9 +51866,12 @@ components: type: array items: $ref: '#/components/schemas/UserWriteStage' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedWebAuthnDeviceList: type: object properties: @@ -51475,9 +51881,12 @@ components: type: array items: $ref: '#/components/schemas/WebAuthnDevice' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete PaginatedWebAuthnDeviceTypeList: type: object properties: @@ -51487,9 +51896,12 @@ components: type: array items: $ref: '#/components/schemas/WebAuthnDeviceType' + autocomplete: + $ref: '#/components/schemas/Autocomplete' required: - pagination - results + - autocomplete Pagination: type: object properties: diff --git a/uv.lock b/uv.lock index e2e9984252..872c69d3d0 100644 --- a/uv.lock +++ b/uv.lock @@ -187,6 +187,7 @@ dependencies = [ { name = "django-redis" }, { name = "django-storages", extra = ["s3"] }, { name = "django-tenants" }, + { name = "djangoql" }, { name = "djangorestframework" }, { name = "djangorestframework-guardian" }, { name = "docker" }, @@ -285,6 +286,7 @@ requires-dist = [ { name = "django-redis", specifier = "==5.4.0" }, { name = "django-storages", extras = ["s3"], specifier = "==1.14.6" }, { name = "django-tenants", specifier = "==3.8.0" }, + { name = "djangoql", specifier = "==0.18.1" }, { name = "djangorestframework", git = "https://github.com/goauthentik/django-rest-framework?rev=896722bab969fabc74a08b827da59409cf9f1a4e" }, { name = "djangorestframework-guardian", specifier = "==0.3.0" }, { name = "docker", specifier = "==7.1.0" }, @@ -1114,6 +1116,17 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/a8/7b/22e3bb79d48e5a4fdcacdcdc27bbc5c2523a2b7892b440bfe229f313d823/django_tenants-3.8.0.tar.gz", hash = "sha256:07d009d5d01be2d65c3f5ddbf323d58d1228838fc1a64fded15c8e5c6f41cf8f", size = 154307, upload-time = "2025-05-23T16:07:24.307Z" } +[[package]] +name = "djangoql" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ply" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/0a/83cdb7b9d3b854b98941363153945f6c051b3bc50cd61108a85677c98c3a/djangoql-0.18.1-py2.py3-none-any.whl", hash = "sha256:51b3085a805627ebb43cfd0aa861137cdf8f69cc3c9244699718fe04a6c8e26d", size = 218209, upload-time = "2024-01-08T14:10:47.915Z" }, +] + [[package]] name = "djangorestframework" version = "3.16.0" @@ -2296,6 +2309,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "ply" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, +] + [[package]] name = "prometheus-client" version = "0.22.1" diff --git a/web/package-lock.json b/web/package-lock.json index 6bb8946fe7..aa90f859a2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,6 +28,7 @@ "@lit/reactive-element": "^2.0.4", "@lit/task": "^1.0.2", "@mdx-js/mdx": "^3.1.0", + "@mrmarble/djangoql-completion": "^0.8.3", "@open-wc/lit-helpers": "^0.7.0", "@patternfly/elements": "^4.1.0", "@patternfly/patternfly": "^4.224.2", @@ -2660,6 +2661,16 @@ "langium": "3.3.1" } }, + "node_modules/@mrmarble/djangoql-completion": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@mrmarble/djangoql-completion/-/djangoql-completion-0.8.3.tgz", + "integrity": "sha512-wYctvF0gQs48wL9jLQ+H2g2B0yJj7CrUSNi4ec5gcSuICIRqD/QSt6G+3zDdeW1LlI/4uj/FByJvg8k4TAAnVg==", + "license": "MIT", + "dependencies": { + "lex": "1.7.9", + "lodash": "4.17.21" + } + }, "node_modules/@napi-rs/nice": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.0.1.tgz", @@ -18978,6 +18989,12 @@ "node": ">= 0.8.0" } }, + "node_modules/lex": { + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/lex/-/lex-1.7.9.tgz", + "integrity": "sha512-vzaalVBmFLnMaedq0QAsBAaXsWahzRpvnIBdBjj7y+7EKTS6lnziU2y/PsU2c6rV5qYj2B5IDw0uNJ9peXD0vw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info." + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", diff --git a/web/package.json b/web/package.json index c12eb53e28..5336b18a93 100644 --- a/web/package.json +++ b/web/package.json @@ -99,6 +99,7 @@ "@lit/reactive-element": "^2.0.4", "@lit/task": "^1.0.2", "@mdx-js/mdx": "^3.1.0", + "@mrmarble/djangoql-completion": "^0.8.3", "@open-wc/lit-helpers": "^0.7.0", "@patternfly/elements": "^4.1.0", "@patternfly/patternfly": "^4.224.2", diff --git a/web/src/admin/events/EventListPage.ts b/web/src/admin/events/EventListPage.ts index 300be05c69..758491a0dc 100644 --- a/web/src/admin/events/EventListPage.ts +++ b/web/src/admin/events/EventListPage.ts @@ -20,6 +20,7 @@ import { Event, EventsApi } from "@goauthentik/api"; @customElement("ak-event-list") export class EventListPage extends TablePage { expandable = true; + supportsQL = true; pageTitle(): string { return msg("Event Log"); diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index 0d575226c5..3ac0177aaf 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -85,6 +85,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa expandable = true; checkbox = true; clearOnRefresh = true; + supportsQL = true; searchEnabled(): boolean { return true; diff --git a/web/src/components/ak-search-ql/index.ts b/web/src/components/ak-search-ql/index.ts new file mode 100644 index 0000000000..4575b8fb20 --- /dev/null +++ b/web/src/components/ak-search-ql/index.ts @@ -0,0 +1,306 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/buttons/Dropdown"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; +import DjangoQL, { Introspections } from "@mrmarble/djangoql-completion"; + +import { msg } from "@lit/localize"; +import { CSSResult, TemplateResult, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFSearchInput from "@patternfly/patternfly/components/SearchInput/search-input.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +export class QL extends DjangoQL { + createCompletionElement() { + this.completionEnabled = !!this.options.completionEnabled; + return; + } + logError(message: string): void { + console.warn(`authentik/ql: ${message}`); + } + textareaResize() {} +} + +@customElement("ak-search-ql") +export class QLSearch extends AKElement { + @property() + value?: string; + + @query("[name=search]") + searchElement?: HTMLTextAreaElement; + + @state() + menuOpen = false; + + @property() + onSearch?: (value: string) => void; + + @state() + selected?: number; + + @state() + cursorX: number = 0; + + @state() + cursorY: number = 0; + + ql?: QL; + canvas?: CanvasRenderingContext2D; + + set apiResponse(value: PaginatedResponse | undefined) { + if (!value || !value.autocomplete || !this.ql) { + return; + } + this.ql.loadIntrospections(value.autocomplete as unknown as Introspections); + } + + static get styles(): CSSResult[] { + return [ + PFBase, + PFFormControl, + PFSearchInput, + css` + ::-webkit-search-cancel-button { + display: none; + } + .ql.pf-c-form-control { + font-family: monospace; + resize: vertical; + height: 2.25em; + } + .selected { + background-color: var(--pf-c-search-input__menu-item--hover--BackgroundColor); + } + :host([theme="dark"]) .pf-c-search-input__menu { + --pf-c-search-input__menu--BackgroundColor: var(--ak-dark-background-light-ish); + color: var(--ak-dark-foreground); + } + :host([theme="dark"]) .pf-c-search-input__menu-item { + --pf-c-search-input__menu-item--Color: var(--ak-dark-foreground); + } + :host([theme="dark"]) .pf-c-search-input__menu-item:hover { + --pf-c-search-input__menu-item--BackgroundColor: var( + --ak-dark-background-lighter + ); + } + :host([theme="dark"]) .pf-c-search-input__menu-list-item.selected { + --pf-c-search-input__menu-item--hover--BackgroundColor: var( + --ak-dark-background-light + ); + } + :host([theme="dark"]) .pf-c-search-input__text::before { + border: 0; + } + .pf-c-search-input__menu { + position: fixed; + min-width: 0; + } + `, + ]; + } + + firstUpdated() { + if (!this.searchElement) { + return; + } + this.ql = new QL({ + completionEnabled: true, + introspections: { + current_model: "", + models: {}, + }, + selector: this.searchElement, + autoResize: false, + }); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) { + console.error("authentik/ql: failed to get canvas context"); + return; + } + context.font = window.getComputedStyle(this.searchElement).font; + this.canvas = context; + } + + refreshCompletions() { + this.value = this.searchElement?.value; + if (!this.ql) { + return; + } + this.ql.generateSuggestions(); + if (this.ql.suggestions.length < 1 || this.ql.loading) { + this.menuOpen = false; + return; + } + this.menuOpen = true; + this.updateDropdownPosition(); + this.requestUpdate(); + } + + updateDropdownPosition() { + if (!this.searchElement) { + return; + } + const bcr = this.getBoundingClientRect(); + // We need the width of a letter to measure x; we use a monospaced font but still + // check the length for `m` as its the widest ASCII char + const metrics = this.canvas?.measureText("m"); + const letterWidth = Math.ceil(metrics?.width || 0) + 1; + + // Mostly static variables for padding, font line-height and how many + const lineHeight = parseInt(window.getComputedStyle(this.searchElement).lineHeight, 10); + const paddingTop = parseInt(window.getComputedStyle(this.searchElement).paddingTop, 10); + const paddingLeft = parseInt(window.getComputedStyle(this.searchElement).paddingLeft, 10); + const paddingRight = parseInt(window.getComputedStyle(this.searchElement).paddingRight, 10); + const actualInnerWidth = bcr.width - paddingLeft - paddingRight; + + let relX = 0; + let relY = 1; + let letterIndex = 0; + + this.searchElement.value.split(" ").some((word, idx) => { + letterIndex += word.length; + const newRelX = relX + word.length * letterWidth; + if (newRelX > actualInnerWidth) { + relY += 1; + if (letterIndex > this.searchElement!.selectionStart) { + relX = + letterWidth * word.length - + (letterIndex - this.searchElement!.selectionStart) * letterWidth; + return true; + } + relX = word.length * letterWidth; + } else { + relX = newRelX + 1; + } + }); + + this.cursorX = bcr.x + paddingLeft + relX; + this.cursorY = bcr.y + paddingTop + relY * lineHeight; + } + + onKeyDown(ev: KeyboardEvent) { + this.updateDropdownPosition(); + if (ev.key === "Enter" && ev.metaKey && this.onSearch && this.searchElement) { + this.onSearch(this.searchElement?.value); + return; + } + if (!this.menuOpen) return; + switch (ev.key) { + case "ArrowUp": + if (this.ql?.suggestions.length) { + if (this.selected === undefined) { + this.selected = this.ql?.suggestions.length - 1; + } else if (this.selected === 0) { + this.selected = undefined; + } else { + this.selected -= 1; + } + this.refreshCompletions(); + ev.preventDefault(); + } + break; + case "ArrowDown": + if (this.ql?.suggestions.length) { + if (this.selected === undefined) { + this.selected = 0; + } else if (this.selected < this.ql?.suggestions.length - 1) { + this.selected += 1; + } else { + this.selected = undefined; + } + this.refreshCompletions(); + ev.preventDefault(); + } + break; + case "Tab": + if (this.selected) { + this.ql?.selectCompletion(this.selected); + ev.preventDefault(); + } + break; + case "Enter": + // Technically this is a textarea, due to automatic multi-line feature, + // but other than that it should look and behave like a normal input. + // So expected behavior when pressing Enter is to submit the form, + // not to add a new line. + if (this.selected !== undefined) { + this.ql?.selectCompletion(this.selected); + } + ev.preventDefault(); + break; + case "Escape": + this.menuOpen = false; + break; + case "Shift": // Shift + case "Control": // Ctrl + case "Alt": // Alt + case "Meta": // Windows Key or Cmd on Mac + // Control keys shouldn't trigger completion popup + break; + } + } + + renderMenu() { + if (!this.menuOpen || !this.ql) { + return nothing; + } + return html` +
+
    + ${this.ql.suggestions.map((suggestion, idx) => { + return html`
  • + +
  • `; + })} +
+
+ `; + } + + render(): TemplateResult { + return html`
+
+ + + +
+ ${this.renderMenu()} +
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-search-ql": QLSearch; + } +} diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 81b0c4ad68..4a5a6b3f04 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -1,3 +1,4 @@ +import { WithLicenseSummary } from "#elements/mixins/license"; import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { APIError, @@ -31,13 +32,20 @@ import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css"; import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; -import { Pagination } from "@goauthentik/api"; +import { LicenseSummaryStatusEnum, Pagination } from "@goauthentik/api"; export interface TableLike { order?: string; fetch: () => void; } +export interface PaginatedResponse { + pagination: Pagination; + autocomplete?: { [key: string]: string }; + + results: Array; +} + export class TableColumn { title: string; orderBy?: string; @@ -94,19 +102,16 @@ export class TableColumn { } } -export interface PaginatedResponse { - pagination: Pagination; - - results: Array; -} - -export abstract class Table extends AKElement implements TableLike { +export abstract class Table extends WithLicenseSummary(AKElement) implements TableLike { abstract apiEndpoint(): Promise>; abstract columns(): TableColumn[]; abstract row(item: T): SlottedTemplateResult[]; private isLoading = false; + @property({ type: Boolean }) + supportsQL: boolean = false; + searchEnabled(): boolean { return false; } @@ -181,6 +186,12 @@ export abstract class Table extends AKElement implements TableLike { PFDropdown, PFPagination, css` + .pf-c-toolbar__group.pf-m-search-filter.ql { + flex-grow: 1; + } + ak-table-search.ql { + width: 100% !important; + } .pf-c-table thead .pf-c-table__check { min-width: 3rem; } @@ -474,14 +485,17 @@ export abstract class Table extends AKElement implements TableLike { }); this.fetch(); }; - + const isQL = + this.supportsQL && this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed; return !this.searchEnabled() ? html`` - : html`
+ : html`
`; diff --git a/web/src/elements/table/TableSearch.ts b/web/src/elements/table/TableSearch.ts index 9e99395577..36d25b40f5 100644 --- a/web/src/elements/table/TableSearch.ts +++ b/web/src/elements/table/TableSearch.ts @@ -1,4 +1,7 @@ +import { WithLicenseSummary } from "#elements/mixins/license"; +import "@goauthentik/components/ak-search-ql"; import { AKElement } from "@goauthentik/elements/Base"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; @@ -11,11 +14,19 @@ import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-gro import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; +import { LicenseSummaryStatusEnum } from "@goauthentik/api"; + @customElement("ak-table-search") -export class TableSearch extends AKElement { +export class TableSearch extends WithLicenseSummary(AKElement) { @property() value?: string; + @property({ type: Boolean }) + supportsQL: boolean = false; + + @property({ attribute: false }) + apiResponse?: PaginatedResponse; + @property() onSearch?: (value: string) => void; @@ -30,39 +41,63 @@ export class TableSearch extends AKElement { ::-webkit-search-cancel-button { display: none; } + ak-search-ql { + width: 100%; + } `, ]; } + renderInput(): TemplateResult { + if ( + this.supportsQL && + this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed + ) { + return html` { + if (!this.onSearch) return; + this.onSearch(value); + }} + name="search" + >`; + } + return html` { + if (!this.onSearch) return; + this.onSearch((ev.target as HTMLInputElement).value); + }} + />`; + } + render(): TemplateResult { return html`
{ e.preventDefault(); if (!this.onSearch) return; - const el = this.shadowRoot?.querySelector("input[type=search]"); + const el = this.shadowRoot?.querySelector( + "[name=search]", + ); if (!el) return; if (el.value === "") return; this.onSearch(el?.value); }} > - { - if (!this.onSearch) return; - this.onSearch((ev.target as HTMLInputElement).value); - }} - /> + ${this.renderInput()}