Merge remote-tracking branch 'origin/main' into website/integrations--add-papra
This commit is contained in:
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
]
|
||||
|
0
authentik/enterprise/search/__init__.py
Normal file
0
authentik/enterprise/search/__init__.py
Normal file
12
authentik/enterprise/search/apps.py
Normal file
12
authentik/enterprise/search/apps.py
Normal 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
|
125
authentik/enterprise/search/fields.py
Normal file
125
authentik/enterprise/search/fields.py
Normal 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
|
53
authentik/enterprise/search/pagination.py
Normal file
53
authentik/enterprise/search/pagination.py
Normal 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
|
78
authentik/enterprise/search/ql.py
Normal file
78
authentik/enterprise/search/ql.py
Normal 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)
|
29
authentik/enterprise/search/schema.py
Normal file
29
authentik/enterprise/search/schema.py
Normal 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
|
17
authentik/enterprise/search/settings.py
Normal file
17
authentik/enterprise/search/settings.py
Normal 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",
|
||||
],
|
||||
}
|
78
authentik/enterprise/search/tests.py
Normal file
78
authentik/enterprise/search/tests.py
Normal 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)
|
@ -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",
|
||||
|
@ -132,6 +132,22 @@ 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, "event_uuid"),
|
||||
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)},
|
||||
|
@ -11,7 +11,7 @@ from authentik.events.models import NotificationRule
|
||||
class NotificationRuleSerializer(ModelSerializer):
|
||||
"""NotificationRule Serializer"""
|
||||
|
||||
group_obj = GroupSerializer(read_only=True, source="group")
|
||||
destination_group_obj = GroupSerializer(read_only=True, source="destination_group")
|
||||
|
||||
class Meta:
|
||||
model = NotificationRule
|
||||
@ -20,8 +20,9 @@ class NotificationRuleSerializer(ModelSerializer):
|
||||
"name",
|
||||
"transports",
|
||||
"severity",
|
||||
"group",
|
||||
"group_obj",
|
||||
"destination_group",
|
||||
"destination_group_obj",
|
||||
"destination_event_user",
|
||||
]
|
||||
|
||||
|
||||
@ -30,6 +31,6 @@ class NotificationRuleViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
queryset = NotificationRule.objects.all()
|
||||
serializer_class = NotificationRuleSerializer
|
||||
filterset_fields = ["name", "severity", "group__name"]
|
||||
filterset_fields = ["name", "severity", "destination_group__name"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name", "group__name"]
|
||||
search_fields = ["name", "destination_group__name"]
|
||||
|
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-16 23:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0009_remove_notificationtransport_webhook_mapping_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="notificationrule",
|
||||
old_name="group",
|
||||
new_name="destination_group",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notificationrule",
|
||||
name="destination_event_user",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="When enabled, notification will be sent to user the user that triggered the event.When destination_group is configured, notification is sent to both.",
|
||||
),
|
||||
),
|
||||
]
|
@ -1,10 +1,12 @@
|
||||
"""authentik events models"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import timedelta
|
||||
from difflib import get_close_matches
|
||||
from functools import lru_cache
|
||||
from inspect import currentframe
|
||||
from smtplib import SMTPException
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from django.apps import apps
|
||||
@ -547,7 +549,7 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
|
||||
default=NotificationSeverity.NOTICE,
|
||||
help_text=_("Controls which severity level the created notifications will have."),
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
destination_group = models.ForeignKey(
|
||||
Group,
|
||||
help_text=_(
|
||||
"Define which group of users this notification should be sent and shown to. "
|
||||
@ -557,6 +559,19 @@ class NotificationRule(SerializerModel, PolicyBindingModel):
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
destination_event_user = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"When enabled, notification will be sent to user the user that triggered the event."
|
||||
"When destination_group is configured, notification is sent to both."
|
||||
),
|
||||
)
|
||||
|
||||
def destination_users(self, event: Event) -> Generator[User, Any]:
|
||||
if self.destination_event_user and event.user.get("pk"):
|
||||
yield User(pk=event.user.get("pk"))
|
||||
if self.destination_group:
|
||||
yield from self.destination_group.users.all()
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
|
@ -68,14 +68,10 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
|
||||
if not result.passing:
|
||||
return
|
||||
|
||||
if not trigger.group:
|
||||
LOGGER.debug("e(trigger): trigger has no group", trigger=trigger)
|
||||
return
|
||||
|
||||
LOGGER.debug("e(trigger): event trigger matched", trigger=trigger)
|
||||
# Create the notification objects
|
||||
for transport in trigger.transports.all():
|
||||
for user in trigger.group.users.all():
|
||||
for user in trigger.destination_users(event):
|
||||
LOGGER.debug("created notification")
|
||||
notification_transport.apply_async(
|
||||
args=[
|
||||
|
@ -6,6 +6,7 @@ from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
EventAction,
|
||||
@ -34,7 +35,7 @@ class TestEventsNotifications(APITestCase):
|
||||
def test_trigger_empty(self):
|
||||
"""Test trigger without any policies attached"""
|
||||
transport = NotificationTransport.objects.create(name=generate_id())
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
|
||||
@ -46,7 +47,7 @@ class TestEventsNotifications(APITestCase):
|
||||
def test_trigger_single(self):
|
||||
"""Test simple transport triggering"""
|
||||
transport = NotificationTransport.objects.create(name=generate_id())
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
@ -59,6 +60,25 @@ class TestEventsNotifications(APITestCase):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(execute_mock.call_count, 1)
|
||||
|
||||
def test_trigger_event_user(self):
|
||||
"""Test trigger with event user"""
|
||||
user = create_test_user()
|
||||
transport = NotificationTransport.objects.create(name=generate_id())
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), destination_event_user=True)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
name="matcher", action=EventAction.CUSTOM_PREFIX
|
||||
)
|
||||
PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
|
||||
|
||||
execute_mock = MagicMock()
|
||||
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).set_user(user).save()
|
||||
self.assertEqual(execute_mock.call_count, 1)
|
||||
notification: Notification = execute_mock.call_args[0][0]
|
||||
self.assertEqual(notification.user, user)
|
||||
|
||||
def test_trigger_no_group(self):
|
||||
"""Test trigger without group"""
|
||||
trigger = NotificationRule.objects.create(name=generate_id())
|
||||
@ -76,7 +96,7 @@ class TestEventsNotifications(APITestCase):
|
||||
"""Test Policy error which would cause recursion"""
|
||||
transport = NotificationTransport.objects.create(name=generate_id())
|
||||
NotificationRule.objects.filter(name__startswith="default").delete()
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
@ -99,7 +119,7 @@ class TestEventsNotifications(APITestCase):
|
||||
|
||||
transport = NotificationTransport.objects.create(name=generate_id(), send_once=True)
|
||||
NotificationRule.objects.filter(name__startswith="default").delete()
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
trigger.save()
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
@ -123,7 +143,7 @@ class TestEventsNotifications(APITestCase):
|
||||
name=generate_id(), webhook_mapping_body=mapping, mode=TransportMode.LOCAL
|
||||
)
|
||||
NotificationRule.objects.filter(name__startswith="default").delete()
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), group=self.group)
|
||||
trigger = NotificationRule.objects.create(name=generate_id(), destination_group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
name="matcher", action=EventAction.CUSTOM_PREFIX
|
||||
|
@ -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,
|
||||
|
@ -44,6 +44,7 @@ class TestRBACRoleAPI(APITestCase):
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
{
|
||||
"autocomplete": {},
|
||||
"pagination": {
|
||||
"next": 0,
|
||||
"previous": 0,
|
||||
|
@ -46,6 +46,7 @@ class TestRBACUserAPI(APITestCase):
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
{
|
||||
"autocomplete": {},
|
||||
"pagination": {
|
||||
"next": 0,
|
||||
"previous": 0,
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -6628,11 +6628,16 @@
|
||||
"title": "Severity",
|
||||
"description": "Controls which severity level the created notifications will have."
|
||||
},
|
||||
"group": {
|
||||
"destination_group": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Group",
|
||||
"title": "Destination group",
|
||||
"description": "Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent."
|
||||
},
|
||||
"destination_event_user": {
|
||||
"type": "boolean",
|
||||
"title": "Destination event user",
|
||||
"description": "When enabled, notification will be sent to user the user that triggered the event.When destination_group is configured, notification is sent to both."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@ -7340,6 +7345,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",
|
||||
|
@ -21,11 +21,12 @@ dependencies = [
|
||||
"django-model-utils==5.0.0",
|
||||
"django-pglock==1.7.2",
|
||||
"django-prometheus==2.3.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-redis==6.0.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",
|
||||
|
439
schema.yml
439
schema.yml
File diff suppressed because it is too large
Load Diff
55
uv.lock
generated
55
uv.lock
generated
@ -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" },
|
||||
@ -282,9 +283,10 @@ requires-dist = [
|
||||
{ name = "django-model-utils", specifier = "==5.0.0" },
|
||||
{ name = "django-pglock", specifier = "==1.7.2" },
|
||||
{ name = "django-prometheus", specifier = "==2.3.1" },
|
||||
{ name = "django-redis", specifier = "==5.4.0" },
|
||||
{ name = "django-redis", specifier = "==6.0.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" },
|
||||
@ -1077,15 +1079,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-redis"
|
||||
version = "5.4.0"
|
||||
version = "6.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "redis" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/9d/2272742fdd9d0a9f0b28cd995b0539430c9467a2192e4de2cea9ea6ad38c/django-redis-5.4.0.tar.gz", hash = "sha256:6a02abaa34b0fea8bf9b707d2c363ab6adc7409950b2db93602e6cb292818c42", size = 52567, upload-time = "2023-10-01T20:22:01.221Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/53/dbcfa1e528e0d6c39947092625b2c89274b5d88f14d357cee53c4d6dbbd4/django_redis-6.0.0.tar.gz", hash = "sha256:2d9cb12a20424a4c4dde082c6122f486628bae2d9c2bee4c0126a4de7fda00dd", size = 56904, upload-time = "2025-06-17T18:15:46.376Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/f1/63caad7c9222c26a62082f4f777de26389233b7574629996098bf6d25a4d/django_redis-5.4.0-py3-none-any.whl", hash = "sha256:ebc88df7da810732e2af9987f7f426c96204bf89319df4c6da6ca9a2942edd5b", size = 31119, upload-time = "2023-10-01T20:21:33.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -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"
|
||||
@ -3102,20 +3124,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.4.2"
|
||||
version = "6.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/59/45/a0daf161f7d6f36c3ea5fc0c2de619746cc3dd4c76402e9db545bd920f63/tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b", size = 501135, upload-time = "2024-11-22T03:06:38.036Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/89/c72771c81d25d53fe33e3dca61c233b665b2780f21820ba6fd2c6793c12b/tornado-6.5.1.tar.gz", hash = "sha256:84ceece391e8eb9b2b95578db65e920d2a61070260594819589609ba9bc6308c", size = 509934, upload-time = "2025-05-22T18:15:38.788Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/7e/71f604d8cea1b58f82ba3590290b66da1e72d840aeb37e0d5f7291bd30db/tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1", size = 436299, upload-time = "2024-11-22T03:06:20.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/44/87543a3b99016d0bf54fdaab30d24bf0af2e848f1d13d34a3a5380aabe16/tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803", size = 434253, upload-time = "2024-11-22T03:06:22.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/fb/fdf679b4ce51bcb7210801ef4f11fdac96e9885daa402861751353beea6e/tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec", size = 437602, upload-time = "2024-11-22T03:06:24.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/3b/e31aeffffc22b475a64dbeb273026a21b5b566f74dee48742817626c47dc/tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946", size = 436972, upload-time = "2024-11-22T03:06:25.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf", size = 437173, upload-time = "2024-11-22T03:06:27.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/5e/be4fb0d1684eb822c9a62fb18a3e44a06188f78aa466b2ad991d2ee31104/tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634", size = 437892, upload-time = "2024-11-22T03:06:28.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/33/4f91fdd94ea36e1d796147003b490fe60a0215ac5737b6f9c65e160d4fe0/tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73", size = 437334, upload-time = "2024-11-22T03:06:30.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/ae/c1b22d4524b0e10da2f29a176fb2890386f7bd1f63aacf186444873a88a0/tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c", size = 437261, upload-time = "2024-11-22T03:06:32.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/25/36dbd49ab6d179bcfc4c6c093a51795a4f3bed380543a8242ac3517a1751/tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482", size = 438463, upload-time = "2024-11-22T03:06:34.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/cc/58b1adeb1bb46228442081e746fcdbc4540905c87e8add7c277540934edb/tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38", size = 438907, upload-time = "2024-11-22T03:06:36.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/89/f4532dee6843c9e0ebc4e28d4be04c67f54f60813e4bf73d595fe7567452/tornado-6.5.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d50065ba7fd11d3bd41bcad0825227cc9a95154bad83239357094c36708001f7", size = 441948, upload-time = "2025-05-22T18:15:20.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/9a/557406b62cffa395d18772e0cdcf03bed2fff03b374677348eef9f6a3792/tornado-6.5.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9e9ca370f717997cb85606d074b0e5b247282cf5e2e1611568b8821afe0342d6", size = 440112, upload-time = "2025-05-22T18:15:22.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/82/7721b7319013a3cf881f4dffa4f60ceff07b31b394e459984e7a36dc99ec/tornado-6.5.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b77e9dfa7ed69754a54c89d82ef746398be82f749df69c4d3abe75c4d1ff4888", size = 443672, upload-time = "2025-05-22T18:15:24.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/42/d11c4376e7d101171b94e03cef0cbce43e823ed6567ceda571f54cf6e3ce/tornado-6.5.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:253b76040ee3bab8bcf7ba9feb136436a3787208717a1fb9f2c16b744fba7331", size = 443019, upload-time = "2025-05-22T18:15:25.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/f7/0c48ba992d875521ac761e6e04b0a1750f8150ae42ea26df1852d6a98942/tornado-6.5.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:308473f4cc5a76227157cdf904de33ac268af770b2c5f05ca6c1161d82fdd95e", size = 443252, upload-time = "2025-05-22T18:15:27.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/46/d8d7413d11987e316df4ad42e16023cd62666a3c0dfa1518ffa30b8df06c/tornado-6.5.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:caec6314ce8a81cf69bd89909f4b633b9f523834dc1a352021775d45e51d9401", size = 443930, upload-time = "2025-05-22T18:15:29.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b2/f8049221c96a06df89bed68260e8ca94beca5ea532ffc63b1175ad31f9cc/tornado-6.5.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:13ce6e3396c24e2808774741331638ee6c2f50b114b97a55c5b442df65fd9692", size = 443351, upload-time = "2025-05-22T18:15:31.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/ff/6a0079e65b326cc222a54720a748e04a4db246870c4da54ece4577bfa702/tornado-6.5.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5cae6145f4cdf5ab24744526cc0f55a17d76f02c98f4cff9daa08ae9a217448a", size = 443328, upload-time = "2025-05-22T18:15:32.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/18/e3f902a1d21f14035b5bc6246a8c0f51e0eef562ace3a2cea403c1fb7021/tornado-6.5.1-cp39-abi3-win32.whl", hash = "sha256:e0a36e1bc684dca10b1aa75a31df8bdfed656831489bc1e6a6ebed05dc1ec365", size = 444396, upload-time = "2025-05-22T18:15:34.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/09/6526e32bf1049ee7de3bebba81572673b19a2a8541f795d887e92af1a8bc/tornado-6.5.1-cp39-abi3-win_amd64.whl", hash = "sha256:908e7d64567cecd4c2b458075589a775063453aeb1d2a1853eedb806922f568b", size = 444840, upload-time = "2025-05-22T18:15:36.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/a7/535c44c7bea4578e48281d83c615219f3ab19e6abc67625ef637c73987be/tornado-6.5.1-cp39-abi3-win_arm64.whl", hash = "sha256:02420a0eb7bf617257b9935e2b754d1b63897525d8a289c9d65690d580b4dcf7", size = 443596, upload-time = "2025-05-22T18:15:37.433Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
287
web/package-lock.json
generated
287
web/package-lock.json
generated
@ -22,13 +22,16 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.7.11",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@goauthentik/api": "^2025.6.2-1750112513",
|
||||
"@goauthentik/api": "^2025.6.2-1750246811",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
"@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",
|
||||
"@openlayers-elements/core": "^0.4.0",
|
||||
"@openlayers-elements/maps": "^0.4.0",
|
||||
"@patternfly/elements": "^4.1.0",
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@sentry/browser": "^9.30.0",
|
||||
@ -1728,9 +1731,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2025.6.2-1750112513",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.6.2-1750112513.tgz",
|
||||
"integrity": "sha512-QgvuHY/G4AetMYBgZ1lNP59bkzU3Nbq7whoSyOXWms4Wze2Fc4Btd35CxleBw9GJug1RVJBE+EG7ImC9bwe+2Q=="
|
||||
"version": "2025.6.2-1750246811",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.6.2-1750246811.tgz",
|
||||
"integrity": "sha512-ENHEi3kGAodf5tKQb5kziUrT1EcJw3z8tp2mU7LWqNlXr4eoAI15BjDfH5DW56l4jy3xKqTd+R2Ntnj4hiVhHw=="
|
||||
},
|
||||
"node_modules/@goauthentik/core": {
|
||||
"resolved": "packages/core",
|
||||
@ -2588,6 +2591,48 @@
|
||||
"@lit/reactive-element": "^1.0.0 || ^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/mapbox-gl-style-spec": {
|
||||
"version": "13.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.28.0.tgz",
|
||||
"integrity": "sha512-B8xM7Fp1nh5kejfIl4SWeY0gtIeewbuRencqO3cJDrCHZpaPg7uY+V8abuR+esMeuOjRl5cLhVTP40v+1ywxbg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/point-geometry": "^0.1.0",
|
||||
"@mapbox/unitbezier": "^0.0.0",
|
||||
"csscolorparser": "~1.0.2",
|
||||
"json-stringify-pretty-compact": "^2.0.0",
|
||||
"minimist": "^1.2.6",
|
||||
"rw": "^1.3.3",
|
||||
"sort-object": "^0.3.2"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-composite": "bin/gl-style-composite.js",
|
||||
"gl-style-format": "bin/gl-style-format.js",
|
||||
"gl-style-migrate": "bin/gl-style-migrate.js",
|
||||
"gl-style-validate": "bin/gl-style-validate.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
|
||||
"integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz",
|
||||
"integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mdx-js/mdx": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.0.tgz",
|
||||
@ -2660,6 +2705,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",
|
||||
@ -3060,6 +3115,27 @@
|
||||
"lit": "^2.0.0 || ^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@openlayers-elements/core": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@openlayers-elements/core/-/core-0.4.0.tgz",
|
||||
"integrity": "sha512-msY2QGYCYf5Zph16j08KszgqtHmMORCK7B5afpe5iM8c3FFSfjijUffiw93MGeowoN4Yo5jfkxuI2plpyidR0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.4",
|
||||
"ol": "^7.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@openlayers-elements/maps": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@openlayers-elements/maps/-/maps-0.4.0.tgz",
|
||||
"integrity": "sha512-uxGW3Lt1BVA8eC0HykXLZA4a3EfCU44FdGaudC4Xu0s+XYPOEPxCGLDCsWSuy67NvEUTFb+odu6mRDLofxdquA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@openlayers-elements/core": "^0.4.0",
|
||||
"lit": "^3.1.4",
|
||||
"ol": "^7.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@ -3960,6 +4036,12 @@
|
||||
"lit": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@petamoriken/float16": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
||||
"integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@ -12167,6 +12249,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csscolorparser": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
|
||||
"integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
@ -13370,6 +13458,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@ -15951,6 +16045,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/geotiff": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/geotiff/-/geotiff-2.1.3.tgz",
|
||||
"integrity": "sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@petamoriken/float16": "^3.4.7",
|
||||
"lerc": "^3.0.0",
|
||||
"pako": "^2.0.4",
|
||||
"parse-headers": "^2.0.2",
|
||||
"quick-lru": "^6.1.1",
|
||||
"web-worker": "^1.2.0",
|
||||
"xml-utils": "^1.0.2",
|
||||
"zstddec": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19"
|
||||
}
|
||||
},
|
||||
"node_modules/geotiff/node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/geotiff/node_modules/quick-lru": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
|
||||
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@ -18517,6 +18648,12 @@
|
||||
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz",
|
||||
"integrity": "sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@ -18956,6 +19093,12 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lerc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz",
|
||||
"integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
@ -18978,6 +19121,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",
|
||||
@ -19479,6 +19628,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mapbox-to-css-font": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-2.4.5.tgz",
|
||||
"integrity": "sha512-VJ6nB8emkO9VODI0Fk+TQ/0zKBTqmf/Pkt8Xv0kHstoc0iXRajA00DAid4Kc3K5xeFIOoiZrVxijEzj0GLVO2w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/markdown-extensions": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz",
|
||||
@ -20907,7 +21062,6 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
@ -22031,6 +22185,34 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ol": {
|
||||
"version": "7.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ol/-/ol-7.5.2.tgz",
|
||||
"integrity": "sha512-HJbb3CxXrksM6ct367LsP3N+uh+iBBMdP3DeGGipdV9YAYTP0vTJzqGnoqQ6C2IW4qf8krw9yuyQbc9fjOIaOQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"earcut": "^2.2.3",
|
||||
"geotiff": "^2.0.7",
|
||||
"ol-mapbox-style": "^10.1.0",
|
||||
"pbf": "3.2.1",
|
||||
"rbush": "^3.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/openlayers"
|
||||
}
|
||||
},
|
||||
"node_modules/ol-mapbox-style": {
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-10.7.0.tgz",
|
||||
"integrity": "sha512-S/UdYBuOjrotcR95Iq9AejGYbifKeZE85D9VtH11ryJLQPTZXZSW1J5bIXcr4AlAH6tyjPPHTK34AdkwB32Myw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/mapbox-gl-style-spec": "^13.23.1",
|
||||
"mapbox-to-css-font": "^2.4.1",
|
||||
"ol": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@ -22361,6 +22543,12 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse-headers": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz",
|
||||
"integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse-ms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
|
||||
@ -22511,6 +22699,19 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz",
|
||||
"integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"ieee754": "^1.1.12",
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/peek-readable": {
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz",
|
||||
@ -22972,6 +23173,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-agent": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
|
||||
@ -23187,6 +23394,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ramda": {
|
||||
"version": "0.30.1",
|
||||
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz",
|
||||
@ -23308,6 +23521,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
|
||||
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quickselect": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
@ -24225,6 +24447,15 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve.exports": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz",
|
||||
@ -25066,6 +25297,22 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-asc": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz",
|
||||
"integrity": "sha512-jBgdDd+rQ+HkZF2/OHCmace5dvpos/aWQpcxuyRs9QUbPRnkEJmYVo81PIGpjIdpOcsnJ4rGjStfDHsbn+UVyw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-desc": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.1.1.tgz",
|
||||
"integrity": "sha512-jfZacW5SKOP97BF5rX5kQfJmRVZP5/adDUTY8fCSPvNcXDVpUEe2pr/iKGlcyZzchRJZrswnp68fgk3qBXgkJw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-keys": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz",
|
||||
@ -25102,6 +25349,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-object": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/sort-object/-/sort-object-0.3.2.tgz",
|
||||
"integrity": "sha512-aAQiEdqFTTdsvUFxXm3umdo04J7MRljoVGbBlkH7BgNsMvVNAJyGj7C/wV1A8wHWAJj/YikeZbfuCKqhggNWGA==",
|
||||
"dependencies": {
|
||||
"sort-asc": "^0.1.0",
|
||||
"sort-desc": "^0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sort-object-keys": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-1.1.3.tgz",
|
||||
@ -28334,6 +28593,12 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/web-worker": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||
"integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webauthn-polyfills": {
|
||||
"version": "0.1.7",
|
||||
"resolved": "https://registry.npmjs.org/webauthn-polyfills/-/webauthn-polyfills-0.1.7.tgz",
|
||||
@ -28908,6 +29173,12 @@
|
||||
"repeat-string": "^1.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-utils": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
|
||||
"integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
@ -29165,6 +29436,12 @@
|
||||
"zod": "^3.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zstddec": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz",
|
||||
"integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==",
|
||||
"license": "MIT AND BSD-3-Clause"
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
@ -93,13 +93,16 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.7.11",
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@goauthentik/api": "^2025.6.2-1750112513",
|
||||
"@goauthentik/api": "^2025.6.2-1750246811",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
"@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",
|
||||
"@openlayers-elements/core": "^0.4.0",
|
||||
"@openlayers-elements/maps": "^0.4.0",
|
||||
"@patternfly/elements": "^4.1.0",
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@sentry/browser": "^9.30.0",
|
||||
|
@ -1,3 +1,7 @@
|
||||
import "#elements/Tabs";
|
||||
import { WithLicenseSummary } from "#elements/mixins/license";
|
||||
import { updateURLParams } from "#elements/router/RouteMatch";
|
||||
import "@goauthentik/admin/events/EventMap";
|
||||
import "@goauthentik/admin/events/EventVolumeChart";
|
||||
import { EventGeo, EventUser } from "@goauthentik/admin/events/utils";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
@ -15,11 +19,14 @@ import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { Event, EventsApi } from "@goauthentik/api";
|
||||
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
||||
|
||||
import { Event, EventsApi, LicenseSummaryStatusEnum } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-event-list")
|
||||
export class EventListPage extends TablePage<Event> {
|
||||
export class EventListPage extends WithLicenseSummary(TablePage<Event>) {
|
||||
expandable = true;
|
||||
supportsQL = true;
|
||||
|
||||
pageTitle(): string {
|
||||
return msg("Event Log");
|
||||
@ -38,11 +45,15 @@ export class EventListPage extends TablePage<Event> {
|
||||
order = "-created";
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return super.styles.concat(css`
|
||||
.pf-m-no-padding-bottom {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`);
|
||||
// @ts-expect-error
|
||||
return super.styles.concat(
|
||||
PFGrid,
|
||||
css`
|
||||
.pf-m-no-padding-bottom {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async apiEndpoint(): Promise<PaginatedResponse<Event>> {
|
||||
@ -61,16 +72,39 @@ export class EventListPage extends TablePage<Event> {
|
||||
}
|
||||
|
||||
renderSectionBefore(): TemplateResult {
|
||||
return html`
|
||||
<div class="pf-c-page__main-section pf-m-no-padding-bottom">
|
||||
if (this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed) {
|
||||
return html`<div
|
||||
class="pf-l-grid pf-m-gutter pf-c-page__main-section pf-m-no-padding-bottom"
|
||||
>
|
||||
<ak-events-volume-chart
|
||||
class="pf-l-grid__item pf-m-12-col pf-m-4-col-on-xl pf-m-4-col-on-2xl "
|
||||
.query=${{
|
||||
page: this.page,
|
||||
search: this.search,
|
||||
}}
|
||||
with-map
|
||||
></ak-events-volume-chart>
|
||||
</div>
|
||||
`;
|
||||
<ak-events-map
|
||||
class="pf-l-grid__item pf-m-12-col pf-m-8-col-on-xl pf-m-8-col-on-2xl "
|
||||
.events=${this.data}
|
||||
@select-event=${(ev: CustomEvent<{ eventId: string }>) => {
|
||||
this.search = `event_uuid = "${ev.detail.eventId}"`;
|
||||
this.page = 1;
|
||||
updateURLParams({
|
||||
search: this.search,
|
||||
tablePage: this.page,
|
||||
});
|
||||
this.fetch();
|
||||
}}
|
||||
></ak-events-map>
|
||||
</div>`;
|
||||
}
|
||||
return html`<ak-events-volume-chart
|
||||
.query=${{
|
||||
page: this.page,
|
||||
search: this.search,
|
||||
}}
|
||||
></ak-events-volume-chart>`;
|
||||
}
|
||||
|
||||
row(item: EventWithContext): SlottedTemplateResult[] {
|
||||
|
139
web/src/admin/events/EventMap.ts
Normal file
139
web/src/admin/events/EventMap.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { EventWithContext } from "#common/events";
|
||||
import { globalAK } from "#common/global";
|
||||
import { PaginatedResponse } from "#elements/table/Table";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@openlayers-elements/core/ol-layer-vector";
|
||||
import type OlLayerVector from "@openlayers-elements/core/ol-layer-vector";
|
||||
import "@openlayers-elements/core/ol-map";
|
||||
import type OlMap from "@openlayers-elements/core/ol-map";
|
||||
import "@openlayers-elements/maps/ol-layer-openstreetmap";
|
||||
import "@openlayers-elements/maps/ol-select";
|
||||
import Feature from "ol/Feature";
|
||||
import { Point } from "ol/geom";
|
||||
import { fromLonLat } from "ol/proj";
|
||||
import Icon from "ol/style/Icon";
|
||||
import Style from "ol/style/Style";
|
||||
|
||||
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
|
||||
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { Event } from "@goauthentik/api";
|
||||
|
||||
/**
|
||||
*
|
||||
* @event {select-event} - Fired when an event is selected on the map. ID of the event is contained
|
||||
* in the `Event.detail` field.
|
||||
*
|
||||
*/
|
||||
@customElement("ak-events-map")
|
||||
export class EventMap extends AKElement {
|
||||
@property({ attribute: false })
|
||||
events?: PaginatedResponse<Event>;
|
||||
|
||||
@query("ol-layer-vector")
|
||||
vectorLayer?: OlLayerVector;
|
||||
|
||||
@query("ol-map")
|
||||
map?: OlMap;
|
||||
|
||||
@property({ type: Number })
|
||||
zoomPaddingPx = 100;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
PFBase,
|
||||
PFCard,
|
||||
css`
|
||||
.pf-c-card,
|
||||
ol-map {
|
||||
height: 24rem;
|
||||
}
|
||||
:host([theme="dark"]) ol-map {
|
||||
filter: invert(100%) hue-rotate(180deg);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
updated(_changedProperties: PropertyValues<this>): void {
|
||||
if (!_changedProperties.has("events")) {
|
||||
return;
|
||||
}
|
||||
if (!this.vectorLayer?.source || !this.map?.map) {
|
||||
return;
|
||||
}
|
||||
// Remove all existing points
|
||||
this.vectorLayer.source.clear();
|
||||
// Re-add them
|
||||
this.events?.results
|
||||
.filter((event) => {
|
||||
if (!Object.hasOwn(event.context, "geo")) {
|
||||
return false;
|
||||
}
|
||||
const geo = (event as EventWithContext).context.geo;
|
||||
if (!geo?.lat || !geo.long) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.forEach((event) => {
|
||||
const geo = (event as EventWithContext).context.geo!;
|
||||
const point = new Point(fromLonLat([geo.long!, geo.lat!]));
|
||||
const feature = new Feature({
|
||||
geometry: point,
|
||||
});
|
||||
feature.setStyle(
|
||||
new Style({
|
||||
image: new Icon({
|
||||
anchor: [0.5, 1],
|
||||
offset: [0, 0],
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotateWithView: false,
|
||||
rotation: 0,
|
||||
src: `${globalAK().api.base}static/dist/assets/images/map_pin.svg`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
feature.setId(event.pk);
|
||||
this.vectorLayer?.source?.addFeature(feature);
|
||||
});
|
||||
// Zoom to show points better
|
||||
this.map.map.getView().fit(this.vectorLayer.source.getExtent(), {
|
||||
padding: [
|
||||
this.zoomPaddingPx,
|
||||
this.zoomPaddingPx,
|
||||
this.zoomPaddingPx,
|
||||
this.zoomPaddingPx,
|
||||
],
|
||||
duration: 500,
|
||||
maxZoom: 4.5,
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="pf-c-card">
|
||||
<ol-map>
|
||||
<ol-select
|
||||
@feature-selected=${(ev: CustomEvent<{ feature: Feature }>) => {
|
||||
const eventId = ev.detail.feature.getId();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select-event", {
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
detail: {
|
||||
eventId: eventId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
></ol-select>
|
||||
<ol-layer-openstreetmap></ol-layer-openstreetmap>
|
||||
<ol-layer-vector></ol-layer-vector>
|
||||
</ol-map>
|
||||
</div>`;
|
||||
}
|
||||
}
|
@ -11,11 +11,14 @@ import { EventVolume, EventsApi, EventsEventsListRequest } from "@goauthentik/ap
|
||||
|
||||
@customElement("ak-events-volume-chart")
|
||||
export class EventVolumeChart extends EventChart {
|
||||
@property({ attribute: "with-map", type: Boolean })
|
||||
withMap = false;
|
||||
|
||||
_query?: EventsEventsListRequest;
|
||||
|
||||
@property({ attribute: false })
|
||||
set query(value: EventsEventsListRequest | undefined) {
|
||||
if (JSON.stringify(this._query) === JSON.stringify(value)) return;
|
||||
if (JSON.stringify(value) !== JSON.stringify(this._query)) return;
|
||||
this._query = value;
|
||||
this.refreshHandler();
|
||||
}
|
||||
@ -24,6 +27,9 @@ export class EventVolumeChart extends EventChart {
|
||||
return super.styles.concat(
|
||||
PFCard,
|
||||
css`
|
||||
:host([with-map]) .pf-c-card {
|
||||
height: 24rem;
|
||||
}
|
||||
.pf-c-card {
|
||||
height: 20rem;
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ export class RuleForm extends ModelForm<NotificationRule, string> {
|
||||
required
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Group")} name="group">
|
||||
<ak-form-element-horizontal label=${msg("Group")} name="destinationGroup">
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<Group[]> => {
|
||||
const args: CoreGroupsListRequest = {
|
||||
@ -86,14 +86,44 @@ export class RuleForm extends ModelForm<NotificationRule, string> {
|
||||
return group?.pk;
|
||||
}}
|
||||
.selected=${(group: Group): boolean => {
|
||||
return group.pk === this.instance?.group;
|
||||
return group.pk === this.instance?.destinationGroup;
|
||||
}}
|
||||
blankable
|
||||
>
|
||||
</ak-search-select>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Select the group of users which the alerts are sent to. ")}
|
||||
</p>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Select the group of users which the alerts are sent to. If no group is selected the rule is disabled.",
|
||||
"If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="destinationEventUser">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.instance?.destinationEventUser ?? false}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="pf-c-switch__label"
|
||||
>${msg("Send notification to event user")}</span
|
||||
>
|
||||
</label>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.",
|
||||
)}
|
||||
</p>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
|
@ -3,6 +3,7 @@ import "@goauthentik/admin/policies/BoundPoliciesList";
|
||||
import "@goauthentik/admin/rbac/ObjectPermissionModal";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { severityToLabel } from "@goauthentik/common/labels";
|
||||
import "@goauthentik/components/ak-status-label";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
@ -51,6 +52,7 @@ export class RuleListPage extends TablePage<NotificationRule> {
|
||||
|
||||
columns(): TableColumn[] {
|
||||
return [
|
||||
new TableColumn(msg("Enabled")),
|
||||
new TableColumn(msg("Name"), "name"),
|
||||
new TableColumn(msg("Severity"), "severity"),
|
||||
new TableColumn(msg("Sent to group"), "group"),
|
||||
@ -81,12 +83,16 @@ export class RuleListPage extends TablePage<NotificationRule> {
|
||||
}
|
||||
|
||||
row(item: NotificationRule): TemplateResult[] {
|
||||
const enabled = !!item.destinationGroupObj || item.destinationEventUser;
|
||||
return [
|
||||
html`<ak-status-label type="warning" ?good=${enabled}></ak-status-label>`,
|
||||
html`${item.name}`,
|
||||
html`${severityToLabel(item.severity)}`,
|
||||
html`${item.groupObj
|
||||
? html`<a href="#/identity/groups/${item.groupObj.pk}">${item.groupObj.name}</a>`
|
||||
: msg("None (rule disabled)")}`,
|
||||
html`${item.destinationGroupObj
|
||||
? html`<a href="#/identity/groups/${item.destinationGroupObj.pk}"
|
||||
>${item.destinationGroupObj.name}</a
|
||||
>`
|
||||
: msg("-")}`,
|
||||
html`<ak-forms-modal>
|
||||
<span slot="submit"> ${msg("Update")} </span>
|
||||
<span slot="header"> ${msg("Update Notification Rule")} </span>
|
||||
|
@ -85,6 +85,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
expandable = true;
|
||||
checkbox = true;
|
||||
clearOnRefresh = true;
|
||||
supportsQL = true;
|
||||
|
||||
searchEnabled(): boolean {
|
||||
return true;
|
||||
|
77
web/src/assets/images/map_pin.svg
Normal file
77
web/src/assets/images/map_pin.svg
Normal file
@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="5.6444445mm"
|
||||
height="9.847393mm"
|
||||
viewBox="0 0 20 34.892337"
|
||||
id="svg3455"
|
||||
version="1.1"
|
||||
inkscape:version="0.91 r13725"
|
||||
sodipodi:docname="Map Pin.svg">
|
||||
<defs
|
||||
id="defs3457" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="12.181359"
|
||||
inkscape:cx="8.4346812"
|
||||
inkscape:cy="14.715224"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1024"
|
||||
inkscape:window-height="705"
|
||||
inkscape:window-x="-4"
|
||||
inkscape:window-y="-4"
|
||||
inkscape:window-maximized="1"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0" />
|
||||
<metadata
|
||||
id="metadata3460">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-814.59595,-274.38623)">
|
||||
<g
|
||||
id="g3477"
|
||||
transform="matrix(1.1855854,0,0,1.1855854,-151.17715,-57.3976)">
|
||||
<path
|
||||
sodipodi:nodetypes="sscccccsscs"
|
||||
inkscape:connector-curvature="0"
|
||||
id="path4337-3"
|
||||
d="m 817.11249,282.97118 c -1.25816,1.34277 -2.04623,3.29881 -2.01563,5.13867 0.0639,3.84476 1.79693,5.3002 4.56836,10.59179 0.99832,2.32851 2.04027,4.79237 3.03125,8.87305 0.13772,0.60193 0.27203,1.16104 0.33416,1.20948 0.0621,0.0485 0.19644,-0.51262 0.33416,-1.11455 0.99098,-4.08068 2.03293,-6.54258 3.03125,-8.87109 2.77143,-5.29159 4.50444,-6.74704 4.56836,-10.5918 0.0306,-1.83986 -0.75942,-3.79785 -2.01758,-5.14062 -1.43724,-1.53389 -3.60504,-2.66908 -5.91619,-2.71655 -2.31115,-0.0475 -4.4809,1.08773 -5.91814,2.62162 z"
|
||||
style="display:inline;opacity:1;fill:#fd4b2d;fill-opacity:1;" />
|
||||
<circle
|
||||
r="3.0355"
|
||||
cy="288.25278"
|
||||
cx="823.03064"
|
||||
id="path3049"
|
||||
style="display:inline;opacity:1;fill:#590000;fill-opacity:1;stroke-width:0" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
@ -12,6 +12,8 @@ export interface EventGeo {
|
||||
city?: string;
|
||||
country?: string;
|
||||
continent?: string;
|
||||
lat?: number;
|
||||
long?: number;
|
||||
}
|
||||
|
||||
export interface EventModel {
|
||||
|
306
web/src/components/ak-search-ql/index.ts
Normal file
306
web/src/components/ak-search-ql/index.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -76,7 +76,6 @@ export class ObjectChangelog extends Table<Event> {
|
||||
html`<div>${formatElapsedTime(item.created)}</div>
|
||||
<small>${item.created.toLocaleString()}</small>`,
|
||||
html`<div>${item.clientIp || msg("-")}</div>
|
||||
|
||||
<small>${EventGeo(item)}</small>`,
|
||||
];
|
||||
}
|
||||
|
@ -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>`;
|
||||
|
@ -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,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`<ak-search-ql
|
||||
.apiResponse=${this.apiResponse}
|
||||
.value=${this.value}
|
||||
.onSearch=${(value: string) => {
|
||||
if (!this.onSearch) return;
|
||||
this.onSearch(value);
|
||||
}}
|
||||
name="search"
|
||||
></ak-search-ql>`;
|
||||
}
|
||||
return html`<input
|
||||
class="pf-c-form-control"
|
||||
name="search"
|
||||
type="search"
|
||||
placeholder=${msg("Search...")}
|
||||
value="${ifDefined(this.value)}"
|
||||
@search=${(ev: Event) => {
|
||||
if (!this.onSearch) return;
|
||||
this.onSearch((ev.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<form
|
||||
class="pf-c-input-group"
|
||||
method="GET"
|
||||
method="get"
|
||||
@submit=${(e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!this.onSearch) return;
|
||||
const el = this.shadowRoot?.querySelector<HTMLInputElement>("input[type=search]");
|
||||
const el = this.shadowRoot?.querySelector<HTMLInputElement | HTMLTextAreaElement>(
|
||||
"[name=search]",
|
||||
);
|
||||
if (!el) return;
|
||||
if (el.value === "") return;
|
||||
this.onSearch(el?.value);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="pf-c-form-control"
|
||||
name="search"
|
||||
type="search"
|
||||
placeholder=${msg("Search...")}
|
||||
value="${ifDefined(this.value)}"
|
||||
@search=${(ev: Event) => {
|
||||
if (!this.onSearch) return;
|
||||
this.onSearch((ev.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
${this.renderInput()}
|
||||
<button
|
||||
class="pf-c-button pf-m-control"
|
||||
type="reset"
|
||||
@click=${() => {
|
||||
if (!this.onSearch) return;
|
||||
this.value = "";
|
||||
this.onSearch("");
|
||||
}}
|
||||
>
|
||||
|
Reference in New Issue
Block a user