mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
policies/event_matcher: Add query option to filter events (#21618)
* policies/event_matcher: support QL query Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix lit dev warning Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cache autocomplete data if QL isn't setup yet Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add ui Signed-off-by: Jens Langhammer <jens@goauthentik.io> * dont use ql input in modal Signed-off-by: Jens Langhammer <jens@goauthentik.io> * cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix codespell Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
@@ -40,18 +40,14 @@ class EventMatcherPolicySerializer(PolicySerializer):
|
|||||||
and attrs["client_ip"] == ""
|
and attrs["client_ip"] == ""
|
||||||
and attrs["app"] == ""
|
and attrs["app"] == ""
|
||||||
and attrs["model"] == ""
|
and attrs["model"] == ""
|
||||||
|
and attrs["query"] == ""
|
||||||
):
|
):
|
||||||
raise ValidationError(_("At least one criteria must be set."))
|
raise ValidationError(_("At least one criteria must be set."))
|
||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = EventMatcherPolicy
|
model = EventMatcherPolicy
|
||||||
fields = PolicySerializer.Meta.fields + [
|
fields = PolicySerializer.Meta.fields + ["action", "client_ip", "app", "model", "query"]
|
||||||
"action",
|
|
||||||
"client_ip",
|
|
||||||
"app",
|
|
||||||
"model",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class EventMatcherPolicyViewSet(UsedByMixin, ModelViewSet):
|
class EventMatcherPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.12 on 2026-04-12 19:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_policies_event_matcher", "0026_alter_eventmatcherpolicy_action"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="eventmatcherpolicy",
|
||||||
|
name="query",
|
||||||
|
field=models.TextField(default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,9 +4,11 @@ from itertools import chain
|
|||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from djangoql.queryset import apply_search
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.api.search.ql import BaseSchema
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.policies.models import Policy
|
from authentik.policies.models import Policy
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
@@ -17,6 +19,10 @@ LOGGER = get_logger()
|
|||||||
class EventMatcherPolicy(Policy):
|
class EventMatcherPolicy(Policy):
|
||||||
"""Passes when Event matches selected criteria."""
|
"""Passes when Event matches selected criteria."""
|
||||||
|
|
||||||
|
query = models.TextField(
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
action = models.TextField(
|
action = models.TextField(
|
||||||
choices=EventAction.choices,
|
choices=EventAction.choices,
|
||||||
null=True,
|
null=True,
|
||||||
@@ -69,6 +75,7 @@ class EventMatcherPolicy(Policy):
|
|||||||
matches: list[PolicyResult] = []
|
matches: list[PolicyResult] = []
|
||||||
messages = []
|
messages = []
|
||||||
checks = [
|
checks = [
|
||||||
|
self.passes_query,
|
||||||
self.passes_action,
|
self.passes_action,
|
||||||
self.passes_client_ip,
|
self.passes_client_ip,
|
||||||
self.passes_app,
|
self.passes_app,
|
||||||
@@ -90,6 +97,20 @@ class EventMatcherPolicy(Policy):
|
|||||||
result.source_results = matches
|
result.source_results = matches
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def passes_query(self, request: PolicyRequest, event: Event) -> PolicyResult | None:
|
||||||
|
"""Check AKQL query"""
|
||||||
|
if not self.query:
|
||||||
|
return None
|
||||||
|
from authentik.events.api.events import EventViewSet
|
||||||
|
|
||||||
|
class InlineSchema(BaseSchema):
|
||||||
|
def get_fields(self, model):
|
||||||
|
return EventViewSet().get_ql_fields()
|
||||||
|
|
||||||
|
print(Event.objects.filter(pk=event.pk))
|
||||||
|
qs = apply_search(Event.objects.filter(pk=event.pk), self.query, InlineSchema)
|
||||||
|
return PolicyResult(qs.exists(), "Query matched.")
|
||||||
|
|
||||||
def passes_action(self, request: PolicyRequest, event: Event) -> PolicyResult | None:
|
def passes_action(self, request: PolicyRequest, event: Event) -> PolicyResult | None:
|
||||||
"""Check if `self.action` matches"""
|
"""Check if `self.action` matches"""
|
||||||
if self.action is None:
|
if self.action is None:
|
||||||
|
|||||||
@@ -101,3 +101,14 @@ class TestEventMatcherPolicy(TestCase):
|
|||||||
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(client_ip="1.2.3.4")
|
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(client_ip="1.2.3.4")
|
||||||
response = policy.passes(request)
|
response = policy.passes(request)
|
||||||
self.assertFalse(response.passing)
|
self.assertFalse(response.passing)
|
||||||
|
|
||||||
|
def test_match_query(self):
|
||||||
|
"""Test match query"""
|
||||||
|
event = Event.new(EventAction.LOGIN)
|
||||||
|
event.save()
|
||||||
|
request = PolicyRequest(get_anonymous_user())
|
||||||
|
request.context["event"] = event
|
||||||
|
policy: EventMatcherPolicy = EventMatcherPolicy.objects.create(query='action = "login"')
|
||||||
|
response = policy.passes(request)
|
||||||
|
self.assertTrue(response.passing)
|
||||||
|
self.assertTupleEqual(response.messages, ("Query matched.",))
|
||||||
|
|||||||
@@ -9072,6 +9072,14 @@
|
|||||||
],
|
],
|
||||||
"title": "Model",
|
"title": "Model",
|
||||||
"description": "Match events created by selected model. When left empty, all models are matched. When an app is selected, all the application's models are matched."
|
"description": "Match events created by selected model. When left empty, all models are matched. When an app is selected, all the application's models are matched."
|
||||||
|
},
|
||||||
|
"query": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"minLength": 1,
|
||||||
|
"title": "Query"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": []
|
"required": []
|
||||||
|
|||||||
@@ -138,7 +138,8 @@
|
|||||||
"SshRsa",
|
"SshRsa",
|
||||||
"UnicodeRef",
|
"UnicodeRef",
|
||||||
"Email",
|
"Email",
|
||||||
"HashStrings"
|
"HashStrings",
|
||||||
|
"AKQL"
|
||||||
],
|
],
|
||||||
"languageSettings": [
|
"languageSettings": [
|
||||||
{
|
{
|
||||||
|
|||||||
5
packages/client-ts/src/apis/PoliciesApi.ts
generated
5
packages/client-ts/src/apis/PoliciesApi.ts
generated
@@ -239,6 +239,7 @@ export interface PoliciesEventMatcherListRequest {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
policyUuid?: string;
|
policyUuid?: string;
|
||||||
|
query?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2220,6 +2221,10 @@ export class PoliciesApi extends runtime.BaseAPI {
|
|||||||
queryParameters["policy_uuid"] = requestParameters["policyUuid"];
|
queryParameters["policy_uuid"] = requestParameters["policyUuid"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestParameters["query"] != null) {
|
||||||
|
queryParameters["query"] = requestParameters["query"];
|
||||||
|
}
|
||||||
|
|
||||||
if (requestParameters["search"] != null) {
|
if (requestParameters["search"] != null) {
|
||||||
queryParameters["search"] = requestParameters["search"];
|
queryParameters["search"] = requestParameters["search"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ export interface EventMatcherPolicy {
|
|||||||
* @memberof EventMatcherPolicy
|
* @memberof EventMatcherPolicy
|
||||||
*/
|
*/
|
||||||
model?: ModelEnum | null;
|
model?: ModelEnum | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof EventMatcherPolicy
|
||||||
|
*/
|
||||||
|
query?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,6 +143,7 @@ export function EventMatcherPolicyFromJSONTyped(
|
|||||||
clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
|
clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
|
||||||
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
|
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
|
||||||
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
|
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
|
||||||
|
query: json["query"] == null ? undefined : json["query"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,5 +169,6 @@ export function EventMatcherPolicyToJSONTyped(
|
|||||||
client_ip: value["clientIp"],
|
client_ip: value["clientIp"],
|
||||||
app: AppEnumToJSON(value["app"]),
|
app: AppEnumToJSON(value["app"]),
|
||||||
model: ModelEnumToJSON(value["model"]),
|
model: ModelEnumToJSON(value["model"]),
|
||||||
|
query: value["query"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export interface EventMatcherPolicyRequest {
|
|||||||
* @memberof EventMatcherPolicyRequest
|
* @memberof EventMatcherPolicyRequest
|
||||||
*/
|
*/
|
||||||
model?: ModelEnum | null;
|
model?: ModelEnum | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof EventMatcherPolicyRequest
|
||||||
|
*/
|
||||||
|
query?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +97,7 @@ export function EventMatcherPolicyRequestFromJSONTyped(
|
|||||||
clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
|
clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
|
||||||
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
|
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
|
||||||
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
|
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
|
||||||
|
query: json["query"] == null ? undefined : json["query"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,5 +120,6 @@ export function EventMatcherPolicyRequestToJSONTyped(
|
|||||||
client_ip: value["clientIp"],
|
client_ip: value["clientIp"],
|
||||||
app: AppEnumToJSON(value["app"]),
|
app: AppEnumToJSON(value["app"]),
|
||||||
model: ModelEnumToJSON(value["model"]),
|
model: ModelEnumToJSON(value["model"]),
|
||||||
|
query: value["query"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export interface PatchedEventMatcherPolicyRequest {
|
|||||||
* @memberof PatchedEventMatcherPolicyRequest
|
* @memberof PatchedEventMatcherPolicyRequest
|
||||||
*/
|
*/
|
||||||
model?: ModelEnum | null;
|
model?: ModelEnum | null;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof PatchedEventMatcherPolicyRequest
|
||||||
|
*/
|
||||||
|
query?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +98,7 @@ export function PatchedEventMatcherPolicyRequestFromJSONTyped(
|
|||||||
clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
|
clientIp: json["client_ip"] == null ? undefined : json["client_ip"],
|
||||||
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
|
app: json["app"] == null ? undefined : AppEnumFromJSON(json["app"]),
|
||||||
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
|
model: json["model"] == null ? undefined : ModelEnumFromJSON(json["model"]),
|
||||||
|
query: json["query"] == null ? undefined : json["query"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,5 +123,6 @@ export function PatchedEventMatcherPolicyRequestToJSONTyped(
|
|||||||
client_ip: value["clientIp"],
|
client_ip: value["clientIp"],
|
||||||
app: AppEnumToJSON(value["app"]),
|
app: AppEnumToJSON(value["app"]),
|
||||||
model: ModelEnumToJSON(value["model"]),
|
model: ModelEnumToJSON(value["model"]),
|
||||||
|
query: value["query"],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
15
schema.yml
15
schema.yml
@@ -11424,6 +11424,10 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
|
- in: query
|
||||||
|
name: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
- $ref: '#/components/parameters/QuerySearch'
|
- $ref: '#/components/parameters/QuerySearch'
|
||||||
tags:
|
tags:
|
||||||
- policies
|
- policies
|
||||||
@@ -38255,6 +38259,9 @@ components:
|
|||||||
description: Match events created by selected model. When left empty, all
|
description: Match events created by selected model. When left empty, all
|
||||||
models are matched. When an app is selected, all the application's models
|
models are matched. When an app is selected, all the application's models
|
||||||
are matched.
|
are matched.
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
required:
|
required:
|
||||||
- bound_to
|
- bound_to
|
||||||
- component
|
- component
|
||||||
@@ -38299,6 +38306,10 @@ components:
|
|||||||
description: Match events created by selected model. When left empty, all
|
description: Match events created by selected model. When left empty, all
|
||||||
models are matched. When an app is selected, all the application's models
|
models are matched. When an app is selected, all the application's models
|
||||||
are matched.
|
are matched.
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
minLength: 1
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
EventRequest:
|
EventRequest:
|
||||||
@@ -47867,6 +47878,10 @@ components:
|
|||||||
description: Match events created by selected model. When left empty, all
|
description: Match events created by selected model. When left empty, all
|
||||||
models are matched. When an app is selected, all the application's models
|
models are matched. When an app is selected, all the application's models
|
||||||
are matched.
|
are matched.
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
minLength: 1
|
||||||
PatchedEventRequest:
|
PatchedEventRequest:
|
||||||
type: object
|
type: object
|
||||||
description: Event Serializer
|
description: Event Serializer
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "#elements/forms/HorizontalFormElement";
|
|||||||
import "#elements/forms/SearchSelect/index";
|
import "#elements/forms/SearchSelect/index";
|
||||||
|
|
||||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||||
|
import { docLink } from "#common/global";
|
||||||
|
|
||||||
import { BasePolicyForm } from "#admin/policies/BasePolicyForm";
|
import { BasePolicyForm } from "#admin/policies/BasePolicyForm";
|
||||||
|
|
||||||
@@ -23,13 +24,14 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
|||||||
|
|
||||||
@customElement("ak-policy-event-matcher-form")
|
@customElement("ak-policy-event-matcher-form")
|
||||||
export class EventMatcherPolicyForm extends BasePolicyForm<EventMatcherPolicy> {
|
export class EventMatcherPolicyForm extends BasePolicyForm<EventMatcherPolicy> {
|
||||||
loadInstance(pk: string): Promise<EventMatcherPolicy> {
|
override loadInstance(pk: string): Promise<EventMatcherPolicy> {
|
||||||
return new PoliciesApi(DEFAULT_CONFIG).policiesEventMatcherRetrieve({
|
return new PoliciesApi(DEFAULT_CONFIG).policiesEventMatcherRetrieve({
|
||||||
policyUuid: pk,
|
policyUuid: pk,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(data: EventMatcherPolicy): Promise<EventMatcherPolicy> {
|
async send(data: EventMatcherPolicy): Promise<EventMatcherPolicy> {
|
||||||
|
if (data.query?.toString() === "") data.query = null;
|
||||||
if (data.action?.toString() === "") data.action = null;
|
if (data.action?.toString() === "") data.action = null;
|
||||||
if (data.clientIp?.toString() === "") data.clientIp = null;
|
if (data.clientIp?.toString() === "") data.clientIp = null;
|
||||||
if (data.app?.toString() === "") data.app = null;
|
if (data.app?.toString() === "") data.app = null;
|
||||||
@@ -70,6 +72,25 @@ export class EventMatcherPolicyForm extends BasePolicyForm<EventMatcherPolicy> {
|
|||||||
</ak-switch-input>
|
</ak-switch-input>
|
||||||
<ak-form-group open label="${msg("Policy-specific settings")}">
|
<ak-form-group open label="${msg("Policy-specific settings")}">
|
||||||
<div class="pf-c-form">
|
<div class="pf-c-form">
|
||||||
|
<ak-form-element-horizontal label=${msg("Query")} name="query">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value="${ifDefined(this.instance?.query || "")}"
|
||||||
|
class="pf-c-form-control pf-m-monospace"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<p class="pf-c-form__helper-text">
|
||||||
|
${msg("Event query using the AKQL syntax.")}
|
||||||
|
<a
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
href=${docLink("/sys-mgmt/events/logging-events/#advanced-queries")}
|
||||||
|
>
|
||||||
|
${msg("See documentation for examples.")}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
<ak-form-element-horizontal label=${msg("Action")} name="action">
|
<ak-form-element-horizontal label=${msg("Action")} name="action">
|
||||||
<ak-search-select
|
<ak-search-select
|
||||||
.fetchObjects=${async (query?: string): Promise<TypeCreate[]> => {
|
.fetchObjects=${async (query?: string): Promise<TypeCreate[]> => {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import DjangoQL, { Introspections } from "@mrmarble/djangoql-completion";
|
|||||||
import { msg } from "@lit/localize";
|
import { msg } from "@lit/localize";
|
||||||
import { CSSResult, html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
|
import { CSSResult, html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
|
||||||
import { customElement, property } from "lit/decorators.js";
|
import { customElement, property } from "lit/decorators.js";
|
||||||
import { ifDefined } from "lit/directives/if-defined.js";
|
|
||||||
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
||||||
|
|
||||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||||
@@ -114,9 +113,14 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
|||||||
#ctx: OffscreenCanvasRenderingContext2D | null = null;
|
#ctx: OffscreenCanvasRenderingContext2D | null = null;
|
||||||
#letterWidth = -1;
|
#letterWidth = -1;
|
||||||
#scrollContainer: HTMLElement | null = null;
|
#scrollContainer: HTMLElement | null = null;
|
||||||
|
#autocompleteCache: Introspections | null = null;
|
||||||
|
|
||||||
public set apiResponse(value: PaginatedResponse<unknown> | undefined) {
|
public set apiResponse(value: PaginatedResponse<unknown> | undefined) {
|
||||||
if (!value?.autocomplete || !this.#ql) {
|
if (!value?.autocomplete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.#ql) {
|
||||||
|
this.#autocompleteCache = value.autocomplete as unknown as Introspections;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,13 +181,16 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
|||||||
|
|
||||||
this.#ql = new QL({
|
this.#ql = new QL({
|
||||||
completionEnabled: true,
|
completionEnabled: true,
|
||||||
introspections: {
|
introspections: this.#autocompleteCache || {
|
||||||
current_model: "",
|
current_model: "",
|
||||||
models: {},
|
models: {},
|
||||||
},
|
},
|
||||||
selector: textarea,
|
selector: textarea,
|
||||||
autoResize: false,
|
autoResize: false,
|
||||||
});
|
});
|
||||||
|
if (this.#autocompleteCache) {
|
||||||
|
this.#autocompleteCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
const canvas = new OffscreenCanvas(300, 150);
|
const canvas = new OffscreenCanvas(300, 150);
|
||||||
this.#ctx = canvas.getContext("2d");
|
this.#ctx = canvas.getContext("2d");
|
||||||
@@ -478,9 +485,8 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
|||||||
@focus=${this.#focusListener}
|
@focus=${this.#focusListener}
|
||||||
@blur=${this.#blurListener}
|
@blur=${this.#blurListener}
|
||||||
@keydown=${this.#keydownListener}
|
@keydown=${this.#keydownListener}
|
||||||
>
|
.value=${this.#value}
|
||||||
${ifDefined(this.#value)}</textarea
|
></textarea>
|
||||||
>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${this.renderMenu()}
|
${this.renderMenu()}
|
||||||
|
|||||||
Reference in New Issue
Block a user