enterprise/search: ability to use more precise search queries (#7698)

* api: use DjangoQL for searches

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* expand search input and use textarea for multiline

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start implementing autocomplete

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* only use ql for events

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make QL search opt in

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make pretend json relation work

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix schema

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* test

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make autocomplete l1 work

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use forked js lib with types, separate QL

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* first attempt at making it fit our UI

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make dark theme somewhat work, fix search

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make more parts work

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make auto complete box be under cursor

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: ripplefcl <github@ripple.contact>

* remove django autocomplete for now

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* re-add event filtering

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix search when no ql is enabled

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make meta+enter submit, fix colour

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make dark theme

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* formatting

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* enterprise

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Update authentik/enterprise/search/apps.py

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Jens L. <jens@beryju.org>

* add json element autocomplete

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Co-authored-by: ripplefcl <github@ripple.contact>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix query

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix search reset

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix dark theme

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: ripplefcl <github@ripple.contact>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Jens L.
2025-06-18 12:23:00 +02:00
committed by GitHub
parent 52115f9345
commit f025d0d1d5
29 changed files with 1276 additions and 30 deletions

View File

@ -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()

View File

@ -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,

View File

@ -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,
),
]

View File

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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",
],
}

View File

@ -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)

View File

@ -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",

View File

@ -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)},

View File

@ -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,

View File

@ -44,6 +44,7 @@ class TestRBACRoleAPI(APITestCase):
self.assertJSONEqual(
res.content.decode(),
{
"autocomplete": {},
"pagination": {
"next": 0,
"previous": 0,

View File

@ -46,6 +46,7 @@ class TestRBACUserAPI(APITestCase):
self.assertJSONEqual(
res.content.decode(),
{
"autocomplete": {},
"pagination": {
"next": 0,
"previous": 0,

View File

@ -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,

View File

@ -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:

View File

@ -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",

View File

@ -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",

File diff suppressed because it is too large Load Diff

22
uv.lock generated
View File

@ -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"

17
web/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -20,6 +20,7 @@ import { Event, EventsApi } from "@goauthentik/api";
@customElement("ak-event-list")
export class EventListPage extends TablePage<Event> {
expandable = true;
supportsQL = true;
pageTitle(): string {
return msg("Event Log");

View File

@ -85,6 +85,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
expandable = true;
checkbox = true;
clearOnRefresh = true;
supportsQL = true;
searchEnabled(): boolean {
return true;

View File

@ -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<unknown> | 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`
<div
class="pf-c-search-input__menu"
style="left: ${this.cursorX}px; top: ${this.cursorY}px;"
>
<ul class="pf-c-search-input__menu-list">
${this.ql.suggestions.map((suggestion, idx) => {
return html`<li
class="pf-c-search-input__menu-list-item ${this.selected === idx
? "selected"
: ""}"
>
<button
class="pf-c-search-input__menu-item"
type="button"
@click=${() => {
this.ql?.selectCompletion(idx);
this.refreshCompletions();
}}
>
<span class="pf-c-search-input__menu-item-text"
>${suggestion.text}</span
>
</button>
</li>`;
})}
</ul>
</div>
`;
}
render(): TemplateResult {
return html`<div class="pf-c-search-input">
<div class="pf-c-search-input__bar">
<span class="pf-c-search-input__text">
<textarea
class="pf-c-form-control ql"
name="search"
placeholder=${msg("Search...")}
spellcheck="false"
@input=${(ev: InputEvent) => this.refreshCompletions()}
@keydown=${this.onKeyDown}
>
${ifDefined(this.value)}</textarea
>
</span>
</div>
${this.renderMenu()}
</div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-search-ql": QLSearch;
}
}

View File

@ -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<T> {
pagination: Pagination;
autocomplete?: { [key: string]: string };
results: Array<T>;
}
export class TableColumn {
title: string;
orderBy?: string;
@ -94,19 +102,16 @@ export class TableColumn {
}
}
export interface PaginatedResponse<T> {
pagination: Pagination;
results: Array<T>;
}
export abstract class Table<T> extends AKElement implements TableLike {
export abstract class Table<T> extends WithLicenseSummary(AKElement) implements TableLike {
abstract apiEndpoint(): Promise<PaginatedResponse<T>>;
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<T> 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<T> extends AKElement implements TableLike {
});
this.fetch();
};
const isQL =
this.supportsQL && this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed;
return !this.searchEnabled()
? html``
: html`<div class="pf-c-toolbar__group pf-m-search-filter">
: html`<div class="pf-c-toolbar__group pf-m-search-filter ${isQL ? "ql" : ""}">
<ak-table-search
class="pf-c-toolbar__item pf-m-search-filter"
?supportsQL=${this.supportsQL}
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
value=${ifDefined(this.search)}
.onSearch=${runSearch}
.apiResponse=${this.data}
>
</ak-table-search>
</div>`;

View File

@ -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<unknown>;
@property()
onSearch?: (value: string) => void;
@ -30,24 +41,29 @@ export class TableSearch extends AKElement {
::-webkit-search-cancel-button {
display: none;
}
ak-search-ql {
width: 100%;
}
`,
];
}
render(): TemplateResult {
return html`<form
class="pf-c-input-group"
method="GET"
@submit=${(e: Event) => {
e.preventDefault();
renderInput(): TemplateResult {
if (
this.supportsQL &&
this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed
) {
return html`<ak-search-ql
.apiResponse=${this.apiResponse}
.value=${this.value}
.onSearch=${(value: string) => {
if (!this.onSearch) return;
const el = this.shadowRoot?.querySelector<HTMLInputElement>("input[type=search]");
if (!el) return;
if (el.value === "") return;
this.onSearch(el?.value);
this.onSearch(value);
}}
>
<input
name="search"
></ak-search-ql>`;
}
return html`<input
class="pf-c-form-control"
name="search"
type="search"
@ -57,12 +73,31 @@ export class TableSearch extends AKElement {
if (!this.onSearch) return;
this.onSearch((ev.target as HTMLInputElement).value);
}}
/>
/>`;
}
render(): TemplateResult {
return html`<form
class="pf-c-input-group"
method="get"
@submit=${(e: Event) => {
e.preventDefault();
if (!this.onSearch) return;
const el = this.shadowRoot?.querySelector<HTMLInputElement | HTMLTextAreaElement>(
"[name=search]",
);
if (!el) return;
if (el.value === "") return;
this.onSearch(el?.value);
}}
>
${this.renderInput()}
<button
class="pf-c-button pf-m-control"
type="reset"
@click=${() => {
if (!this.onSearch) return;
this.value = "";
this.onSearch("");
}}
>