mirror of
https://github.com/goauthentik/authentik
synced 2026-05-07 15:42:48 +02:00
Compare commits
16 Commits
playwright
...
docs-remov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cb339f3a | ||
|
|
e124e21119 | ||
|
|
213cf44928 | ||
|
|
dc0c7a858a | ||
|
|
3fddbb918e | ||
|
|
3c97b081b0 | ||
|
|
ba725365ec | ||
|
|
e5e9708ec2 | ||
|
|
6a604e42ca | ||
|
|
ab1f87cfd6 | ||
|
|
de9b795c97 | ||
|
|
0377e3593e | ||
|
|
951c24dab5 | ||
|
|
707eca883e | ||
|
|
8bc64ea478 | ||
|
|
8b1240ff0b |
15
Dockerfile
15
Dockerfile
@@ -134,11 +134,16 @@ ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
LABEL org.opencontainers.image.url=https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description="goauthentik.io Main server image, see https://goauthentik.io for more info."
|
||||
LABEL org.opencontainers.image.source=https://github.com/goauthentik/authentik
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
LABEL org.opencontainers.image.revision=${GIT_BUILD_HASH}
|
||||
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
|
||||
org.opencontainers.image.description="goauthentik.io Main server image, see https://goauthentik.io for more info." \
|
||||
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
|
||||
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \
|
||||
org.opencontainers.image.revision=${GIT_BUILD_HASH} \
|
||||
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
|
||||
org.opencontainers.image.title="authentik server image" \
|
||||
org.opencontainers.image.url="https://goauthentik.io" \
|
||||
org.opencontainers.image.vendor="Authentik Security Inc." \
|
||||
org.opencontainers.image.version=${VERSION}
|
||||
|
||||
WORKDIR /
|
||||
|
||||
|
||||
@@ -49,11 +49,28 @@ class GroupMemberSerializer(ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class GroupChildSerializer(ModelSerializer):
|
||||
"""Stripped down group serializer to show relevant children for groups"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Group
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"is_superuser",
|
||||
"attributes",
|
||||
"group_uuid",
|
||||
]
|
||||
|
||||
|
||||
class GroupSerializer(ModelSerializer):
|
||||
"""Group Serializer"""
|
||||
|
||||
attributes = JSONDictField(required=False)
|
||||
users_obj = SerializerMethodField(allow_null=True)
|
||||
children_obj = SerializerMethodField(allow_null=True)
|
||||
roles_obj = ListSerializer(
|
||||
child=RoleSerializer(),
|
||||
read_only=True,
|
||||
@@ -61,7 +78,6 @@ class GroupSerializer(ModelSerializer):
|
||||
required=False,
|
||||
)
|
||||
parent_name = CharField(source="parent.name", read_only=True, allow_null=True)
|
||||
|
||||
num_pk = IntegerField(read_only=True)
|
||||
|
||||
@property
|
||||
@@ -71,12 +87,25 @@ class GroupSerializer(ModelSerializer):
|
||||
return True
|
||||
return str(request.query_params.get("include_users", "true")).lower() == "true"
|
||||
|
||||
@property
|
||||
def _should_include_children(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_children", "false")).lower() == "true"
|
||||
|
||||
@extend_schema_field(GroupMemberSerializer(many=True))
|
||||
def get_users_obj(self, instance: Group) -> list[GroupMemberSerializer] | None:
|
||||
if not self._should_include_users:
|
||||
return None
|
||||
return GroupMemberSerializer(instance.users, many=True).data
|
||||
|
||||
@extend_schema_field(GroupChildSerializer(many=True))
|
||||
def get_children_obj(self, instance: Group) -> list[GroupChildSerializer] | None:
|
||||
if not self._should_include_children:
|
||||
return None
|
||||
return GroupChildSerializer(instance.children, many=True).data
|
||||
|
||||
def validate_parent(self, parent: Group | None):
|
||||
"""Validate group parent (if set), ensuring the parent isn't itself"""
|
||||
if not self.instance or not parent:
|
||||
@@ -126,11 +155,17 @@ class GroupSerializer(ModelSerializer):
|
||||
"attributes",
|
||||
"roles",
|
||||
"roles_obj",
|
||||
"children",
|
||||
"children_obj",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"users": {
|
||||
"default": list,
|
||||
},
|
||||
"children": {
|
||||
"required": False,
|
||||
"default": list,
|
||||
},
|
||||
# TODO: This field isn't unique on the database which is hard to backport
|
||||
# hence we just validate the uniqueness here
|
||||
"name": {"validators": [UniqueValidator(Group.objects.all())]},
|
||||
@@ -203,11 +238,15 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||
)
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_children:
|
||||
base_qs = base_qs.prefetch_related("children")
|
||||
|
||||
return base_qs
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
@@ -216,6 +255,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
]
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
|
||||
@@ -5,7 +5,7 @@ from json import loads
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import AnonymousUser, Permission
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django.urls import reverse_lazy
|
||||
@@ -16,6 +16,7 @@ from django.utils.translation import gettext as _
|
||||
from django_filters.filters import (
|
||||
BooleanFilter,
|
||||
CharFilter,
|
||||
IsoDateTimeFilter,
|
||||
ModelMultipleChoiceFilter,
|
||||
MultipleChoiceFilter,
|
||||
UUIDFilter,
|
||||
@@ -241,6 +242,7 @@ class UserSerializer(ModelSerializer):
|
||||
"type",
|
||||
"uuid",
|
||||
"password_change_date",
|
||||
"last_updated",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"name": {"allow_blank": True},
|
||||
@@ -331,6 +333,14 @@ class UsersFilter(FilterSet):
|
||||
method="filter_attributes",
|
||||
)
|
||||
|
||||
date_joined__lt = IsoDateTimeFilter(field_name="date_joined", lookup_expr="lt")
|
||||
date_joined = IsoDateTimeFilter(field_name="date_joined")
|
||||
date_joined__gt = IsoDateTimeFilter(field_name="date_joined", lookup_expr="gt")
|
||||
|
||||
last_updated__lt = IsoDateTimeFilter(field_name="last_updated", lookup_expr="lt")
|
||||
last_updated = IsoDateTimeFilter(field_name="last_updated")
|
||||
last_updated__gt = IsoDateTimeFilter(field_name="last_updated", lookup_expr="gt")
|
||||
|
||||
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
|
||||
uuid = UUIDFilter(field_name="uuid")
|
||||
|
||||
@@ -376,6 +386,8 @@ class UsersFilter(FilterSet):
|
||||
fields = [
|
||||
"username",
|
||||
"email",
|
||||
"date_joined",
|
||||
"last_updated",
|
||||
"name",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
@@ -390,10 +402,19 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""User Viewset"""
|
||||
|
||||
queryset = User.objects.none()
|
||||
ordering = ["username"]
|
||||
ordering = ["username", "date_joined", "last_updated"]
|
||||
serializer_class = UserSerializer
|
||||
filterset_class = UsersFilter
|
||||
search_fields = ["username", "name", "is_active", "email", "uuid", "attributes"]
|
||||
search_fields = [
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"email",
|
||||
"uuid",
|
||||
"attributes",
|
||||
"date_joined",
|
||||
"last_updated",
|
||||
]
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import BoolField, StrField
|
||||
@@ -435,6 +456,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
user: User = self.get_object()
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
self.request._request.user = AnonymousUser()
|
||||
try:
|
||||
plan = planner.plan(
|
||||
self.request._request,
|
||||
|
||||
27
authentik/core/migrations/0050_user_last_updated_and_more.py
Normal file
27
authentik/core/migrations/0050_user_last_updated_and_more.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.11 on 2025-07-15 15:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
("authentik_core", "0049_alter_token_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="last_updated",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["last_updated"], name="authentik_c_last_up_ed7486_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="user",
|
||||
index=models.Index(fields=["date_joined"], name="authentik_c_date_jo_58c256_idx"),
|
||||
),
|
||||
]
|
||||
@@ -274,6 +274,8 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
||||
ak_groups = models.ManyToManyField("Group", related_name="users")
|
||||
password_change_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
last_updated = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
class Meta:
|
||||
@@ -293,6 +295,8 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
||||
models.Index(fields=["uuid"]),
|
||||
models.Index(fields=["path"]),
|
||||
models.Index(fields=["type"]),
|
||||
models.Index(fields=["date_joined"]),
|
||||
models.Index(fields=["last_updated"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -21,7 +21,7 @@ from authentik.core.tests.utils import (
|
||||
create_test_flow,
|
||||
create_test_user,
|
||||
)
|
||||
from authentik.flows.models import FlowDesignation
|
||||
from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.stages.email.models import EmailStage
|
||||
|
||||
@@ -103,8 +103,11 @@ class TestUsersAPI(APITestCase):
|
||||
self.assertTrue(self.admin.check_password(new_pw))
|
||||
|
||||
def test_recovery(self):
|
||||
"""Test user recovery link (no recovery flow set)"""
|
||||
flow = create_test_flow(FlowDesignation.RECOVERY)
|
||||
"""Test user recovery link"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.RECOVERY,
|
||||
authentication=FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
|
||||
)
|
||||
brand: Brand = create_test_brand()
|
||||
brand.flow_recovery = flow
|
||||
brand.save()
|
||||
@@ -387,3 +390,72 @@ class TestUsersAPI(APITestCase):
|
||||
self.assertFalse(
|
||||
AuthenticatedSession.objects.filter(session__session_key=session_id).exists()
|
||||
)
|
||||
|
||||
def test_sort_by_last_updated(self):
|
||||
"""Test API sorting by last_updated"""
|
||||
User.objects.all().delete()
|
||||
admin = create_test_admin_user()
|
||||
self.client.force_login(admin)
|
||||
|
||||
user = create_test_user()
|
||||
admin.first_name = "Sample change"
|
||||
admin.last_name = "To trigger an update"
|
||||
admin.save()
|
||||
|
||||
# Ascending
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"ordering": "last_updated",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["pk"], user.pk)
|
||||
|
||||
# Descending
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"ordering": "-last_updated",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["pk"], admin.pk)
|
||||
|
||||
def test_sort_by_date_joined(self):
|
||||
"""Test API sorting by date_joined"""
|
||||
User.objects.all().delete()
|
||||
admin = create_test_admin_user()
|
||||
self.client.force_login(admin)
|
||||
|
||||
user = create_test_user()
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"ordering": "date_joined",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["pk"], admin.pk)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"ordering": "-date_joined",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
self.assertEqual(body["results"][0]["pk"], user.pk)
|
||||
|
||||
@@ -55,6 +55,7 @@ class TestEnterpriseAudit(APITestCase):
|
||||
self.assertIsNotNone(event)
|
||||
self.assertIsNotNone(event.context["diff"])
|
||||
diff = event.context["diff"]
|
||||
diff.pop("last_updated")
|
||||
self.assertEqual(
|
||||
diff,
|
||||
{
|
||||
@@ -116,6 +117,7 @@ class TestEnterpriseAudit(APITestCase):
|
||||
self.assertIsNotNone(event)
|
||||
self.assertIsNotNone(event.context["diff"])
|
||||
diff = event.context["diff"]
|
||||
diff.pop("last_updated")
|
||||
self.assertEqual(
|
||||
diff,
|
||||
{
|
||||
|
||||
@@ -190,6 +190,7 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"sign_response",
|
||||
"sp_binding",
|
||||
"default_relay_state",
|
||||
"default_name_id_policy",
|
||||
"url_download_metadata",
|
||||
"url_sso_post",
|
||||
"url_sso_redirect",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-18 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_saml", "0018_alter_samlprovider_acs_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlprovider",
|
||||
name="default_name_id_policy",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "Persistent"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", "X509"),
|
||||
(
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"Windows",
|
||||
),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "Transient"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", "Unspecified"),
|
||||
],
|
||||
default="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -12,6 +12,7 @@ from authentik.core.models import PropertyMapping, Provider
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DSA_SHA1,
|
||||
ECDSA_SHA1,
|
||||
@@ -179,6 +180,9 @@ class SAMLProvider(Provider):
|
||||
default_relay_state = models.TextField(
|
||||
default="", blank=True, help_text=_("Default relay_state value for IDP-initiated logins")
|
||||
)
|
||||
default_name_id_policy = models.TextField(
|
||||
choices=SAMLNameIDPolicy.choices, default=SAMLNameIDPolicy.UNSPECIFIED
|
||||
)
|
||||
|
||||
sign_assertion = models.BooleanField(default=True)
|
||||
sign_response = models.BooleanField(default=False)
|
||||
|
||||
@@ -205,6 +205,13 @@ class AssertionProcessor:
|
||||
def get_name_id(self) -> Element:
|
||||
"""Get NameID Element"""
|
||||
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
# For requests that don't specify a NameIDPolicy, check if we
|
||||
# can fall back to the provider default
|
||||
if (
|
||||
self.auth_n_request.name_id_policy == SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
and self.provider.default_name_id_policy != SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
):
|
||||
self.auth_n_request.name_id_policy = self.provider.default_name_id_policy
|
||||
name_id.attrib["Format"] = self.auth_n_request.name_id_policy
|
||||
# persistent is used as a fallback, so always generate it
|
||||
persistent = self.http_request.user.uid
|
||||
|
||||
@@ -13,6 +13,7 @@ from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DSA_SHA1,
|
||||
NS_MAP,
|
||||
@@ -175,7 +176,9 @@ class AuthNRequestParser:
|
||||
|
||||
def idp_initiated(self) -> AuthNRequest:
|
||||
"""Create IdP Initiated AuthNRequest"""
|
||||
relay_state = None
|
||||
request = AuthNRequest(relay_state=None)
|
||||
if self.provider.default_relay_state != "":
|
||||
relay_state = self.provider.default_relay_state
|
||||
return AuthNRequest(relay_state=relay_state)
|
||||
request.relay_state = self.provider.default_relay_state
|
||||
if self.provider.default_name_id_policy != SAMLNameIDPolicy.UNSPECIFIED:
|
||||
request.name_id_policy = self.provider.default_name_id_policy
|
||||
return request
|
||||
|
||||
@@ -13,6 +13,7 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import PEM_FOOTER, PEM_HEADER
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_METADATA,
|
||||
@@ -46,6 +47,7 @@ class ServiceProviderMetadata:
|
||||
|
||||
auth_n_request_signed: bool
|
||||
assertion_signed: bool
|
||||
name_id_policy: SAMLNameIDPolicy
|
||||
|
||||
signing_keypair: CertificateKeyPair | None = None
|
||||
|
||||
@@ -60,6 +62,7 @@ class ServiceProviderMetadata:
|
||||
provider.issuer = self.entity_id
|
||||
provider.sp_binding = self.acs_binding
|
||||
provider.acs_url = self.acs_location
|
||||
provider.default_name_id_policy = self.name_id_policy
|
||||
if self.signing_keypair and self.auth_n_request_signed:
|
||||
self.signing_keypair.name = f"Provider {name} - SAML Signing Certificate"
|
||||
self.signing_keypair.save()
|
||||
@@ -148,6 +151,11 @@ class ServiceProviderMetadataParser:
|
||||
if signing_keypair:
|
||||
self.check_signature(root, signing_keypair)
|
||||
|
||||
name_id_format = descriptor.findall(f"{{{NS_SAML_METADATA}}}NameIDFormat")
|
||||
name_id_policy = SAMLNameIDPolicy.UNSPECIFIED
|
||||
if len(name_id_format) > 0:
|
||||
name_id_policy = SAMLNameIDPolicy(name_id_format[0].text)
|
||||
|
||||
return ServiceProviderMetadata(
|
||||
entity_id=entity_id,
|
||||
acs_binding=acs_binding,
|
||||
@@ -155,4 +163,5 @@ class ServiceProviderMetadataParser:
|
||||
auth_n_request_signed=auth_n_request_signed,
|
||||
assertion_signed=assertion_signed,
|
||||
signing_keypair=signing_keypair,
|
||||
name_id_policy=name_id_policy,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
cacheDuration="PT604800S"
|
||||
entityID="http://localhost:8080/saml/metadata">
|
||||
<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="http://localhost:8080/saml/acs"
|
||||
index="1" />
|
||||
|
||||
@@ -14,6 +14,7 @@ from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.providers.saml.processors.metadata_parser import ServiceProviderMetadataParser
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import ECDSA_SHA256, NS_MAP, NS_SAML_METADATA
|
||||
|
||||
|
||||
@@ -86,6 +87,7 @@ class TestServiceProviderMetadataParser(TestCase):
|
||||
self.assertEqual(provider.acs_url, "http://localhost:8080/saml/acs")
|
||||
self.assertEqual(provider.issuer, "http://localhost:8080/saml/metadata")
|
||||
self.assertEqual(provider.sp_binding, SAMLBindings.POST)
|
||||
self.assertEqual(provider.default_name_id_policy, SAMLNameIDPolicy.EMAIL)
|
||||
self.assertEqual(
|
||||
len(provider.property_mappings.all()),
|
||||
len(SAMLPropertyMapping.objects.exclude(managed__isnull=True)),
|
||||
|
||||
@@ -75,7 +75,9 @@ TENANT_APPS = [
|
||||
"pgtrigger",
|
||||
"authentik.admin",
|
||||
"authentik.api",
|
||||
"authentik.core",
|
||||
"authentik.crypto",
|
||||
"authentik.enterprise",
|
||||
"authentik.events",
|
||||
"authentik.flows",
|
||||
"authentik.outposts",
|
||||
@@ -171,6 +173,7 @@ SPECTACULAR_SETTINGS = {
|
||||
"PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
|
||||
"ProxyMode": "authentik.providers.proxy.models.ProxyMode",
|
||||
"TaskAggregatedStatusEnum": "authentik.tasks.models.TaskStatus",
|
||||
"SAMLNameIDPolicyEnum": "authentik.sources.saml.models.SAMLNameIDPolicy",
|
||||
"UserTypeEnum": "authentik.core.models.UserTypes",
|
||||
"UserVerificationEnum": "authentik.stages.authenticator_webauthn.models.UserVerification",
|
||||
},
|
||||
@@ -245,10 +248,12 @@ SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
|
||||
MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage"
|
||||
|
||||
MIDDLEWARE_FIRST = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
]
|
||||
MIDDLEWARE = [
|
||||
"django_tenants.middleware.default.DefaultTenantMiddleware",
|
||||
"authentik.root.middleware.LoggingMiddleware",
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
"authentik.root.middleware.ClientIPMiddleware",
|
||||
"authentik.stages.user_login.middleware.BoundSessionMiddleware",
|
||||
"authentik.core.middleware.AuthenticationMiddleware",
|
||||
@@ -261,6 +266,8 @@ MIDDLEWARE = [
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"authentik.core.middleware.ImpersonateMiddleware",
|
||||
]
|
||||
MIDDLEWARE_LAST = [
|
||||
"django_prometheus.middleware.PrometheusAfterMiddleware",
|
||||
]
|
||||
|
||||
@@ -496,7 +503,9 @@ _DISALLOWED_ITEMS = [
|
||||
"SHARED_APPS",
|
||||
"TENANT_APPS",
|
||||
"INSTALLED_APPS",
|
||||
"MIDDLEWARE_FIRST",
|
||||
"MIDDLEWARE",
|
||||
"MIDDLEWARE_LAST",
|
||||
"AUTHENTICATION_BACKENDS",
|
||||
"SPECTACULAR_SETTINGS",
|
||||
"REST_FRAMEWORK",
|
||||
@@ -514,16 +523,35 @@ SILENCED_SYSTEM_CHECKS = [
|
||||
]
|
||||
|
||||
|
||||
def _update_settings(app_path: str):
|
||||
def subtract_list(a: list, b: list) -> list:
|
||||
return [item for item in a if item not in b]
|
||||
|
||||
|
||||
def _filter_and_update(apps: list[str]) -> None:
|
||||
for _app in set(apps):
|
||||
if not _app.startswith("authentik"):
|
||||
continue
|
||||
_update_settings(f"{_app}.settings")
|
||||
|
||||
|
||||
def _update_settings(app_path: str) -> None:
|
||||
try:
|
||||
settings_module = importlib.import_module(app_path)
|
||||
CONFIG.log("debug", "Loaded app settings", path=app_path)
|
||||
SHARED_APPS.extend(getattr(settings_module, "SHARED_APPS", []))
|
||||
TENANT_APPS.extend(getattr(settings_module, "TENANT_APPS", []))
|
||||
|
||||
new_shared_apps = subtract_list(getattr(settings_module, "SHARED_APPS", []), SHARED_APPS)
|
||||
new_tenant_apps = subtract_list(getattr(settings_module, "TENANT_APPS", []), TENANT_APPS)
|
||||
SHARED_APPS.extend(new_shared_apps)
|
||||
TENANT_APPS.extend(new_tenant_apps)
|
||||
_filter_and_update(new_shared_apps + new_tenant_apps)
|
||||
|
||||
MIDDLEWARE_FIRST.extend(getattr(settings_module, "MIDDLEWARE_FIRST", []))
|
||||
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", {}))
|
||||
|
||||
for _attr in dir(settings_module):
|
||||
if not _attr.startswith("__") and _attr not in _DISALLOWED_ITEMS:
|
||||
globals()[_attr] = getattr(settings_module, _attr)
|
||||
@@ -538,26 +566,13 @@ if DEBUG:
|
||||
SHARED_APPS.insert(SHARED_APPS.index("django.contrib.staticfiles"), "daphne")
|
||||
enable_debug_trace(True)
|
||||
|
||||
TENANT_APPS.append("authentik.core")
|
||||
|
||||
CONFIG.log("info", "Booting authentik", version=__version__)
|
||||
|
||||
# Attempt to load enterprise app, if available
|
||||
try:
|
||||
importlib.import_module("authentik.enterprise.apps")
|
||||
CONFIG.log("info", "Enabled authentik enterprise")
|
||||
TENANT_APPS.append("authentik.enterprise")
|
||||
_update_settings("authentik.enterprise.settings")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
# Load subapps's settings
|
||||
for _app in set(SHARED_APPS + TENANT_APPS):
|
||||
if not _app.startswith("authentik"):
|
||||
continue
|
||||
_update_settings(f"{_app}.settings")
|
||||
_filter_and_update(SHARED_APPS + TENANT_APPS)
|
||||
_update_settings("data.user_settings")
|
||||
|
||||
MIDDLEWARE = list(OrderedDict.fromkeys(MIDDLEWARE_FIRST + MIDDLEWARE + MIDDLEWARE_LAST))
|
||||
SHARED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS))
|
||||
INSTALLED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 5.1.11 on 2025-06-18 09:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="samlsource",
|
||||
name="name_id_policy",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "Email"),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", "Persistent"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName", "X509"),
|
||||
(
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"Windows",
|
||||
),
|
||||
("urn:oasis:names:tc:SAML:2.0:nameid-format:transient", "Transient"),
|
||||
("urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", "Unspecified"),
|
||||
],
|
||||
default="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
||||
help_text="NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent.",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -39,6 +39,7 @@ from authentik.sources.saml.processors.constants import (
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_PERSISTENT,
|
||||
SAML_NAME_ID_FORMAT_TRANSIENT,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
SAML_NAME_ID_FORMAT_WINDOWS,
|
||||
SAML_NAME_ID_FORMAT_X509,
|
||||
SHA1,
|
||||
@@ -73,6 +74,7 @@ class SAMLNameIDPolicy(models.TextChoices):
|
||||
X509 = SAML_NAME_ID_FORMAT_X509
|
||||
WINDOWS = SAML_NAME_ID_FORMAT_WINDOWS
|
||||
TRANSIENT = SAML_NAME_ID_FORMAT_TRANSIENT
|
||||
UNSPECIFIED = SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
|
||||
|
||||
class SAMLSource(Source):
|
||||
|
||||
@@ -4689,6 +4689,14 @@
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": "Roles"
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"title": "Children"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -9287,6 +9295,18 @@
|
||||
"type": "string",
|
||||
"title": "Default relay state",
|
||||
"description": "Default relay_state value for IDP-initiated logins"
|
||||
},
|
||||
"default_name_id_policy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
|
||||
],
|
||||
"title": "Default name id policy"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -11714,7 +11734,8 @@
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName",
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient"
|
||||
"urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified"
|
||||
],
|
||||
"title": "Name id policy",
|
||||
"description": "NameID Policy sent to the IdP. Can be unset, in which case no Policy is sent."
|
||||
|
||||
2
go.mod
2
go.mod
@@ -29,7 +29,7 @@ require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2025064.2
|
||||
goauthentik.io/api/v3 v3.2025064.3
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.16.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -185,8 +185,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
goauthentik.io/api/v3 v3.2025064.2 h1:WFXe12hfsRe29EkLCxWCvrdK6peAkCA6ftdEh04hKLg=
|
||||
goauthentik.io/api/v3 v3.2025064.2/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
goauthentik.io/api/v3 v3.2025064.3 h1:REfDBEjswP2id2WRRDUajRxX+6u+XZ7e/smYq7jw5Z0=
|
||||
goauthentik.io/api/v3 v3.2025064.3/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
|
||||
@@ -17,6 +17,7 @@ type LDAPGroup struct {
|
||||
Uid string
|
||||
GidNumber string
|
||||
Member []string
|
||||
MemberOf []string
|
||||
IsSuperuser bool
|
||||
IsVirtualGroup bool
|
||||
Attributes map[string]interface{}
|
||||
@@ -38,6 +39,7 @@ func (lg *LDAPGroup) Entry() *ldap.Entry {
|
||||
"ak-superuser": {strconv.FormatBool(lg.IsSuperuser)},
|
||||
"objectClass": objectClass,
|
||||
"member": lg.Member,
|
||||
"memberOf": lg.MemberOf,
|
||||
"cn": {lg.CN},
|
||||
"uid": {lg.Uid},
|
||||
"sAMAccountName": {lg.CN},
|
||||
@@ -52,7 +54,8 @@ func FromAPIGroup(g api.Group, si server.LDAPServerInstance) *LDAPGroup {
|
||||
CN: g.Name,
|
||||
Uid: string(g.Pk),
|
||||
GidNumber: si.GetGroupGidNumber(g),
|
||||
Member: si.UsersForGroup(g),
|
||||
Member: si.MembersForGroup(g),
|
||||
MemberOf: si.MemberOfForGroup(g),
|
||||
IsVirtualGroup: false,
|
||||
IsSuperuser: *g.IsSuperuser,
|
||||
Attributes: g.Attributes,
|
||||
|
||||
@@ -155,7 +155,7 @@ func (ds *DirectSearcher) Search(req *search.Request) (ldap.ServerSearchResult,
|
||||
if needGroups {
|
||||
errs.Go(func() error {
|
||||
gapisp := sentry.StartSpan(errCtx, "authentik.providers.ldap.search.api_group")
|
||||
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()).IncludeUsers(true), parsedFilter, false)
|
||||
searchReq, skip := utils.ParseFilterForGroup(c.CoreApi.CoreGroupsList(gapisp.Context()).IncludeUsers(true).IncludeChildren(true), parsedFilter, false)
|
||||
if skip {
|
||||
req.Log().Trace("Skip backend request")
|
||||
return nil
|
||||
|
||||
@@ -165,7 +165,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
|
||||
for _, u := range g.UsersObj {
|
||||
if flag.UserPk == u.Pk {
|
||||
// TODO: Is there a better way to clone this object?
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.GroupMember{u}, []api.Role{})
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, g.ParentName, []api.GroupMember{u}, []api.Role{}, []api.GroupChild{})
|
||||
fg.SetUsers([]int32{flag.UserPk})
|
||||
if g.Parent.IsSet() {
|
||||
if p := g.Parent.Get(); p != nil {
|
||||
|
||||
@@ -32,7 +32,8 @@ type LDAPServerInstance interface {
|
||||
GetUserGidNumber(api.User) string
|
||||
GetGroupGidNumber(api.Group) string
|
||||
|
||||
UsersForGroup(api.Group) []string
|
||||
MembersForGroup(api.Group) []string
|
||||
MemberOfForGroup(api.Group) []string
|
||||
|
||||
GetFlags(dn string) *flags.UserFlags
|
||||
SetFlags(dn string, flags *flags.UserFlags)
|
||||
|
||||
@@ -15,12 +15,27 @@ func (pi *ProviderInstance) GroupsForUser(user api.User) []string {
|
||||
return groups
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) UsersForGroup(group api.Group) []string {
|
||||
func (pi *ProviderInstance) MembersForGroup(group api.Group) []string {
|
||||
users := make([]string, len(group.UsersObj))
|
||||
for i, user := range group.UsersObj {
|
||||
users[i] = pi.GetUserDN(user.Username)
|
||||
}
|
||||
return users
|
||||
children := make([]string, len(group.ChildrenObj))
|
||||
for i, child := range group.ChildrenObj {
|
||||
children[i] = pi.GetGroupDN(child.Name)
|
||||
}
|
||||
return append(users, children...)
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) MemberOfForGroup(group api.Group) []string {
|
||||
if group.ParentName.IsSet() {
|
||||
parent := group.ParentName.Get()
|
||||
if parent != nil {
|
||||
return []string{pi.GetGroupDN(*group.ParentName.Get())}
|
||||
}
|
||||
}
|
||||
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (pi *ProviderInstance) GetUserDN(user string) string {
|
||||
|
||||
@@ -37,11 +37,16 @@ ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
LABEL org.opencontainers.image.url=https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description="goauthentik.io LDAP outpost, see https://goauthentik.io for more info."
|
||||
LABEL org.opencontainers.image.source=https://github.com/goauthentik/authentik
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
LABEL org.opencontainers.image.revision=${GIT_BUILD_HASH}
|
||||
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
|
||||
org.opencontainers.image.description="goauthentik.io LDAP outpost, see https://goauthentik.io for more info." \
|
||||
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
|
||||
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \
|
||||
org.opencontainers.image.revision=${GIT_BUILD_HASH} \
|
||||
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
|
||||
org.opencontainers.image.title="authentik LDAP outpost image" \
|
||||
org.opencontainers.image.url="https://goauthentik.io" \
|
||||
org.opencontainers.image.vendor="Authentik Security Inc." \
|
||||
org.opencontainers.image.version=${VERSION}
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
|
||||
@@ -186,19 +186,19 @@ class MetricsMiddleware(Middleware):
|
||||
"The total number of dead-lettered tasks.",
|
||||
self.labels,
|
||||
)
|
||||
self.inprogress_messages = Gauge(
|
||||
f"{self.prefix}_tasks_inprogress",
|
||||
self.in_progress_messages = Gauge(
|
||||
f"{self.prefix}_tasks_in_progress",
|
||||
"The number of tasks in progress.",
|
||||
self.labels,
|
||||
multiprocess_mode="livesum",
|
||||
)
|
||||
self.inprogress_delayed_messages = Gauge(
|
||||
f"{self.prefix}_tasks_delayed_inprogress",
|
||||
self.in_progress_delayed_messages = Gauge(
|
||||
f"{self.prefix}_tasks_delayed_in_progress",
|
||||
"The number of delayed tasks in memory.",
|
||||
self.labels,
|
||||
)
|
||||
self.messages_durations = Histogram(
|
||||
f"{self.prefix}_tasks_duration_miliseconds",
|
||||
f"{self.prefix}_tasks_duration_milliseconds",
|
||||
"The time spent processing tasks.",
|
||||
self.labels,
|
||||
buckets=(
|
||||
@@ -244,15 +244,15 @@ class MetricsMiddleware(Middleware):
|
||||
|
||||
def before_delay_message(self, broker: Broker, message: Message):
|
||||
self.delayed_messages.add(message.message_id)
|
||||
self.inprogress_delayed_messages.labels(*self._make_labels(message)).inc()
|
||||
self.in_progress_delayed_messages.labels(*self._make_labels(message)).inc()
|
||||
|
||||
def before_process_message(self, broker: Broker, message: Message):
|
||||
labels = self._make_labels(message)
|
||||
if message.message_id in self.delayed_messages:
|
||||
self.delayed_messages.remove(message.message_id)
|
||||
self.inprogress_delayed_messages.labels(*labels).dec()
|
||||
self.in_progress_delayed_messages.labels(*labels).dec()
|
||||
|
||||
self.inprogress_messages.labels(*labels).inc()
|
||||
self.in_progress_messages.labels(*labels).inc()
|
||||
self.message_start_times[message.message_id] = current_millis()
|
||||
|
||||
def after_process_message(
|
||||
@@ -269,7 +269,7 @@ class MetricsMiddleware(Middleware):
|
||||
message_duration = current_millis() - message_start_time
|
||||
self.messages_durations.labels(*labels).observe(message_duration)
|
||||
|
||||
self.inprogress_messages.labels(*labels).dec()
|
||||
self.in_progress_messages.labels(*labels).dec()
|
||||
self.total_messages.labels(*labels).inc()
|
||||
if exception is not None:
|
||||
self.total_errored_messages.labels(*labels).inc()
|
||||
|
||||
@@ -53,11 +53,16 @@ ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
LABEL org.opencontainers.image.url=https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description="goauthentik.io Proxy outpost image, see https://goauthentik.io for more info."
|
||||
LABEL org.opencontainers.image.source=https://github.com/goauthentik/authentik
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
LABEL org.opencontainers.image.revision=${GIT_BUILD_HASH}
|
||||
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
|
||||
org.opencontainers.image.description="goauthentik.io Proxy outpost image, see https://goauthentik.io for more info." \
|
||||
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
|
||||
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \
|
||||
org.opencontainers.image.revision=${GIT_BUILD_HASH} \
|
||||
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
|
||||
org.opencontainers.image.title="authentik proxy outpost image" \
|
||||
org.opencontainers.image.url="https://goauthentik.io" \
|
||||
org.opencontainers.image.vendor="Authentik Security Inc." \
|
||||
org.opencontainers.image.version=${VERSION}
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
|
||||
@@ -37,11 +37,16 @@ ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
LABEL org.opencontainers.image.url=https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description="goauthentik.io RAC outpost, see https://goauthentik.io for more info."
|
||||
LABEL org.opencontainers.image.source=https://github.com/goauthentik/authentik
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
LABEL org.opencontainers.image.revision=${GIT_BUILD_HASH}
|
||||
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
|
||||
org.opencontainers.image.description="goauthentik.io RAC outpost, see https://goauthentik.io for more info." \
|
||||
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
|
||||
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \
|
||||
org.opencontainers.image.revision=${GIT_BUILD_HASH} \
|
||||
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
|
||||
org.opencontainers.image.title="authentik RAC outpost image" \
|
||||
org.opencontainers.image.url="https://goauthentik.io" \
|
||||
org.opencontainers.image.vendor="Authentik Security Inc." \
|
||||
org.opencontainers.image.version=${VERSION}
|
||||
|
||||
USER root
|
||||
RUN apt-get update && \
|
||||
|
||||
@@ -37,11 +37,16 @@ ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
LABEL org.opencontainers.image.url=https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description="goauthentik.io Radius outpost, see https://goauthentik.io for more info."
|
||||
LABEL org.opencontainers.image.source=https://github.com/goauthentik/authentik
|
||||
LABEL org.opencontainers.image.version=${VERSION}
|
||||
LABEL org.opencontainers.image.revision=${GIT_BUILD_HASH}
|
||||
LABEL org.opencontainers.image.authors="Authentik Security Inc." \
|
||||
org.opencontainers.image.description="goauthentik.io Radius outpost, see https://goauthentik.io for more info." \
|
||||
org.opencontainers.image.documentation="https://docs.goauthentik.io" \
|
||||
org.opencontainers.image.licenses="https://github.com/goauthentik/authentik/blob/main/LICENSE" \
|
||||
org.opencontainers.image.revision=${GIT_BUILD_HASH} \
|
||||
org.opencontainers.image.source="https://github.com/goauthentik/authentik" \
|
||||
org.opencontainers.image.title="authentik RADIUS outpost image" \
|
||||
org.opencontainers.image.url="https://goauthentik.io" \
|
||||
org.opencontainers.image.vendor="Authentik Security Inc." \
|
||||
org.opencontainers.image.version=${VERSION}
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
|
||||
133
schema.yml
133
schema.yml
@@ -4718,6 +4718,11 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
description: Attributes
|
||||
- in: query
|
||||
name: include_children
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- in: query
|
||||
name: include_users
|
||||
schema:
|
||||
@@ -4840,6 +4845,11 @@ paths:
|
||||
format: uuid
|
||||
description: A UUID string identifying this Group.
|
||||
required: true
|
||||
- in: query
|
||||
name: include_children
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- in: query
|
||||
name: include_users
|
||||
schema:
|
||||
@@ -5654,6 +5664,21 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
description: Attributes
|
||||
- in: query
|
||||
name: date_joined
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: date_joined__gt
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: date_joined__lt
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: email
|
||||
schema:
|
||||
@@ -5688,6 +5713,21 @@ paths:
|
||||
name: is_superuser
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: last_updated
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: last_updated__gt
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: last_updated__lt
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
@@ -22282,6 +22322,17 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: default_name_id_policy
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:transient
|
||||
- in: query
|
||||
name: default_relay_state
|
||||
schema:
|
||||
@@ -29498,6 +29549,7 @@ paths:
|
||||
enum:
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:transient
|
||||
@@ -46466,13 +46518,50 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/Role'
|
||||
readOnly: true
|
||||
children:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
children_obj:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GroupChild'
|
||||
readOnly: true
|
||||
nullable: true
|
||||
required:
|
||||
- children_obj
|
||||
- name
|
||||
- num_pk
|
||||
- parent_name
|
||||
- pk
|
||||
- roles_obj
|
||||
- users_obj
|
||||
GroupChild:
|
||||
type: object
|
||||
description: Stripped down group serializer to show relevant children for groups
|
||||
properties:
|
||||
pk:
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
title: Group uuid
|
||||
name:
|
||||
type: string
|
||||
is_superuser:
|
||||
type: boolean
|
||||
description: Users added to this group will be superusers.
|
||||
attributes:
|
||||
type: object
|
||||
additionalProperties: {}
|
||||
group_uuid:
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
required:
|
||||
- group_uuid
|
||||
- name
|
||||
- pk
|
||||
GroupKerberosSourceConnection:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
@@ -46794,6 +46883,11 @@ components:
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
children:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
required:
|
||||
- name
|
||||
GroupSAMLSourceConnection:
|
||||
@@ -49063,14 +49157,6 @@ components:
|
||||
- mode
|
||||
- name
|
||||
- user_attribute
|
||||
NameIdPolicyEnum:
|
||||
enum:
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:transient
|
||||
type: string
|
||||
NetworkBindingEnum:
|
||||
enum:
|
||||
- no_binding
|
||||
@@ -53685,6 +53771,11 @@ components:
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
children:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: uuid
|
||||
PatchedGroupSAMLSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
@@ -55289,6 +55380,8 @@ components:
|
||||
default_relay_state:
|
||||
type: string
|
||||
description: Default relay_state value for IDP-initiated logins
|
||||
default_name_id_policy:
|
||||
$ref: '#/components/schemas/SAMLNameIDPolicyEnum'
|
||||
PatchedSAMLSourcePropertyMappingRequest:
|
||||
type: object
|
||||
description: SAMLSourcePropertyMapping Serializer
|
||||
@@ -55382,7 +55475,7 @@ components:
|
||||
be a security risk, as no validation of the request ID is done.
|
||||
name_id_policy:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/NameIdPolicyEnum'
|
||||
- $ref: '#/components/schemas/SAMLNameIDPolicyEnum'
|
||||
description: NameID Policy sent to the IdP. Can be unset, in which case
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
@@ -58130,6 +58223,15 @@ components:
|
||||
required:
|
||||
- download_url
|
||||
- metadata
|
||||
SAMLNameIDPolicyEnum:
|
||||
enum:
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:WindowsDomainQualifiedName
|
||||
- urn:oasis:names:tc:SAML:2.0:nameid-format:transient
|
||||
- urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified
|
||||
type: string
|
||||
SAMLPropertyMapping:
|
||||
type: object
|
||||
description: SAMLPropertyMapping Serializer
|
||||
@@ -58347,6 +58449,8 @@ components:
|
||||
default_relay_state:
|
||||
type: string
|
||||
description: Default relay_state value for IDP-initiated logins
|
||||
default_name_id_policy:
|
||||
$ref: '#/components/schemas/SAMLNameIDPolicyEnum'
|
||||
url_download_metadata:
|
||||
type: string
|
||||
description: Get metadata download URL
|
||||
@@ -58519,6 +58623,8 @@ components:
|
||||
default_relay_state:
|
||||
type: string
|
||||
description: Default relay_state value for IDP-initiated logins
|
||||
default_name_id_policy:
|
||||
$ref: '#/components/schemas/SAMLNameIDPolicyEnum'
|
||||
required:
|
||||
- acs_url
|
||||
- authorization_flow
|
||||
@@ -58627,7 +58733,7 @@ components:
|
||||
be a security risk, as no validation of the request ID is done.
|
||||
name_id_policy:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/NameIdPolicyEnum'
|
||||
- $ref: '#/components/schemas/SAMLNameIDPolicyEnum'
|
||||
description: NameID Policy sent to the IdP. Can be unset, in which case
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
@@ -58817,7 +58923,7 @@ components:
|
||||
be a security risk, as no validation of the request ID is done.
|
||||
name_id_policy:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/NameIdPolicyEnum'
|
||||
- $ref: '#/components/schemas/SAMLNameIDPolicyEnum'
|
||||
description: NameID Policy sent to the IdP. Can be unset, in which case
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
@@ -61075,11 +61181,16 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
last_updated:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- avatar
|
||||
- date_joined
|
||||
- groups_obj
|
||||
- is_superuser
|
||||
- last_updated
|
||||
- name
|
||||
- password_change_date
|
||||
- pk
|
||||
|
||||
2
web/.gitignore
vendored
2
web/.gitignore
vendored
@@ -25,8 +25,6 @@ lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
@@ -15,6 +15,7 @@ export function addCommands(browser) {
|
||||
/**
|
||||
* @this {HTMLElement}
|
||||
*/
|
||||
// @ts-ignore
|
||||
function () {
|
||||
this.focus();
|
||||
|
||||
@@ -28,6 +29,7 @@ export function addCommands(browser) {
|
||||
/**
|
||||
* @this {HTMLElement}
|
||||
*/
|
||||
// @ts-ignore
|
||||
function () {
|
||||
this.blur();
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
import { ConsoleLogger } from "#logger/node";
|
||||
|
||||
import { expect, Locator } from "@playwright/test";
|
||||
import { kebabCase } from "change-case";
|
||||
|
||||
export type LocatorMatchers = ReturnType<typeof expect<Locator>>;
|
||||
|
||||
export interface LocatorProxy extends Pick<Locator, keyof Locator> {
|
||||
$: Locator;
|
||||
expect: LocatorMatchers;
|
||||
}
|
||||
|
||||
// Type helpers to extract the shape of the proxy
|
||||
export type DeepLocatorProxy<T> =
|
||||
Disposable & T extends Record<string, any>
|
||||
? T extends HTMLElement
|
||||
? LocatorProxy
|
||||
: {
|
||||
[K in keyof T]: DeepLocatorProxy<T[K]>;
|
||||
}
|
||||
: LocatorProxy;
|
||||
|
||||
export function createLocatorProxy<T extends Record<string, any>>(
|
||||
ctx: LocatorContext,
|
||||
initialPathPrefix: string[] = [],
|
||||
dataAttribute: string = "test-id",
|
||||
): DeepLocatorProxy<T> {
|
||||
dataAttribute = kebabCase(dataAttribute);
|
||||
|
||||
function createProxy(path: string[] = initialPathPrefix): any {
|
||||
const proxyCache = new Map<string, LocatorProxy>();
|
||||
|
||||
return new Proxy({} as any, {
|
||||
get(_, property: string) {
|
||||
// Build the current path
|
||||
const currentPath = [...path, property];
|
||||
|
||||
// Convert the path to kebab-case and join with hyphens
|
||||
const selectorValue = currentPath.map((segment) => kebabCase(segment)).join("-");
|
||||
const selector = `[data-${dataAttribute}="${selectorValue}"]`;
|
||||
|
||||
// Create a locator for the current selector
|
||||
const locator = ctx.locator(selector);
|
||||
|
||||
if (proxyCache.has(selector)) {
|
||||
ConsoleLogger.debug(`Using cached locator for ${selector}`);
|
||||
return proxyCache.get(selector)!;
|
||||
}
|
||||
|
||||
// Return a new proxy that also behaves like a Locator
|
||||
// This allows us to either continue chaining or use Locator methods
|
||||
const nextProxy = new Proxy(locator, {
|
||||
get(target, prop) {
|
||||
if (typeof prop === "string") {
|
||||
// The user is likely trying to access a property on the page.
|
||||
if (prop === "$") {
|
||||
return target as any;
|
||||
}
|
||||
|
||||
if (prop === "expect") {
|
||||
return expect(target);
|
||||
}
|
||||
}
|
||||
|
||||
// If the property exists on the Locator, use it
|
||||
if (prop in target) {
|
||||
const value = (target as any)[prop];
|
||||
// Bind methods to the locator instance
|
||||
if (typeof value === "function") {
|
||||
return value.bind(target);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
// Otherwise, continue building the path
|
||||
|
||||
return createProxy(currentPath)[prop];
|
||||
},
|
||||
});
|
||||
|
||||
proxyCache.set(selector, nextProxy as LocatorProxy);
|
||||
|
||||
return nextProxy;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createProxy() as DeepLocatorProxy<T>;
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
|
||||
import { expect, Page } from "@playwright/test";
|
||||
|
||||
export class FormFixture extends PageFixture {
|
||||
static fixtureName = "Form";
|
||||
|
||||
//#region Selector Methods
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Field Methods
|
||||
|
||||
/**
|
||||
* Set the value of a text input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
public fill = async (
|
||||
fieldName: string,
|
||||
value: string,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent
|
||||
.getByRole("textbox", {
|
||||
name: fieldName,
|
||||
})
|
||||
.or(
|
||||
parent.getByRole("spinbutton", {
|
||||
name: fieldName,
|
||||
}),
|
||||
)
|
||||
.first();
|
||||
|
||||
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
|
||||
await control.fill(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a radio or checkbox input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
public setInputCheck = async (
|
||||
fieldName: string,
|
||||
value: boolean = true,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent.locator("ak-switch-input", {
|
||||
hasText: fieldName,
|
||||
});
|
||||
|
||||
await control.scrollIntoViewIfNeeded();
|
||||
|
||||
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
|
||||
const currentChecked = await control
|
||||
.getAttribute("checked")
|
||||
.then((value) => value !== null);
|
||||
|
||||
if (currentChecked === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await control.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a radio or checkbox input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param pattern the value to set.
|
||||
*/
|
||||
public setRadio = async (
|
||||
groupName: string,
|
||||
fieldName: string,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const group = parent.getByRole("group", { name: groupName });
|
||||
|
||||
await expect(group, `Field "${groupName}" should be visible`).toBeVisible();
|
||||
const control = parent.getByRole("radio", { name: fieldName });
|
||||
|
||||
await control.setChecked(true, {
|
||||
force: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a search select input.
|
||||
*
|
||||
* @param fieldLabel The name of the search select element.
|
||||
* @param pattern The text to match against the search select entry.
|
||||
*/
|
||||
public selectSearchValue = async (
|
||||
fieldLabel: string,
|
||||
pattern: string | RegExp,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent.getByRole("textbox", { name: fieldLabel });
|
||||
|
||||
await expect(
|
||||
control,
|
||||
`Search select control (${fieldLabel}) should be visible`,
|
||||
).toBeVisible();
|
||||
|
||||
const fieldName = await control.getAttribute("name");
|
||||
|
||||
if (!fieldName) {
|
||||
throw new Error(`Unable to find name attribute on search select (${fieldLabel})`);
|
||||
}
|
||||
|
||||
// Find the search select input control and activate it.
|
||||
await control.click();
|
||||
|
||||
const button = this.page
|
||||
// ---
|
||||
.locator(`div[data-managed-for*="${fieldName}"] button`, {
|
||||
hasText: pattern,
|
||||
});
|
||||
|
||||
if (!button) {
|
||||
throw new Error(
|
||||
`Unable to find an ak-search-select entry matching ${fieldLabel}:${pattern.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
await button.click();
|
||||
await this.page.keyboard.press("Tab");
|
||||
await control.blur();
|
||||
};
|
||||
|
||||
public setFormGroup = async (
|
||||
pattern: string | RegExp,
|
||||
value: boolean = true,
|
||||
parent: LocatorContext = this.page,
|
||||
) => {
|
||||
const control = parent
|
||||
.locator("ak-form-group", {
|
||||
hasText: pattern,
|
||||
})
|
||||
.first();
|
||||
|
||||
const currentOpen = await control.getAttribute("open").then((value) => value !== null);
|
||||
|
||||
if (currentOpen === value) {
|
||||
this.logger.debug(`Form group ${pattern} is already ${value ? "open" : "closed"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Toggling form group ${pattern} to ${value ? "open" : "closed"}`);
|
||||
|
||||
await control.click();
|
||||
|
||||
if (value) {
|
||||
await expect(control).toHaveAttribute("open");
|
||||
} else {
|
||||
await expect(control).not.toHaveAttribute("open");
|
||||
}
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
constructor(page: Page, testName: string) {
|
||||
super({ page, testName });
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ConsoleLogger, FixtureLogger } from "#logger/node";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export interface PageFixtureOptions {
|
||||
page: Page;
|
||||
testName: string;
|
||||
}
|
||||
|
||||
export abstract class PageFixture {
|
||||
/**
|
||||
* The name of the fixture.
|
||||
*
|
||||
* Used for logging.
|
||||
*/
|
||||
static fixtureName: string;
|
||||
|
||||
protected readonly logger: FixtureLogger;
|
||||
protected readonly page: Page;
|
||||
protected readonly testName: string;
|
||||
|
||||
constructor({ page, testName }: PageFixtureOptions) {
|
||||
this.page = page;
|
||||
this.testName = testName;
|
||||
|
||||
const Constructor = this.constructor as typeof PageFixture;
|
||||
|
||||
this.logger = ConsoleLogger.fixture(Constructor.fixtureName, this.testName);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export type GetByRoleParameters = Parameters<Page["getByRole"]>;
|
||||
export type ARIARole = GetByRoleParameters[0];
|
||||
export type ARIAOptions = GetByRoleParameters[1];
|
||||
|
||||
export type ClickByName = (name: string) => Promise<void>;
|
||||
export type ClickByRole = (
|
||||
role: ARIARole,
|
||||
options?: ARIAOptions,
|
||||
context?: LocatorContext,
|
||||
) => Promise<void>;
|
||||
|
||||
export class PointerFixture extends PageFixture {
|
||||
public static fixtureName = "Pointer";
|
||||
|
||||
public click = (
|
||||
name: string,
|
||||
optionsOrRole?: ARIAOptions | ARIARole,
|
||||
context: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
if (typeof optionsOrRole === "string") {
|
||||
return context.getByRole(optionsOrRole, { name }).click();
|
||||
}
|
||||
|
||||
const options = {
|
||||
...optionsOrRole,
|
||||
name,
|
||||
};
|
||||
|
||||
return (
|
||||
context
|
||||
// ---
|
||||
.getByRole("button", options)
|
||||
.or(context.getByRole("link", options))
|
||||
.click()
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export const GOOD_USERNAME = "test-admin@goauthentik.io";
|
||||
export const GOOD_PASSWORD = "test-runner";
|
||||
|
||||
export const BAD_USERNAME = "bad-username@bad-login.io";
|
||||
export const BAD_PASSWORD = "-this-is-a-bad-password-";
|
||||
|
||||
export interface LoginInit {
|
||||
username?: string;
|
||||
password?: string;
|
||||
to?: URL | string;
|
||||
}
|
||||
|
||||
export class SessionFixture extends PageFixture {
|
||||
static fixtureName = "Session";
|
||||
|
||||
public static readonly pathname = "/if/flow/default-authentication-flow/";
|
||||
|
||||
//#region Selectors
|
||||
|
||||
public $identificationStage = this.page.locator("ak-stage-identification");
|
||||
|
||||
/**
|
||||
* The username field on the login page.
|
||||
*/
|
||||
public $usernameField = this.page.getByLabel("Username");
|
||||
|
||||
public $passwordStage = this.page.locator("ak-stage-password");
|
||||
public $passwordField = this.page.getByLabel("Password");
|
||||
|
||||
/**
|
||||
* The button to submit the the login flow,
|
||||
* typically redirecting to the authenticated interface.
|
||||
*/
|
||||
public $submitButton = this.page.locator('button[type="submit"]');
|
||||
|
||||
/**
|
||||
* A possible authentication failure message.
|
||||
*/
|
||||
public $authFailureMessage = this.page.locator(".pf-m-error");
|
||||
|
||||
//#endregion
|
||||
|
||||
constructor(page: Page, testName: string) {
|
||||
super({ page, testName });
|
||||
}
|
||||
|
||||
//#region Specific interactions
|
||||
|
||||
public checkAuthenticated = async (): Promise<boolean> => {
|
||||
// TODO: Check if the user is authenticated via API
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Log into the application.
|
||||
*/
|
||||
public async login({
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
}: LoginInit = {}) {
|
||||
this.logger.info("Logging in...");
|
||||
|
||||
const initialURL = new URL(this.page.url());
|
||||
|
||||
if (initialURL.pathname === SessionFixture.pathname) {
|
||||
this.logger.info("Skipping navigation because we're already in a authentication flow");
|
||||
} else {
|
||||
await this.page.goto(to.toString());
|
||||
}
|
||||
|
||||
await this.$usernameField.fill(username);
|
||||
|
||||
const passwordFieldVisible = await this.$passwordField.isVisible();
|
||||
|
||||
if (!passwordFieldVisible) {
|
||||
await this.$submitButton.click();
|
||||
|
||||
await this.$passwordField.waitFor({ state: "visible" });
|
||||
}
|
||||
|
||||
await this.$passwordField.fill(password);
|
||||
|
||||
await this.$submitButton.click();
|
||||
|
||||
const expectedPathname = typeof to === "string" ? to : to.pathname;
|
||||
|
||||
await this.page.waitForURL(`**${expectedPathname}`);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Navigation
|
||||
|
||||
public async toLoginPage() {
|
||||
await this.page.goto(SessionFixture.pathname);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { createLocatorProxy, DeepLocatorProxy } from "#e2e/elements/proxy";
|
||||
import { FormFixture } from "#e2e/fixtures/FormFixture";
|
||||
import { PointerFixture } from "#e2e/fixtures/PointerFixture";
|
||||
import { SessionFixture } from "#e2e/fixtures/SessionFixture";
|
||||
import { createOUIDNameEngine } from "#e2e/selectors/ouid";
|
||||
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
|
||||
type TestIDLocatorProxy = DeepLocatorProxy<TestIDSelectorMap>;
|
||||
|
||||
interface E2EFixturesTestScope {
|
||||
/**
|
||||
* A proxy to retrieve elements by test ID.
|
||||
*
|
||||
* ```ts
|
||||
* const $button = $.button;
|
||||
* ```
|
||||
*/
|
||||
$: TestIDLocatorProxy;
|
||||
session: SessionFixture;
|
||||
pointer: PointerFixture;
|
||||
form: FormFixture;
|
||||
}
|
||||
|
||||
interface E2EWorkerScope {
|
||||
selectorRegistration: void;
|
||||
}
|
||||
|
||||
export const test = base.extend<E2EFixturesTestScope, E2EWorkerScope>({
|
||||
selectorRegistration: [
|
||||
async ({ playwright }, use) => {
|
||||
await playwright.selectors.register("ouid", createOUIDNameEngine);
|
||||
await use();
|
||||
},
|
||||
{ auto: true, scope: "worker" },
|
||||
],
|
||||
|
||||
$: async ({ page }, use) => {
|
||||
await use(createLocatorProxy<TestIDSelectorMap>(page));
|
||||
},
|
||||
|
||||
session: async ({ page }, use, { title }) => {
|
||||
await use(new SessionFixture(page, title));
|
||||
},
|
||||
|
||||
form: async ({ page }, use, { title }) => {
|
||||
await use(new FormFixture(page, title));
|
||||
},
|
||||
|
||||
pointer: async ({ page }, use, { title }) => {
|
||||
await use(new PointerFixture({ page, testName: title }));
|
||||
},
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
type SelectorRoot = Document | ShadowRoot;
|
||||
|
||||
export function createOUIDNameEngine() {
|
||||
const attributeName = "data-ouid-component-name";
|
||||
|
||||
console.log("Creating OUID selector engine!!");
|
||||
return {
|
||||
// Returns all elements matching given selector in the root's subtree.
|
||||
queryAll(scope: SelectorRoot, componentName: string) {
|
||||
const result: Element[] = [];
|
||||
|
||||
const match = (element: Element) => {
|
||||
const name = element.getAttribute(attributeName);
|
||||
|
||||
if (name === componentName) {
|
||||
result.push(element);
|
||||
}
|
||||
};
|
||||
|
||||
const query = (root: Element | ShadowRoot | Document) => {
|
||||
const shadows: ShadowRoot[] = [];
|
||||
|
||||
if ((root as Element).shadowRoot) {
|
||||
shadows.push((root as Element).shadowRoot!);
|
||||
}
|
||||
|
||||
for (const element of root.querySelectorAll("*")) {
|
||||
match(element);
|
||||
|
||||
if (element.shadowRoot) {
|
||||
shadows.push(element.shadowRoot);
|
||||
}
|
||||
}
|
||||
|
||||
shadows.forEach(query);
|
||||
};
|
||||
|
||||
query(scope);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
|
||||
export type LocatorContext = Pick<
|
||||
Locator,
|
||||
| "locator"
|
||||
| "getByRole"
|
||||
| "getByTestId"
|
||||
| "getByText"
|
||||
| "getByLabel"
|
||||
| "getByAltText"
|
||||
| "getByTitle"
|
||||
| "getByPlaceholder"
|
||||
>;
|
||||
@@ -1,60 +0,0 @@
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
|
||||
import {
|
||||
adjectives,
|
||||
colors,
|
||||
Config as NameConfig,
|
||||
uniqueNamesGenerator,
|
||||
} from "unique-names-generator";
|
||||
|
||||
/**
|
||||
* Given a dictionary of words, slice the dictionary to only include words that start with the given letter.
|
||||
*/
|
||||
export function alliterate(dictionary: string[], letter: string): string[] {
|
||||
let firstIndex = 0;
|
||||
|
||||
for (let i = 0; i < dictionary.length; i++) {
|
||||
if (dictionary[i][0] === letter) {
|
||||
firstIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let lastIndex = firstIndex;
|
||||
|
||||
for (let i = firstIndex; i < dictionary.length; i++) {
|
||||
if (dictionary[i][0] !== letter) {
|
||||
lastIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary.slice(firstIndex, lastIndex);
|
||||
}
|
||||
|
||||
export function createRandomName({
|
||||
seed = IDGenerator.randomID(),
|
||||
...config
|
||||
}: Partial<NameConfig> = {}) {
|
||||
const randomLetterIndex =
|
||||
typeof seed === "number"
|
||||
? seed
|
||||
: Array.from(seed).reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
|
||||
const letter = adjectives[randomLetterIndex % adjectives.length][0];
|
||||
|
||||
const availableAdjectives = alliterate(adjectives, letter);
|
||||
|
||||
const availableColors = alliterate(colors, letter);
|
||||
|
||||
const name = uniqueNamesGenerator({
|
||||
dictionaries: [availableAdjectives, availableAdjectives, availableColors],
|
||||
style: "capital",
|
||||
separator: " ",
|
||||
length: 3,
|
||||
seed,
|
||||
...config,
|
||||
});
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Application logger.
|
||||
*
|
||||
* @import { LoggerOptions, Logger, Level, ChildLoggerOptions } from "pino"
|
||||
* @import { PrettyOptions } from "pino-pretty"
|
||||
*/
|
||||
|
||||
import { pino } from "pino";
|
||||
|
||||
//#region Constants
|
||||
|
||||
/**
|
||||
* Default options for creating a Pino logger.
|
||||
*
|
||||
* @category Logger
|
||||
* @satisfies {LoggerOptions<never, false>}
|
||||
*/
|
||||
export const DEFAULT_PINO_LOGGER_OPTIONS = {
|
||||
enabled: true,
|
||||
level: "info",
|
||||
transport: {
|
||||
target: "./transport.js",
|
||||
options: /** @satisfies {PrettyOptions} */ ({
|
||||
colorize: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Functions
|
||||
|
||||
/**
|
||||
* Read the log level from the environment.
|
||||
* @return {Level}
|
||||
*/
|
||||
export function readLogLevel() {
|
||||
return process.env.AK_LOG_LEVEL || DEFAULT_PINO_LOGGER_OPTIONS.level;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Logger} FixtureLogger
|
||||
*/
|
||||
|
||||
/**
|
||||
* @this {Logger}
|
||||
* @param {string} fixtureName
|
||||
* @param {string} [testName]
|
||||
* @param {ChildLoggerOptions} [options]
|
||||
* @returns {FixtureLogger}
|
||||
*/
|
||||
function createFixtureLogger(fixtureName, testName, options) {
|
||||
return this.child(
|
||||
{ name: fixtureName },
|
||||
{
|
||||
msgPrefix: `[${testName}] `,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomLoggerMethods
|
||||
* @property {typeof createFixtureLogger} fixture
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Logger & CustomLoggerMethods} ConsoleLogger
|
||||
*/
|
||||
|
||||
/**
|
||||
* A singleton logger instance for Node.js.
|
||||
*
|
||||
* ```js
|
||||
* import { ConsoleLogger } from "#logger/node";
|
||||
*
|
||||
* ConsoleLogger.info("Hello, world!");
|
||||
* ```
|
||||
*
|
||||
* @runtime node
|
||||
* @type {ConsoleLogger}
|
||||
*/
|
||||
export const ConsoleLogger = Object.assign(
|
||||
pino({
|
||||
...DEFAULT_PINO_LOGGER_OPTIONS,
|
||||
level: readLogLevel(),
|
||||
}),
|
||||
{ fixture: createFixtureLogger },
|
||||
);
|
||||
|
||||
/**
|
||||
* @typedef {ReturnType<ConsoleLogger['child']>} ChildConsoleLogger
|
||||
*/
|
||||
|
||||
//#region Aliases
|
||||
|
||||
export const info = ConsoleLogger.info.bind(ConsoleLogger);
|
||||
export const debug = ConsoleLogger.debug.bind(ConsoleLogger);
|
||||
export const warn = ConsoleLogger.warn.bind(ConsoleLogger);
|
||||
export const error = ConsoleLogger.error.bind(ConsoleLogger);
|
||||
|
||||
//#endregion
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* @file Pretty transport for Pino
|
||||
*
|
||||
* @import { PrettyOptions } from "pino-pretty"
|
||||
*/
|
||||
|
||||
import PinoPretty from "pino-pretty";
|
||||
|
||||
/**
|
||||
* @param {PrettyOptions} options
|
||||
*/
|
||||
function prettyTransporter(options) {
|
||||
const pretty = PinoPretty({
|
||||
...options,
|
||||
ignore: "pid,hostname",
|
||||
translateTime: "SYS:HH:MM:ss",
|
||||
});
|
||||
|
||||
return pretty;
|
||||
}
|
||||
|
||||
export default prettyTransporter;
|
||||
4006
web/package-lock.json
generated
4006
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,8 +24,8 @@
|
||||
"pseudolocalize": "node ./scripts/pseudolocalize.mjs",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "wireit",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test": "wireit",
|
||||
"test:e2e": "wireit",
|
||||
"test:e2e:watch": "wireit",
|
||||
"test:watch": "wireit",
|
||||
"tsc": "wireit",
|
||||
@@ -69,9 +69,6 @@
|
||||
"#flow/*": "./src/flow/*.js",
|
||||
"#locales/*": "./src/locales/*.js",
|
||||
"#stories/*": "./src/stories/*.js",
|
||||
"#tests/*": "./tests/*.js",
|
||||
"#e2e": "./e2e/index.ts",
|
||||
"#e2e/*": "./e2e/*.ts",
|
||||
"#*/browser": {
|
||||
"types": "./out/*/browser.d.ts",
|
||||
"import": "./*/browser.js"
|
||||
@@ -97,7 +94,7 @@
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@formatjs/intl-listformat": "^7.7.11",
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@goauthentik/api": "^2025.6.4-1753714826",
|
||||
"@goauthentik/api": "^2025.6.4-1754241870",
|
||||
"@goauthentik/core": "^1.0.0",
|
||||
"@goauthentik/esbuild-plugin-live-reload": "^1.1.0",
|
||||
"@goauthentik/eslint-config": "^1.0.5",
|
||||
@@ -116,7 +113,6 @@
|
||||
"@openlayers-elements/maps": "^0.4.0",
|
||||
"@patternfly/elements": "^4.1.0",
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@sentry/browser": "^10.0.0",
|
||||
"@spotlightjs/spotlight": "^3.0.1",
|
||||
"@storybook/addon-docs": "^9.1.0",
|
||||
@@ -132,17 +128,11 @@
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@wdio/browser-runner": "^9.18.4",
|
||||
"@wdio/cli": "9.15",
|
||||
"@wdio/spec-reporter": "^9.15.0",
|
||||
"@web/test-runner": "^0.20.2",
|
||||
"@webcomponents/webcomponentsjs": "^2.8.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"change-case": "^5.4.4",
|
||||
"chart.js": "^4.5.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chromedriver": "^138.0.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"construct-style-sheets-polyfill": "^3.1.0",
|
||||
"core-js": "^3.44.0",
|
||||
@@ -168,9 +158,6 @@
|
||||
"md-front-matter": "^1.0.4",
|
||||
"mermaid": "^11.9.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"playwright": "^1.54.1",
|
||||
"prettier": "^3.6.2",
|
||||
"pseudolocale": "^2.1.0",
|
||||
"rapidoc": "^9.3.8",
|
||||
@@ -191,10 +178,7 @@
|
||||
"turnstile-types": "^1.2.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vite": "^7.0.6",
|
||||
"vitest": "^3.2.4",
|
||||
"webcomponent-qr-code": "^1.3.0",
|
||||
"wireit": "^0.14.12",
|
||||
"yaml": "^2.8.0"
|
||||
@@ -205,7 +189,11 @@
|
||||
"@esbuild/linux-x64": "^0.25.4",
|
||||
"@rollup/rollup-darwin-arm64": "^4.46.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.46.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.46.2"
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.46.2",
|
||||
"@wdio/browser-runner": "^9.18.4",
|
||||
"@wdio/cli": "^9.18.4",
|
||||
"@wdio/spec-reporter": "^9.18.0",
|
||||
"@web/test-runner": "^0.20.2"
|
||||
},
|
||||
"wireit": {
|
||||
"build": {
|
||||
@@ -278,7 +266,7 @@
|
||||
"command": "lit-analyzer src"
|
||||
},
|
||||
"lint:types:tests": {
|
||||
"command": "tsc --noEmit -p tsconfig.test.json"
|
||||
"command": "tsc --noEmit -p ./tests"
|
||||
},
|
||||
"lint:types": {
|
||||
"command": "tsc -p .",
|
||||
@@ -327,7 +315,7 @@
|
||||
],
|
||||
"env": {
|
||||
"CI": "true",
|
||||
"TS_NODE_PROJECT": "tsconfig.test.json"
|
||||
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
|
||||
}
|
||||
},
|
||||
"test:e2e:watch": {
|
||||
@@ -336,7 +324,7 @@
|
||||
"build"
|
||||
],
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "tsconfig.test.json"
|
||||
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
|
||||
}
|
||||
},
|
||||
"test:watch": {
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* @file Playwright configuration.
|
||||
*
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*
|
||||
* @import { LogFn, Logger } from "pino"
|
||||
*/
|
||||
|
||||
import { ConsoleLogger } from "#logger/node";
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const CI = !!process.env.CI;
|
||||
|
||||
/**
|
||||
* @type {Map<string, Logger>}
|
||||
*/
|
||||
const LoggerCache = new Map();
|
||||
|
||||
const baseURL = process.env.AK_TEST_RUNNER_PAGE_URL ?? "http://localhost:9000";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./test/browser",
|
||||
fullyParallel: true,
|
||||
forbidOnly: CI,
|
||||
retries: CI ? 2 : 0,
|
||||
workers: CI ? 1 : undefined,
|
||||
reporter: CI
|
||||
? "github"
|
||||
: [
|
||||
// ---
|
||||
["list", { printSteps: true }],
|
||||
["html", { open: "never" }],
|
||||
],
|
||||
use: {
|
||||
testIdAttribute: "data-test-id",
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
launchOptions: {
|
||||
logger: {
|
||||
isEnabled() {
|
||||
return true;
|
||||
},
|
||||
log: (name, severity, message, args) => {
|
||||
let logger = LoggerCache.get(name);
|
||||
|
||||
if (!logger) {
|
||||
logger = ConsoleLogger.child({
|
||||
name: `Playwright ${name.toUpperCase()}`,
|
||||
});
|
||||
LoggerCache.set(name, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {LogFn}
|
||||
*/
|
||||
let log;
|
||||
|
||||
switch (severity) {
|
||||
case "verbose":
|
||||
log = logger.debug;
|
||||
break;
|
||||
case "warning":
|
||||
log = logger.warn;
|
||||
break;
|
||||
case "error":
|
||||
log = logger.error;
|
||||
break;
|
||||
default:
|
||||
log = logger.info;
|
||||
break;
|
||||
}
|
||||
|
||||
if (name === "api") {
|
||||
log = logger.debug;
|
||||
}
|
||||
|
||||
log.call(logger, message.toString(), args);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -5,10 +5,6 @@ import { groupBy } from "#common/utils";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { IDGenerator } from "#packages/core/id";
|
||||
|
||||
import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/api";
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
@@ -42,48 +38,37 @@ export class AkProviderInput extends AKElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
//#region Properties
|
||||
@property({ type: String })
|
||||
name!: string;
|
||||
|
||||
@property({ type: String })
|
||||
public name!: string;
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
label = "";
|
||||
|
||||
@property({ type: Number })
|
||||
public value?: number;
|
||||
value?: number;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public required = false;
|
||||
required = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public blankable = false;
|
||||
blankable = false;
|
||||
|
||||
@property({ type: String })
|
||||
public help?: string;
|
||||
help = "";
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
protected fieldID = IDGenerator.elementID().toString();
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
#selected = (item: Provider) => {
|
||||
return typeof this.value === "number" && this.value === item.pk;
|
||||
};
|
||||
selected(item: Provider) {
|
||||
return this.value !== undefined && this.value === item.pk;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <ak-form-element-horizontal name=${this.name}>
|
||||
<div slot="label" class="pf-c-form__group-label">
|
||||
${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label)}
|
||||
</div>
|
||||
|
||||
return html` <ak-form-element-horizontal label=${this.label} name=${this.name}>
|
||||
<ak-search-select
|
||||
.fieldID=${this.fieldID}
|
||||
.selected=${this.#selected}
|
||||
.selected=${this.selected}
|
||||
.fetchObjects=${fetch}
|
||||
.renderElement=${renderElement}
|
||||
.value=${renderValue}
|
||||
|
||||
@@ -135,8 +135,7 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
|
||||
name="group"
|
||||
value=${ifDefined(app.group)}
|
||||
label=${msg("Group")}
|
||||
placeholder=${msg("e.g. Collaboration, Communication, Internal, etc.")}
|
||||
.errorMessages=${errors.group}
|
||||
.errorMessages=${errors.group ?? []}
|
||||
help=${msg(
|
||||
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
|
||||
)}
|
||||
@@ -148,7 +147,7 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
|
||||
name="policyEngineMode"
|
||||
.options=${policyEngineModes}
|
||||
.value=${app.policyEngineMode}
|
||||
.errorMessages=${errors.policyEngineMode}
|
||||
.errorMessages=${errors.policyEngineMode ?? []}
|
||||
></ak-radio-input>
|
||||
<ak-form-group label=${msg("UI Settings")}>
|
||||
<div class="pf-c-form">
|
||||
|
||||
@@ -50,12 +50,13 @@ export class CoreGroupSearch extends CustomListenerElement(AKElement) {
|
||||
search!: SearchSelect<Group>;
|
||||
|
||||
@property({ type: String })
|
||||
public name?: string | null;
|
||||
name: string | null | undefined;
|
||||
|
||||
selectedGroup?: Group;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
|
||||
}
|
||||
|
||||
@@ -82,9 +83,9 @@ export class CoreGroupSearch extends CustomListenerElement(AKElement) {
|
||||
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
selected = (group: Group) => {
|
||||
selected(group: Group) {
|
||||
return this.group === group.pk;
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
|
||||
@@ -40,13 +40,7 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
search!: SearchSelect<CertificateKeyPair>;
|
||||
|
||||
@property({ type: String })
|
||||
public name?: string | null;
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string | undefined;
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder?: string | undefined;
|
||||
name: string | null | undefined;
|
||||
|
||||
/**
|
||||
* Set to `true` to allow certificates without private key to show up. When set to `false`,
|
||||
@@ -54,7 +48,7 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "nokey" })
|
||||
public noKey = false;
|
||||
noKey = false;
|
||||
|
||||
/**
|
||||
* Set this to true if, should there be only one certificate available, you want the system to
|
||||
@@ -63,12 +57,16 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "singleton" })
|
||||
public singleton = false;
|
||||
singleton = false;
|
||||
|
||||
/**
|
||||
* @todo Document this.
|
||||
*/
|
||||
public selectedKeypair?: CertificateKeyPair;
|
||||
selectedKeypair?: CertificateKeyPair;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
this.fetchObjects = this.fetchObjects.bind(this);
|
||||
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.selectedKeypair ? renderValue(this.selectedKeypair) : null;
|
||||
@@ -87,13 +85,13 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
}
|
||||
}
|
||||
|
||||
handleSearchUpdate = (ev: CustomEvent) => {
|
||||
handleSearchUpdate(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
this.selectedKeypair = ev.detail.value;
|
||||
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
|
||||
};
|
||||
}
|
||||
|
||||
fetchObjects = async (query?: string): Promise<CertificateKeyPair[]> => {
|
||||
async fetchObjects(query?: string): Promise<CertificateKeyPair[]> {
|
||||
const args: CryptoCertificatekeypairsListRequest = {
|
||||
ordering: "name",
|
||||
hasKey: !this.noKey,
|
||||
@@ -106,21 +104,19 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement)
|
||||
args,
|
||||
);
|
||||
return certificates.results;
|
||||
};
|
||||
}
|
||||
|
||||
selected = (item: CertificateKeyPair, items: CertificateKeyPair[]) => {
|
||||
selected(item: CertificateKeyPair, items: CertificateKeyPair[]) {
|
||||
return (
|
||||
(this.singleton && !this.certificate && items.length === 1) ||
|
||||
(!!this.certificate && this.certificate === item.pk)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<ak-search-select
|
||||
name=${ifDefined(this.name ?? undefined)}
|
||||
label=${ifDefined(this.label ?? undefined)}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
.fetchObjects=${this.fetchObjects}
|
||||
.renderElement=${renderElement}
|
||||
.value=${renderValue}
|
||||
|
||||
@@ -3,7 +3,6 @@ import "#elements/forms/SearchSelect/index";
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import type { HorizontalFormElement } from "#elements/forms/HorizontalFormElement";
|
||||
import { SearchSelect } from "#elements/forms/SearchSelect/index";
|
||||
import { CustomListenerElement } from "#elements/utils/eventEmitter";
|
||||
|
||||
@@ -12,7 +11,6 @@ import { RenderFlowOption } from "#admin/flows/utils";
|
||||
import type { Flow, FlowsInstancesListRequest } from "@goauthentik/api";
|
||||
import { FlowsApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { property, query } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
@@ -35,17 +33,17 @@ export function getFlowValue(flow: Flow | undefined): string | undefined {
|
||||
* A wrapper around SearchSelect that understands the basic semantics of querying about Flows. This
|
||||
* code eliminates the long blocks of unreadable invocation that were embedded in every provider, as well as in
|
||||
* sources, brands, and applications.
|
||||
*
|
||||
*/
|
||||
export abstract class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement) {
|
||||
//#region Properties
|
||||
|
||||
export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement) {
|
||||
/**
|
||||
* The type of flow we're looking for.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public flowType?: FlowsInstancesListDesignationEnum;
|
||||
flowType?: FlowsInstancesListDesignationEnum;
|
||||
|
||||
/**
|
||||
* The id of the current flow, if any. For stages where the flow is already defined.
|
||||
@@ -53,7 +51,7 @@ export abstract class FlowSearch<T extends Flow> extends CustomListenerElement(A
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public currentFlow?: string;
|
||||
currentFlow?: string | undefined;
|
||||
|
||||
/**
|
||||
* If true, it is not valid to leave the flow blank.
|
||||
@@ -61,7 +59,10 @@ export abstract class FlowSearch<T extends Flow> extends CustomListenerElement(A
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
public required?: boolean;
|
||||
required?: boolean = false;
|
||||
|
||||
@query("ak-search-select")
|
||||
search!: SearchSelect<T>;
|
||||
|
||||
/**
|
||||
* When specified and the object instance does not have a flow selected, auto-select the flow with the given slug.
|
||||
@@ -69,118 +70,66 @@ export abstract class FlowSearch<T extends Flow> extends CustomListenerElement(A
|
||||
* @attr
|
||||
*/
|
||||
@property()
|
||||
public defaultFlowSlug?: string;
|
||||
defaultFlowSlug?: string;
|
||||
|
||||
@property({ type: String })
|
||||
public name?: string;
|
||||
name: string | null | undefined;
|
||||
|
||||
/**
|
||||
* The label of the input, for forms.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
/**
|
||||
* The textual placeholder for the search's <input> object, if currently empty. Used as the
|
||||
* native <input> object's `placeholder` field.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public placeholder = msg("Select a flow...");
|
||||
|
||||
@query("ak-search-select")
|
||||
protected search!: SearchSelect<T>;
|
||||
|
||||
protected selectedFlow?: T;
|
||||
selectedFlow?: T;
|
||||
|
||||
get value() {
|
||||
return this.selectedFlow ? getFlowValue(this.selectedFlow) : null;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
protected searchUpdateListener = (event: CustomEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
this.selectedFlow = event.detail.value;
|
||||
constructor() {
|
||||
super();
|
||||
this.fetchObjects = this.fetchObjects.bind(this);
|
||||
this.selected = this.selected.bind(this);
|
||||
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
|
||||
}
|
||||
|
||||
handleSearchUpdate(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
this.selectedFlow = ev.detail.value;
|
||||
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
|
||||
};
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
/**
|
||||
* Fetch the objects from the API.
|
||||
*
|
||||
* @param query The search query, if any.
|
||||
*/
|
||||
protected fetchObjects = (query?: string): Promise<Flow[]> => {
|
||||
async fetchObjects(query?: string): Promise<Flow[]> {
|
||||
const args: FlowsInstancesListRequest = {
|
||||
ordering: "slug",
|
||||
designation: this.flowType,
|
||||
...(query ? { search: query } : {}),
|
||||
...(query !== undefined ? { search: query } : {}),
|
||||
};
|
||||
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(args);
|
||||
return flows.results;
|
||||
}
|
||||
|
||||
return new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(args).then((flows) => flows.results);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if the flow matches the current state of the search.
|
||||
*
|
||||
* @param flow The flow to compare against.
|
||||
/* This is the most commonly overridden method of this class. About half of the Flow Searches
|
||||
* use this method, but several have more complex needs, such as relating to the brand, or just
|
||||
* returning false.
|
||||
*/
|
||||
protected match = (flow: Flow): boolean => {
|
||||
if (this.currentFlow) {
|
||||
return this.currentFlow === flow.pk;
|
||||
selected(flow: Flow): boolean {
|
||||
let selected = this.currentFlow === flow.pk;
|
||||
if (!this.currentFlow && this.defaultFlowSlug && flow.slug === this.defaultFlowSlug) {
|
||||
selected = true;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
return !!(this.defaultFlowSlug && flow.slug === this.defaultFlowSlug);
|
||||
};
|
||||
|
||||
/**
|
||||
* This is the most commonly overridden method of this class.
|
||||
*
|
||||
* About half of the Flow Searches use this method, but several have more complex needs,
|
||||
* such as relating to the brand, or just returning false.
|
||||
*
|
||||
* @param flow The flow to compare against.
|
||||
* @abstract
|
||||
*/
|
||||
protected selected = (flow: Flow): boolean => {
|
||||
return this.match(flow);
|
||||
};
|
||||
|
||||
public override connectedCallback() {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
const horizontalContainer = this.closest<HorizontalFormElement>(
|
||||
"ak-form-element-horizontal[name]",
|
||||
);
|
||||
|
||||
const horizontalContainer = this.closest("ak-form-element-horizontal[name]");
|
||||
if (!horizontalContainer) {
|
||||
throw new Error("This search can only be used in a named ak-form-element-horizontal");
|
||||
}
|
||||
|
||||
const name = horizontalContainer.getAttribute("name");
|
||||
const myName = this.getAttribute("name");
|
||||
|
||||
if (name !== null && name !== myName) {
|
||||
this.setAttribute("name", name);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Render
|
||||
|
||||
public override render() {
|
||||
render() {
|
||||
return html`
|
||||
<ak-search-select
|
||||
.fetchObjects=${this.fetchObjects}
|
||||
@@ -188,15 +137,13 @@ export abstract class FlowSearch<T extends Flow> extends CustomListenerElement(A
|
||||
.renderElement=${renderElement}
|
||||
.renderDescription=${renderDescription}
|
||||
.value=${getFlowValue}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
label=${ifDefined(this.label)}
|
||||
name=${ifDefined(this.name)}
|
||||
@ak-change=${this.searchUpdateListener}
|
||||
name=${ifDefined(this.name ?? undefined)}
|
||||
@ak-change=${this.handleSearchUpdate}
|
||||
?blankable=${!this.required}
|
||||
>
|
||||
</ak-search-select>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export default FlowSearch;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FlowSearch } from "./FlowSearch.js";
|
||||
import FlowSearch from "./FlowSearch.js";
|
||||
|
||||
import type { Flow } from "@goauthentik/api";
|
||||
|
||||
@@ -19,11 +19,16 @@ export class AkBrandedFlowSearch<T extends Flow> extends FlowSearch<T> {
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: false, type: String })
|
||||
public brandFlow?: string;
|
||||
brandFlow?: string;
|
||||
|
||||
protected override selected = (flow: Flow): boolean => {
|
||||
return this.match(flow) || flow.pk === this.brandFlow;
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
}
|
||||
|
||||
selected(flow: Flow): boolean {
|
||||
return super.selected(flow) || flow.pk === this.brandFlow;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -24,7 +24,7 @@ export class AkFlowSearchNoDefault<T extends Flow> extends FlowSearch<T> {
|
||||
.renderElement=${renderElement}
|
||||
.renderDescription=${renderDescription}
|
||||
.value=${getFlowValue}
|
||||
@ak-change=${this.searchUpdateListener}
|
||||
@ak-change=${this.handleSearchUpdate}
|
||||
?blankable=${!this.required}
|
||||
>
|
||||
</ak-search-select>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FlowSearch } from "./FlowSearch.js";
|
||||
import FlowSearch from "./FlowSearch.js";
|
||||
|
||||
import type { Flow } from "@goauthentik/api";
|
||||
|
||||
@@ -18,3 +18,5 @@ declare global {
|
||||
"ak-flow-search": AkFlowSearch<Flow>;
|
||||
}
|
||||
}
|
||||
|
||||
export default AkFlowSearch;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FlowSearch } from "./FlowSearch.js";
|
||||
import FlowSearch from "./FlowSearch.js";
|
||||
|
||||
import type { Flow } from "@goauthentik/api";
|
||||
|
||||
@@ -18,8 +18,9 @@ export class AkSourceFlowSearch<T extends Flow> extends FlowSearch<T> {
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
|
||||
@property({ type: String })
|
||||
public fallback?: string;
|
||||
fallback: string | undefined;
|
||||
|
||||
/**
|
||||
* The primary key of the Source (not the Flow). Mostly the instancePk itself, used to affirm
|
||||
@@ -28,16 +29,21 @@ export class AkSourceFlowSearch<T extends Flow> extends FlowSearch<T> {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public instanceId?: string;
|
||||
instanceId: string | undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.selected = this.selected.bind(this);
|
||||
}
|
||||
|
||||
// If there's no instance or no currentFlowId for it and the flow resembles the fallback,
|
||||
// otherwise defer to the parent class.
|
||||
protected override selected = (flow: Flow): boolean => {
|
||||
selected(flow: Flow): boolean {
|
||||
return (
|
||||
(!this.instanceId && !this.currentFlow && flow.slug === this.fallback) ||
|
||||
this.match(flow)
|
||||
super.selected(flow)
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -51,7 +51,7 @@ export function renderForm(
|
||||
placeholder=${msg("Provider name")}
|
||||
value=${ifDefined(provider?.name)}
|
||||
label=${msg("Name")}
|
||||
.errorMessages=${errors?.name}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
required
|
||||
help=${msg("Method's display Name.")}
|
||||
></ak-text-input>
|
||||
@@ -87,7 +87,7 @@ export function renderForm(
|
||||
label=${msg("Bind flow")}
|
||||
required
|
||||
name="authorizationFlow"
|
||||
.errorMessages=${errors?.authorizationFlow}
|
||||
.errorMessages=${errors?.authorizationFlow ?? []}
|
||||
>
|
||||
<ak-branded-flow-search
|
||||
label=${msg("Bind flow")}
|
||||
@@ -111,7 +111,7 @@ export function renderForm(
|
||||
.currentFlow=${provider?.invalidationFlow}
|
||||
.brandFlow=${brand?.flowInvalidation}
|
||||
defaultFlowSlug="default-invalidation-flow"
|
||||
.errorMessages=${errors?.invalidationFlow}
|
||||
.errorMessages=${errors?.invalidationFlow ?? []}
|
||||
required
|
||||
></ak-branded-flow-search>
|
||||
<p class="pf-c-form__helper-text">${msg("Flow used for unbinding users.")}</p>
|
||||
@@ -127,7 +127,7 @@ export function renderForm(
|
||||
required
|
||||
value="${provider?.baseDn ?? "DC=ldap,DC=goauthentik,DC=io"}"
|
||||
input-hint="code"
|
||||
.errorMessages=${errors?.baseDn}
|
||||
.errorMessages=${errors?.baseDn ?? []}
|
||||
help=${msg(
|
||||
"LDAP DN under which bind requests and search requests can be made.",
|
||||
)}
|
||||
@@ -137,11 +137,9 @@ export function renderForm(
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Certificate")}
|
||||
name="certificate"
|
||||
.errorMessages=${errors?.certificate}
|
||||
.errorMessages=${errors?.certificate ?? []}
|
||||
>
|
||||
<ak-crypto-certificate-search
|
||||
label=${msg("Certificate")}
|
||||
placeholder=${msg("Select a certificate...")}
|
||||
certificate=${ifDefined(provider?.certificate ?? nothing)}
|
||||
name="certificate"
|
||||
>
|
||||
@@ -153,7 +151,7 @@ export function renderForm(
|
||||
label=${msg("TLS Server name")}
|
||||
name="tlsServerName"
|
||||
value="${provider?.tlsServerName ?? ""}"
|
||||
.errorMessages=${errors?.tlsServerName}
|
||||
.errorMessages=${errors?.tlsServerName ?? []}
|
||||
help=${tlsServerNameHelp}
|
||||
input-hint="code"
|
||||
></ak-text-input>
|
||||
@@ -163,7 +161,7 @@ export function renderForm(
|
||||
required
|
||||
name="uidStartNumber"
|
||||
value="${provider?.uidStartNumber ?? 2000}"
|
||||
.errorMessages=${errors?.uidStartNumber}
|
||||
.errorMessages=${errors?.uidStartNumber ?? []}
|
||||
help=${uidStartNumberHelp}
|
||||
></ak-number-input>
|
||||
|
||||
@@ -172,7 +170,7 @@ export function renderForm(
|
||||
required
|
||||
name="gidStartNumber"
|
||||
value="${provider?.gidStartNumber ?? 4000}"
|
||||
.errorMessages=${errors?.gidStartNumber}
|
||||
.errorMessages=${errors?.gidStartNumber ?? []}
|
||||
help=${gidStartNumberHelp}
|
||||
></ak-number-input>
|
||||
</div>
|
||||
|
||||
@@ -124,10 +124,8 @@ export function renderForm(
|
||||
) {
|
||||
return html` <ak-text-input
|
||||
name="name"
|
||||
placeholder=${msg("Provider name")}
|
||||
label=${msg("Name")}
|
||||
value=${ifDefined(provider?.name)}
|
||||
.errorMessages=${errors?.name}
|
||||
required
|
||||
></ak-text-input>
|
||||
|
||||
@@ -137,11 +135,8 @@ export function renderForm(
|
||||
required
|
||||
>
|
||||
<ak-flow-search
|
||||
label=${msg("Authorization flow")}
|
||||
placeholder=${msg("Select an authorization flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authorization}
|
||||
.currentFlow=${provider?.authorizationFlow}
|
||||
.errorMessages=${errors?.authorizationFlow}
|
||||
required
|
||||
></ak-flow-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
@@ -167,7 +162,6 @@ export function renderForm(
|
||||
value="${provider?.clientId ?? randomString(40, ascii_letters + digits)}"
|
||||
required
|
||||
input-hint="code"
|
||||
.errorMessages=${errors?.clientId}
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-hidden-text-input
|
||||
@@ -180,6 +174,7 @@ export function renderForm(
|
||||
>
|
||||
</ak-hidden-text-input>
|
||||
<ak-form-element-horizontal
|
||||
flow-direction="row"
|
||||
label=${msg("Redirect URIs/Origins (RegEx)")}
|
||||
name="redirectUris"
|
||||
>
|
||||
@@ -202,8 +197,6 @@ export function renderForm(
|
||||
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
|
||||
<!-- NOTE: 'null' cast to 'undefined' on signingKey to satisfy Lit requirements -->
|
||||
<ak-crypto-certificate-search
|
||||
label=${msg("Signing Key")}
|
||||
placeholder=${msg("Select a signing key...")}
|
||||
certificate=${ifDefined(provider?.signingKey ?? undefined)}
|
||||
singleton
|
||||
></ak-crypto-certificate-search>
|
||||
@@ -212,8 +205,6 @@ export function renderForm(
|
||||
<ak-form-element-horizontal label=${msg("Encryption Key")} name="encryptionKey">
|
||||
<!-- NOTE: 'null' cast to 'undefined' on encryptionKey to satisfy Lit requirements -->
|
||||
<ak-crypto-certificate-search
|
||||
label=${msg("Encryption Key")}
|
||||
placeholder=${msg("Select an encryption key...")}
|
||||
certificate=${ifDefined(provider?.encryptionKey ?? undefined)}
|
||||
></ak-crypto-certificate-search>
|
||||
<p class="pf-c-form__helper-text">${msg("Key used to encrypt the tokens.")}</p>
|
||||
@@ -228,8 +219,6 @@ export function renderForm(
|
||||
label=${msg("Authentication flow")}
|
||||
>
|
||||
<ak-flow-search
|
||||
label=${msg("Authentication flow")}
|
||||
placeholder=${msg("Select an authentication flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authentication}
|
||||
.currentFlow=${provider?.authenticationFlow}
|
||||
></ak-flow-search>
|
||||
@@ -245,8 +234,6 @@ export function renderForm(
|
||||
required
|
||||
>
|
||||
<ak-flow-search
|
||||
label=${msg("Invalidation flow")}
|
||||
placeholder=${msg("Select an invalidation flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Invalidation}
|
||||
.currentFlow=${provider?.invalidationFlow}
|
||||
defaultFlowSlug="default-provider-invalidation-flow"
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
RbacPermissionsAssignedByUsersListModelEnum,
|
||||
User,
|
||||
} from "@goauthentik/api";
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
|
||||
import MDProviderOAuth2 from "~docs/add-secure-apps/providers/oauth2/index.mdx";
|
||||
|
||||
@@ -268,16 +267,12 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
<div class="pf-c-card__body">
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("providerInfo")}"
|
||||
>
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("OpenID Configuration URL")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("providerInfo")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -285,16 +280,12 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("issuer")}"
|
||||
>
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("OpenID Configuration Issuer")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("issuer")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -303,16 +294,12 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
</div>
|
||||
<hr class="pf-c-divider" />
|
||||
<div class="pf-c-form__group">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("authorize")}"
|
||||
>
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("Authorize URL")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("authorize")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -320,14 +307,10 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("token")}"
|
||||
>
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text">${msg("Token URL")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("token")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -335,16 +318,12 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("userInfo")}"
|
||||
>
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text"
|
||||
>${msg("Userinfo URL")}</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("userInfo")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -352,14 +331,10 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("logout")}"
|
||||
>
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text">${msg("Logout URL")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("logout")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -367,14 +342,10 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label
|
||||
class="pf-c-form__label"
|
||||
for="${IDGenerator.elementID("jwks")}"
|
||||
>
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text">${msg("JWKS URL")}</span>
|
||||
</label>
|
||||
<input
|
||||
id="${IDGenerator.elementID("jwks")}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
type="text"
|
||||
@@ -420,12 +391,9 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
${renderDescriptionList(
|
||||
[
|
||||
[
|
||||
html`<label for="${IDGenerator.elementID("preview-user")}"
|
||||
>${msg("Preview for user")}</label
|
||||
>`,
|
||||
msg("Preview for user"),
|
||||
html`
|
||||
<ak-search-select
|
||||
id="${IDGenerator.elementID("preview-user")}"
|
||||
.fetchObjects=${async (query?: string): Promise<User[]> => {
|
||||
const args: CoreUsersListRequest = {
|
||||
ordering: "username",
|
||||
|
||||
@@ -88,7 +88,7 @@ function renderProxySettings(provider: Partial<ProxyProvider>, errors?: Validati
|
||||
label=${msg("External host")}
|
||||
value="${ifDefined(provider?.externalHost)}"
|
||||
required
|
||||
.errorMessages=${errors?.externalHost}
|
||||
.errorMessages=${errors?.externalHost ?? []}
|
||||
help=${msg(
|
||||
"The external URL you'll access the application at. Include any non-standard port.",
|
||||
)}
|
||||
@@ -99,7 +99,7 @@ function renderProxySettings(provider: Partial<ProxyProvider>, errors?: Validati
|
||||
label=${msg("Internal host")}
|
||||
value="${ifDefined(provider?.internalHost)}"
|
||||
required
|
||||
.errorMessages=${errors?.internalHost}
|
||||
.errorMessages=${errors?.internalHost ?? []}
|
||||
help=${msg("Upstream host that the requests are forwarded to.")}
|
||||
input-hint="code"
|
||||
></ak-text-input>
|
||||
@@ -124,7 +124,7 @@ function renderForwardSingleSettings(provider: Partial<ProxyProvider>, errors?:
|
||||
label=${msg("External host")}
|
||||
value="${ifDefined(provider?.externalHost)}"
|
||||
required
|
||||
.errorMessages=${errors?.externalHost}
|
||||
.errorMessages=${errors?.externalHost ?? []}
|
||||
help=${msg(
|
||||
"The external URL you'll access the application at. Include any non-standard port.",
|
||||
)}
|
||||
@@ -154,7 +154,7 @@ function renderForwardDomainSettings(provider: Partial<ProxyProvider>, errors?:
|
||||
label=${msg("Authentication URL")}
|
||||
value="${provider?.externalHost ?? window.location.origin}"
|
||||
required
|
||||
.errorMessages=${errors?.externalHost}
|
||||
.errorMessages=${errors?.externalHost ?? []}
|
||||
help=${msg(
|
||||
"The external URL you'll authenticate at. The authentik core server should be reachable under this URL.",
|
||||
)}
|
||||
@@ -165,7 +165,7 @@ function renderForwardDomainSettings(provider: Partial<ProxyProvider>, errors?:
|
||||
name="cookieDomain"
|
||||
value="${ifDefined(provider?.cookieDomain)}"
|
||||
required
|
||||
.errorMessages=${errors?.cookieDomain}
|
||||
.errorMessages=${errors?.cookieDomain ?? []}
|
||||
help=${msg(
|
||||
"Set this to the domain you wish the authentication to be valid for. Must be a parent domain of the URL above. If you're running applications as app1.domain.tld, app2.domain.tld, set this to 'domain.tld'.",
|
||||
)}
|
||||
@@ -196,7 +196,7 @@ export function renderForm(
|
||||
name="name"
|
||||
value=${ifDefined(provider?.name)}
|
||||
label=${msg("Name")}
|
||||
.errorMessages=${errors?.name}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
required
|
||||
></ak-text-input>
|
||||
|
||||
@@ -224,7 +224,7 @@ export function renderForm(
|
||||
label=${msg("Token validity")}
|
||||
name="accessTokenValidity"
|
||||
value="${provider?.accessTokenValidity ?? "hours=24"}"
|
||||
.errorMessages=${errors?.accessTokenValidity}
|
||||
.errorMessages=${errors?.accessTokenValidity ?? []}
|
||||
required
|
||||
.help=${msg("Configure how long tokens are valid for.")}
|
||||
input-hint="code"
|
||||
|
||||
@@ -45,9 +45,8 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="name"
|
||||
label=${msg("Name")}
|
||||
placeholder=${msg("Provider name")}
|
||||
value=${ifDefined(provider?.name)}
|
||||
.errorMessages=${errors?.name}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
required
|
||||
>
|
||||
</ak-text-input>
|
||||
@@ -56,11 +55,9 @@ export function renderForm(
|
||||
label=${msg("Authentication flow")}
|
||||
required
|
||||
name="authorizationFlow"
|
||||
.errorMessages=${errors?.authorizationFlow}
|
||||
.errorMessages=${errors?.authorizationFlow ?? []}
|
||||
>
|
||||
<ak-branded-flow-search
|
||||
label=${msg("Authentication flow")}
|
||||
placeholder=${msg("Select an authentication flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authentication}
|
||||
.currentFlow=${provider?.authorizationFlow}
|
||||
.brandFlow=${brand?.flowAuthentication}
|
||||
@@ -82,7 +79,7 @@ export function renderForm(
|
||||
<ak-hidden-text-input
|
||||
name="sharedSecret"
|
||||
label=${msg("Shared secret")}
|
||||
.errorMessages=${errors?.sharedSecret}
|
||||
.errorMessages=${errors?.sharedSecret ?? []}
|
||||
value=${provider?.sharedSecret ?? randomString(128, ascii_letters + digits)}
|
||||
required
|
||||
input-hint="code"
|
||||
@@ -91,7 +88,7 @@ export function renderForm(
|
||||
name="clientNetworks"
|
||||
label=${msg("Client Networks")}
|
||||
value=${provider?.clientNetworks ?? "0.0.0.0/0, ::/0"}
|
||||
.errorMessages=${errors?.clientNetworks}
|
||||
.errorMessages=${errors?.clientNetworks ?? []}
|
||||
required
|
||||
help=${clientNetworksHelp}
|
||||
input-hint="code"
|
||||
@@ -121,7 +118,7 @@ export function renderForm(
|
||||
placeholder=${msg("Select an invalidation flow...")}
|
||||
flowType=${FlowsInstancesListDesignationEnum.Invalidation}
|
||||
.currentFlow=${provider?.invalidationFlow}
|
||||
.errorMessages=${errors?.invalidationFlow}
|
||||
.errorMessages=${errors?.invalidationFlow ?? []}
|
||||
defaultFlowSlug="default-invalidation-flow"
|
||||
required
|
||||
></ak-flow-search>
|
||||
|
||||
@@ -12,12 +12,11 @@ import { digestAlgorithmOptions, signatureAlgorithmOptions } from "./SAMLProvide
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { RadioOption } from "#elements/forms/Radio";
|
||||
|
||||
import {
|
||||
FlowsInstancesListDesignationEnum,
|
||||
PropertymappingsApi,
|
||||
PropertymappingsProviderSamlListRequest,
|
||||
SAMLNameIDPolicyEnum,
|
||||
SAMLPropertyMapping,
|
||||
SAMLProvider,
|
||||
SpBindingEnum,
|
||||
@@ -28,7 +27,7 @@ import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
const serviceProviderBindingOptions: RadioOption<SpBindingEnum>[] = [
|
||||
const serviceProviderBindingOptions = [
|
||||
{
|
||||
label: msg("Redirect"),
|
||||
value: SpBindingEnum.Redirect,
|
||||
@@ -40,11 +39,11 @@ const serviceProviderBindingOptions: RadioOption<SpBindingEnum>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
function renderHasSigningKp(provider: Partial<SAMLProvider>) {
|
||||
function renderHasSigningKp(provider?: Partial<SAMLProvider>) {
|
||||
return html` <ak-switch-input
|
||||
name="signAssertion"
|
||||
label=${msg("Sign assertions")}
|
||||
?checked=${provider.signAssertion ?? true}
|
||||
?checked=${provider?.signAssertion ?? true}
|
||||
help=${msg("When enabled, the assertion element of the SAML response will be signed.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
@@ -52,7 +51,7 @@ function renderHasSigningKp(provider: Partial<SAMLProvider>) {
|
||||
<ak-switch-input
|
||||
name="signResponse"
|
||||
label=${msg("Sign responses")}
|
||||
?checked=${provider.signResponse ?? false}
|
||||
?checked=${provider?.signResponse ?? false}
|
||||
help=${msg("When enabled, the SAML response will be signed.")}
|
||||
>
|
||||
</ak-switch-input>`;
|
||||
@@ -66,10 +65,10 @@ export function renderForm(
|
||||
) {
|
||||
return html` <ak-text-input
|
||||
name="name"
|
||||
value=${ifDefined(provider.name)}
|
||||
value=${ifDefined(provider?.name)}
|
||||
label=${msg("Name")}
|
||||
required
|
||||
.errorMessages=${errors?.name}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
></ak-text-input>
|
||||
<ak-form-element-horizontal
|
||||
name="authorizationFlow"
|
||||
@@ -78,9 +77,9 @@ export function renderForm(
|
||||
>
|
||||
<ak-flow-search
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authorization}
|
||||
.currentFlow=${provider.authorizationFlow}
|
||||
.errorMessages=${errors?.authorizationFlow}
|
||||
.currentFlow=${provider?.authorizationFlow}
|
||||
required
|
||||
.errorMessages=${errors?.authorizationFlow ?? []}
|
||||
></ak-flow-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Flow used when authorizing this provider.")}
|
||||
@@ -92,16 +91,16 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="acsUrl"
|
||||
label=${msg("ACS URL")}
|
||||
value="${ifDefined(provider.acsUrl)}"
|
||||
value="${ifDefined(provider?.acsUrl)}"
|
||||
required
|
||||
.errorMessages=${errors?.acsUrl}
|
||||
.errorMessages=${errors?.acsUrl ?? []}
|
||||
></ak-text-input>
|
||||
<ak-text-input
|
||||
label=${msg("Issuer")}
|
||||
name="issuer"
|
||||
value="${provider.issuer || "authentik"}"
|
||||
value="${provider?.issuer || "authentik"}"
|
||||
required
|
||||
.errorMessages=${errors?.issuer}
|
||||
.errorMessages=${errors?.issuer ?? []}
|
||||
help=${msg("Also known as EntityID.")}
|
||||
></ak-text-input>
|
||||
<ak-radio-input
|
||||
@@ -109,7 +108,7 @@ export function renderForm(
|
||||
name="spBinding"
|
||||
required
|
||||
.options=${serviceProviderBindingOptions}
|
||||
.value=${provider.spBinding}
|
||||
.value=${provider?.spBinding}
|
||||
help=${msg(
|
||||
"Determines how authentik sends the response back to the Service Provider.",
|
||||
)}
|
||||
@@ -118,8 +117,8 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="audience"
|
||||
label=${msg("Audience")}
|
||||
value="${ifDefined(provider.audience)}"
|
||||
.errorMessages=${errors?.audience}
|
||||
value="${ifDefined(provider?.audience)}"
|
||||
.errorMessages=${errors?.audience ?? []}
|
||||
></ak-text-input>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
@@ -132,7 +131,7 @@ export function renderForm(
|
||||
>
|
||||
<ak-flow-search
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authentication}
|
||||
.currentFlow=${provider.authenticationFlow}
|
||||
.currentFlow=${provider?.authenticationFlow}
|
||||
></ak-flow-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
@@ -147,7 +146,7 @@ export function renderForm(
|
||||
>
|
||||
<ak-flow-search
|
||||
flowType=${FlowsInstancesListDesignationEnum.Invalidation}
|
||||
.currentFlow=${provider.invalidationFlow}
|
||||
.currentFlow=${provider?.invalidationFlow}
|
||||
defaultFlowSlug="default-provider-invalidation-flow"
|
||||
required
|
||||
></ak-flow-search>
|
||||
@@ -162,7 +161,7 @@ export function renderForm(
|
||||
<div class="pf-c-form">
|
||||
<ak-form-element-horizontal label=${msg("Signing Certificate")} name="signingKp">
|
||||
<ak-crypto-certificate-search
|
||||
.certificate=${provider.signingKp}
|
||||
.certificate=${provider?.signingKp}
|
||||
@input=${setHasSigningKp}
|
||||
></ak-crypto-certificate-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
@@ -178,7 +177,7 @@ export function renderForm(
|
||||
name="verificationKp"
|
||||
>
|
||||
<ak-crypto-certificate-search
|
||||
.certificate=${provider.verificationKp}
|
||||
.certificate=${provider?.verificationKp}
|
||||
nokey
|
||||
></ak-crypto-certificate-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
@@ -192,7 +191,7 @@ export function renderForm(
|
||||
name="encryptionKp"
|
||||
>
|
||||
<ak-crypto-certificate-search
|
||||
.certificate=${provider.encryptionKp}
|
||||
.certificate=${provider?.encryptionKp}
|
||||
></ak-crypto-certificate-search>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("When selected, assertions will be encrypted using this keypair.")}
|
||||
@@ -204,7 +203,7 @@ export function renderForm(
|
||||
>
|
||||
<ak-dual-select-dynamic-selected
|
||||
.provider=${propertyMappingsProvider}
|
||||
.selector=${propertyMappingsSelector(provider.propertyMappings)}
|
||||
.selector=${propertyMappingsSelector(provider?.propertyMappings)}
|
||||
available-label=${msg("Available User Property Mappings")}
|
||||
selected-label=${msg("Selected User Property Mappings")}
|
||||
></ak-dual-select-dynamic-selected>
|
||||
@@ -214,7 +213,6 @@ export function renderForm(
|
||||
name="nameIdMapping"
|
||||
>
|
||||
<ak-search-select
|
||||
required
|
||||
.fetchObjects=${async (query?: string): Promise<SAMLPropertyMapping[]> => {
|
||||
const args: PropertymappingsProviderSamlListRequest = {
|
||||
ordering: "saml_name",
|
||||
@@ -234,7 +232,7 @@ export function renderForm(
|
||||
return item?.pk;
|
||||
}}
|
||||
.selected=${(item: SAMLPropertyMapping): boolean => {
|
||||
return provider.nameIdMapping === item.pk;
|
||||
return provider?.nameIdMapping === item.pk;
|
||||
}}
|
||||
blankable
|
||||
>
|
||||
@@ -250,7 +248,6 @@ export function renderForm(
|
||||
name="authnContextClassRefMapping"
|
||||
>
|
||||
<ak-search-select
|
||||
required
|
||||
.fetchObjects=${async (query?: string): Promise<SAMLPropertyMapping[]> => {
|
||||
const args: PropertymappingsProviderSamlListRequest = {
|
||||
ordering: "saml_name",
|
||||
@@ -270,7 +267,7 @@ export function renderForm(
|
||||
return item?.pk;
|
||||
}}
|
||||
.selected=${(item: SAMLPropertyMapping): boolean => {
|
||||
return provider.authnContextClassRefMapping === item.pk;
|
||||
return provider?.authnContextClassRefMapping === item.pk;
|
||||
}}
|
||||
blankable
|
||||
>
|
||||
@@ -285,45 +282,93 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="assertionValidNotBefore"
|
||||
label=${msg("Assertion valid not before")}
|
||||
value="${provider.assertionValidNotBefore || "minutes=-5"}"
|
||||
value="${provider?.assertionValidNotBefore || "minutes=-5"}"
|
||||
required
|
||||
.errorMessages=${errors?.assertionValidNotBefore}
|
||||
.errorMessages=${errors?.assertionValidNotBefore ?? []}
|
||||
help=${msg("Configure the maximum allowed time drift for an assertion.")}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-text-input
|
||||
name="assertionValidNotOnOrAfter"
|
||||
label=${msg("Assertion valid not on or after")}
|
||||
value="${provider.assertionValidNotOnOrAfter || "minutes=5"}"
|
||||
value="${provider?.assertionValidNotOnOrAfter || "minutes=5"}"
|
||||
required
|
||||
.errorMessages=${errors?.assertionValidNotBefore}
|
||||
.errorMessages=${errors?.assertionValidNotBefore ?? []}
|
||||
help=${msg("Assertion not valid on or after current time + this value.")}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-text-input
|
||||
name="sessionValidNotOnOrAfter"
|
||||
label=${msg("Session valid not on or after")}
|
||||
value="${provider.sessionValidNotOnOrAfter || "minutes=86400"}"
|
||||
value="${provider?.sessionValidNotOnOrAfter || "minutes=86400"}"
|
||||
required
|
||||
.errorMessages=${errors?.sessionValidNotOnOrAfter}
|
||||
.errorMessages=${errors?.sessionValidNotOnOrAfter ?? []}
|
||||
help=${msg("Session not valid on or after current time + this value.")}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-text-input
|
||||
name="defaultRelayState"
|
||||
label=${msg("Default relay state")}
|
||||
value="${provider.defaultRelayState || ""}"
|
||||
.errorMessages=${errors?.sessionValidNotOnOrAfter}
|
||||
value="${provider?.defaultRelayState || ""}"
|
||||
.errorMessages=${errors?.sessionValidNotOnOrAfter ?? []}
|
||||
help=${msg(
|
||||
"When using IDP-initiated logins, the relay state will be set to this value.",
|
||||
)}
|
||||
></ak-text-input>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Default NameID Policy")}
|
||||
required
|
||||
name="defaultNameIdPolicy"
|
||||
>
|
||||
<select class="pf-c-form-control">
|
||||
<option
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatPersistent}
|
||||
?selected=${provider?.defaultNameIdPolicy ===
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatPersistent}
|
||||
>
|
||||
${msg("Persistent")}
|
||||
</option>
|
||||
<option
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatEmailAddress}
|
||||
?selected=${provider?.defaultNameIdPolicy ===
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatEmailAddress}
|
||||
>
|
||||
${msg("Email address")}
|
||||
</option>
|
||||
<option
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatWindowsDomainQualifiedName}
|
||||
?selected=${provider?.defaultNameIdPolicy ===
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatWindowsDomainQualifiedName}
|
||||
>
|
||||
${msg("Windows")}
|
||||
</option>
|
||||
<option
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatX509SubjectName}
|
||||
?selected=${provider?.defaultNameIdPolicy ===
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatX509SubjectName}
|
||||
>
|
||||
${msg("X509 Subject")}
|
||||
</option>
|
||||
<option
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatTransient}
|
||||
?selected=${provider?.defaultNameIdPolicy ===
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatTransient}
|
||||
>
|
||||
${msg("Transient")}
|
||||
</option>
|
||||
</select>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-radio-input
|
||||
name="digestAlgorithm"
|
||||
label=${msg("Digest algorithm")}
|
||||
.options=${digestAlgorithmOptions}
|
||||
.value=${provider.digestAlgorithm}
|
||||
.value=${provider?.digestAlgorithm}
|
||||
required
|
||||
>
|
||||
</ak-radio-input>
|
||||
@@ -332,7 +377,7 @@ export function renderForm(
|
||||
name="signatureAlgorithm"
|
||||
label=${msg("Signature algorithm")}
|
||||
.options=${signatureAlgorithmOptions}
|
||||
.value=${provider.signatureAlgorithm}
|
||||
.value=${provider?.signatureAlgorithm}
|
||||
required
|
||||
>
|
||||
</ak-radio-input>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE
|
||||
name="name"
|
||||
value=${ifDefined(provider?.name)}
|
||||
label=${msg("Name")}
|
||||
.errorMessages=${errors?.name}
|
||||
.errorMessages=${errors?.name ?? []}
|
||||
required
|
||||
help=${msg("Method's display Name.")}
|
||||
></ak-text-input>
|
||||
@@ -38,7 +38,7 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE
|
||||
name="url"
|
||||
label=${msg("URL")}
|
||||
value="${provider?.url ?? ""}"
|
||||
.errorMessages=${errors?.url}
|
||||
.errorMessages=${errors?.url ?? []}
|
||||
required
|
||||
help=${msg("SCIM base url, usually ends in /v2.")}
|
||||
input-hint="code"
|
||||
@@ -55,7 +55,7 @@ export function renderForm(provider?: Partial<SCIMProvider>, errors: ValidationE
|
||||
name="token"
|
||||
label=${msg("Token")}
|
||||
value="${provider?.token ?? ""}"
|
||||
.errorMessages=${errors?.token}
|
||||
.errorMessages=${errors?.token ?? []}
|
||||
required
|
||||
help=${msg(
|
||||
"Token to authenticate with. Currently only bearer authentication is supported.",
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
DigestAlgorithmEnum,
|
||||
FlowsInstancesListDesignationEnum,
|
||||
GroupMatchingModeEnum,
|
||||
NameIdPolicyEnum,
|
||||
SAMLNameIDPolicyEnum,
|
||||
SAMLSource,
|
||||
SignatureAlgorithmEnum,
|
||||
SourcesApi,
|
||||
@@ -351,37 +351,37 @@ export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm<SAMLSo
|
||||
>
|
||||
<select class="pf-c-form-control">
|
||||
<option
|
||||
value=${NameIdPolicyEnum.UrnOasisNamesTcSaml20NameidFormatPersistent}
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatPersistent}
|
||||
?selected=${this.instance?.nameIdPolicy ===
|
||||
NameIdPolicyEnum.UrnOasisNamesTcSaml20NameidFormatPersistent}
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatPersistent}
|
||||
>
|
||||
${msg("Persistent")}
|
||||
</option>
|
||||
<option
|
||||
value=${NameIdPolicyEnum.UrnOasisNamesTcSaml11NameidFormatEmailAddress}
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatEmailAddress}
|
||||
?selected=${this.instance?.nameIdPolicy ===
|
||||
NameIdPolicyEnum.UrnOasisNamesTcSaml11NameidFormatEmailAddress}
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatEmailAddress}
|
||||
>
|
||||
${msg("Email address")}
|
||||
</option>
|
||||
<option
|
||||
value=${NameIdPolicyEnum.UrnOasisNamesTcSaml20NameidFormatWindowsDomainQualifiedName}
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatWindowsDomainQualifiedName}
|
||||
?selected=${this.instance?.nameIdPolicy ===
|
||||
NameIdPolicyEnum.UrnOasisNamesTcSaml20NameidFormatWindowsDomainQualifiedName}
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatWindowsDomainQualifiedName}
|
||||
>
|
||||
${msg("Windows")}
|
||||
</option>
|
||||
<option
|
||||
value=${NameIdPolicyEnum.UrnOasisNamesTcSaml11NameidFormatX509SubjectName}
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatX509SubjectName}
|
||||
?selected=${this.instance?.nameIdPolicy ===
|
||||
NameIdPolicyEnum.UrnOasisNamesTcSaml11NameidFormatX509SubjectName}
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml11NameidFormatX509SubjectName}
|
||||
>
|
||||
${msg("X509 Subject")}
|
||||
</option>
|
||||
<option
|
||||
value=${NameIdPolicyEnum.UrnOasisNamesTcSaml20NameidFormatTransient}
|
||||
value=${SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatTransient}
|
||||
?selected=${this.instance?.nameIdPolicy ===
|
||||
NameIdPolicyEnum.UrnOasisNamesTcSaml20NameidFormatTransient}
|
||||
SAMLNameIDPolicyEnum.UrnOasisNamesTcSaml20NameidFormatTransient}
|
||||
>
|
||||
${msg("Transient")}
|
||||
</option>
|
||||
|
||||
@@ -171,10 +171,6 @@ export function pluckErrorDetail(errorLike: unknown, fallback?: string): string
|
||||
ResponseErrorMessages[HTTPStatusCode.InternalServiceError],
|
||||
);
|
||||
|
||||
if (errorLike && typeof errorLike === "string") {
|
||||
return errorLike;
|
||||
}
|
||||
|
||||
if (!errorLike || typeof errorLike !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -35,24 +35,6 @@
|
||||
--ak-navbar--height: 7rem;
|
||||
}
|
||||
|
||||
.pf-c-form__group {
|
||||
--pf-c-form--m-horizontal__group-label--md--GridColumnWidth: minmax(max-content, 9.375rem);
|
||||
column-gap: var(--pf-global--spacer--md);
|
||||
}
|
||||
|
||||
.pf-c-form__group-label {
|
||||
user-select: none;
|
||||
padding-top: var(--pf-c-form--m-horizontal__group-label--md--PaddingTop);
|
||||
}
|
||||
|
||||
.pf-c-form__label[aria-required] .pf-c-form__label-text::after {
|
||||
content: "*";
|
||||
user-select: none;
|
||||
margin-left: var(--pf-c-form__label-required--MarginLeft);
|
||||
font-size: var(--pf-c-form__label-required--FontSize);
|
||||
color: var(--pf-c-form__label-required--Color);
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
|
||||
@@ -292,7 +292,7 @@ export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "au
|
||||
* @todo Can this be handled with a Lit Mixin?
|
||||
*/
|
||||
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
|
||||
const element = document.body.querySelector<T>("[data-test-id=interface-root]");
|
||||
const element = document.body.querySelector<T>("[data-ak-interface-root]");
|
||||
|
||||
if (!element) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { map } from "lit/directives/map.js";
|
||||
|
||||
export type DescriptionPair = [
|
||||
term: SlottedTemplateResult,
|
||||
desc: SlottedTemplateResult | undefined,
|
||||
];
|
||||
export type DescriptionRecord = { term: string; desc: SlottedTemplateResult | undefined };
|
||||
export type DescriptionDesc = string | TemplateResult | undefined | typeof nothing;
|
||||
export type DescriptionPair = [string, DescriptionDesc];
|
||||
export type DescriptionRecord = { term: string; desc: DescriptionDesc };
|
||||
|
||||
interface DescriptionConfig {
|
||||
horizontal?: boolean;
|
||||
|
||||
@@ -4,13 +4,11 @@ import { SlottedTemplateResult } from "../elements/types";
|
||||
|
||||
import { AKElement, type AKElementProps } from "#elements/Base";
|
||||
|
||||
import { ErrorProp } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
export interface HorizontalLightComponentProps<T> extends AKElementProps {
|
||||
name: string;
|
||||
@@ -20,7 +18,7 @@ export interface HorizontalLightComponentProps<T> extends AKElementProps {
|
||||
bighelp?: SlottedTemplateResult | SlottedTemplateResult[];
|
||||
hidden?: boolean;
|
||||
invalid?: boolean;
|
||||
errorMessages?: ErrorProp[];
|
||||
errorMessages?: string[];
|
||||
value?: T;
|
||||
inputHint?: string;
|
||||
}
|
||||
@@ -40,15 +38,13 @@ export abstract class HorizontalLightComponent<T>
|
||||
return this;
|
||||
}
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* The name attribute for the form element
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
public name!: string;
|
||||
name!: string;
|
||||
|
||||
/**
|
||||
* The label for the input control
|
||||
@@ -56,14 +52,14 @@ export abstract class HorizontalLightComponent<T>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
public label?: string;
|
||||
label?: string;
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public required = false;
|
||||
required = false;
|
||||
|
||||
/**
|
||||
* Help text to display below the form element. Optional
|
||||
@@ -71,40 +67,41 @@ export abstract class HorizontalLightComponent<T>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
public help = "";
|
||||
help = "";
|
||||
|
||||
/**
|
||||
* Extended help content. Optional. Expects to be a TemplateResult
|
||||
* @property
|
||||
*/
|
||||
@property({ type: Object })
|
||||
public bighelp?: TemplateResult | TemplateResult[];
|
||||
bighelp?: TemplateResult | TemplateResult[];
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public hidden = false;
|
||||
hidden = false;
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public invalid = false;
|
||||
invalid = false;
|
||||
|
||||
/**
|
||||
* @property
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public errorMessages?: ErrorProp[];
|
||||
errorMessages: string[] = [];
|
||||
|
||||
/**
|
||||
* @attribute
|
||||
* @property
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public value?: T;
|
||||
value?: T;
|
||||
|
||||
/**
|
||||
* Input hint.
|
||||
@@ -113,24 +110,14 @@ export abstract class HorizontalLightComponent<T>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "input-hint" })
|
||||
public inputHint?: string;
|
||||
inputHint?: string;
|
||||
|
||||
protected renderControl() {
|
||||
throw new Error("Must be implemented in a subclass");
|
||||
}
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
protected fieldID = IDGenerator.elementID().toString();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
/**
|
||||
* Render the control element, e.g. an input, textarea, select, etc.
|
||||
*/
|
||||
protected abstract renderControl(): SlottedTemplateResult;
|
||||
|
||||
protected renderHelp(): SlottedTemplateResult | SlottedTemplateResult[] {
|
||||
const bigHelp: SlottedTemplateResult[] = Array.isArray(this.bighelp)
|
||||
? this.bighelp
|
||||
@@ -144,20 +131,15 @@ export abstract class HorizontalLightComponent<T>
|
||||
|
||||
render() {
|
||||
return html`<ak-form-element-horizontal
|
||||
.fieldID=${this.fieldID}
|
||||
fieldID=${this.fieldID}
|
||||
label=${ifDefined(this.label)}
|
||||
?required=${this.required}
|
||||
?hidden=${this.hidden}
|
||||
name=${this.name}
|
||||
.errorMessages=${this.errorMessages}
|
||||
?invalid=${this.invalid}
|
||||
>
|
||||
<div slot="label" class="pf-c-form__group-label">
|
||||
${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label)}
|
||||
</div>
|
||||
|
||||
${this.renderControl()} ${this.renderHelp()}
|
||||
</ak-form-element-horizontal> `;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { pluckErrorDetail } from "#common/errors/network";
|
||||
|
||||
import { LitFC } from "#elements/types";
|
||||
|
||||
import { ErrorDetail, ValidationError } from "@goauthentik/api";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
|
||||
/**
|
||||
* An error originating from a form field.
|
||||
*/
|
||||
export type FieldErrorTuple = [fieldName: string, detail: string];
|
||||
|
||||
export type ErrorProp = string | Error | ErrorDetail | ValidationError | FieldErrorTuple;
|
||||
|
||||
export interface AKFormErrorsProps {
|
||||
errors?: ErrorProp[];
|
||||
}
|
||||
|
||||
function renderError(detail: string) {
|
||||
if (!detail) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
<span class="pf-c-form__helper-text-icon">
|
||||
<i class="fas fa-exclamation-circle" aria-hidden="true"></i> </span
|
||||
>${detail}
|
||||
</p>`;
|
||||
}
|
||||
|
||||
export const AKFormErrors: LitFC<AKFormErrorsProps> = ({ errors } = {}) => {
|
||||
if (!errors?.length) return nothing;
|
||||
|
||||
return errors.flatMap((error) => {
|
||||
if (Array.isArray(error) && error.length === 2) {
|
||||
const [fieldName, detail] = error;
|
||||
|
||||
return renderError(msg(str`${fieldName}: ${detail}`));
|
||||
}
|
||||
|
||||
return renderError(pluckErrorDetail(error));
|
||||
});
|
||||
};
|
||||
@@ -1,32 +1,67 @@
|
||||
import { HorizontalLightComponent } from "#components/HorizontalLightComponent";
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
|
||||
@customElement("ak-file-input")
|
||||
export class AkFileInput extends HorizontalLightComponent<string> {
|
||||
#inputRef = createRef<HTMLInputElement>();
|
||||
|
||||
get files(): Iterable<File> {
|
||||
return this.#inputRef.value?.files || [];
|
||||
export class AkFileInput extends AKElement {
|
||||
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
|
||||
// we're not actually using that and, for the meantime, we need the form handlers to be able to
|
||||
// find the children of this component.
|
||||
//
|
||||
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
|
||||
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
|
||||
// general.
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
#inputListener(ev: InputEvent) {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
@property({ type: String })
|
||||
name!: string;
|
||||
|
||||
@property({ type: String })
|
||||
label = "";
|
||||
|
||||
/*
|
||||
* The message to show next to the "current icon".
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
current = msg("Currently set to:");
|
||||
|
||||
@property({ type: String })
|
||||
value = "";
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@property({ type: String })
|
||||
help = "";
|
||||
|
||||
@query('input[type="file"]')
|
||||
input!: HTMLInputElement;
|
||||
|
||||
get files() {
|
||||
return this.input.files;
|
||||
}
|
||||
|
||||
public override renderControl() {
|
||||
return html` <input
|
||||
${ref(this.#inputRef)}
|
||||
id=${ifDefined(this.fieldID)}
|
||||
type="file"
|
||||
@input=${this.#inputListener}
|
||||
value=${ifDefined(this.value)}
|
||||
class="pf-c-form-control"
|
||||
?required=${ifDefined(this.required)}
|
||||
/>`;
|
||||
render() {
|
||||
const currentMsg =
|
||||
this.value && this.current
|
||||
? html` <p class="pf-c-form__helper-text">${this.current} ${this.value}</p> `
|
||||
: nothing;
|
||||
|
||||
return html`<ak-form-element-horizontal
|
||||
?required="${this.required}"
|
||||
label=${this.label}
|
||||
name=${this.name}
|
||||
>
|
||||
<input type="file" value="" class="pf-c-form-control" />
|
||||
${currentMsg}
|
||||
${this.help.trim() ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
|
||||
</ak-form-element-horizontal>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
type BaseProps = HorizontalLightComponentProps<string> &
|
||||
Pick<VisibilityToggleProps, "hideContentLabel" | "revealContentLabel">;
|
||||
Pick<VisibilityToggleProps, "showMessage" | "hideMessage">;
|
||||
|
||||
export interface AkHiddenTextInputProps extends BaseProps {
|
||||
revealed: boolean;
|
||||
@@ -53,13 +53,10 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public value = "";
|
||||
|
||||
/**
|
||||
* Whether the input value is visible.
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@@ -67,7 +64,7 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
public revealed = false;
|
||||
|
||||
/**
|
||||
* Placeholder text when no value is set.
|
||||
* Text for when the input has no set value
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
@@ -76,7 +73,16 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
public placeholder?: string;
|
||||
|
||||
/**
|
||||
* Specify kind of help the browser should try to provide.
|
||||
* Text for when the input has no set value
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
/**
|
||||
* Specify kind of help the browser should try to provide
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
@@ -89,37 +95,29 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "show-message" })
|
||||
public revealContentLabel = msg("Show field content");
|
||||
public showMessage = msg("Show field content");
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "hide-message" })
|
||||
public hideContentLabel = msg("Hide field content");
|
||||
public hideMessage = msg("Hide field content");
|
||||
|
||||
/**
|
||||
* A listener for the input event.
|
||||
*/
|
||||
protected inputListener = (event: InputEvent) => {
|
||||
this.value = (event.target as T).value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the input field.
|
||||
*
|
||||
* TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content LightDOM so the inner components actually inherit styling, the normal `css` options aren't available. Embedding styles is bad styling, and we'll fix it in the next style refresh.
|
||||
*/
|
||||
protected renderInputField() {
|
||||
const code = this.inputHint === "code";
|
||||
@query("#main > input")
|
||||
protected inputField!: T;
|
||||
|
||||
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
|
||||
// in the LightDom so the inner components actually inherit styling, the normal `css` options
|
||||
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
|
||||
// refresh.
|
||||
protected renderInputField(setValue: InputListener, code: boolean) {
|
||||
return html` <input
|
||||
part="input"
|
||||
id=${ifDefined(this.fieldID)}
|
||||
autocomplete=${ifDefined(this.autocomplete)}
|
||||
type=${this.revealed ? "text" : "password"}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
@input=${this.inputListener}
|
||||
@input=${setValue}
|
||||
value=${ifDefined(this.value)}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
class="${classMap({
|
||||
@@ -132,14 +130,19 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
}
|
||||
|
||||
protected override renderControl() {
|
||||
const code = this.inputHint === "code";
|
||||
const setValue: InputListener = (ev) => {
|
||||
this.value = (ev.target as T).value;
|
||||
};
|
||||
|
||||
return html` <div style="display: flex; gap: 0.25rem">
|
||||
${this.renderInputField()}
|
||||
${this.renderInputField(setValue, code)}
|
||||
<ak-visibility-toggle
|
||||
part="toggle"
|
||||
style="flex: 0 0 auto; align-self: flex-start"
|
||||
?open=${this.revealed}
|
||||
show-message=${this.revealContentLabel}
|
||||
hide-message=${this.hideContentLabel}
|
||||
show-message=${this.showMessage}
|
||||
hide-message=${this.hideMessage}
|
||||
@click=${() => (this.revealed = !this.revealed)}
|
||||
></ak-visibility-toggle>
|
||||
</div>`;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { AkHiddenTextInput, type AkHiddenTextInputProps } from "./ak-hidden-text-input.js";
|
||||
import {
|
||||
AkHiddenTextInput,
|
||||
type AkHiddenTextInputProps,
|
||||
InputListener,
|
||||
} from "./ak-hidden-text-input.js";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@@ -44,44 +48,43 @@ export class AkHiddenTextAreaInput
|
||||
extends AkHiddenTextInput<HTMLTextAreaElement>
|
||||
implements AkHiddenTextAreaInputProps
|
||||
{
|
||||
//#region Properties
|
||||
/* These are mostly just forwarded to the textarea component. */
|
||||
|
||||
/**
|
||||
* Number of visible text lines (rows)
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Number })
|
||||
public rows?: number = 4;
|
||||
rows?: number = 4;
|
||||
|
||||
/**
|
||||
* Nummber of visible character width (cols)
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: Number })
|
||||
public cols?: number;
|
||||
cols?: number;
|
||||
|
||||
/**
|
||||
* You want `resize=true` so that the resize value is visible in the component tag, activating the CSS associated with these values.
|
||||
*
|
||||
* @property
|
||||
* @attribute
|
||||
*
|
||||
* You want `resize=true` so that the resize value is visible in the component tag, activating
|
||||
* the CSS associated with these values.
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
public resize?: "none" | "both" | "horizontal" | "vertical" = "vertical";
|
||||
resize?: "none" | "both" | "horizontal" | "vertical" = "vertical";
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String })
|
||||
public wrap?: "soft" | "hard" | "off" = "soft";
|
||||
wrap?: "soft" | "hard" | "off" = "soft";
|
||||
|
||||
//#endregion
|
||||
@query("#main > textarea")
|
||||
protected inputField!: HTMLTextAreaElement;
|
||||
|
||||
get #visibleValue() {
|
||||
get displayValue() {
|
||||
const value = this.value ?? "";
|
||||
if (this.revealed) {
|
||||
return value;
|
||||
@@ -93,18 +96,18 @@ export class AkHiddenTextAreaInput
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
//#region Rendering
|
||||
|
||||
protected override renderInputField() {
|
||||
// TODO: Because of the peculiarities of how HorizontalLightComponent works, keeping its content
|
||||
// in the LightDom so the inner components actually inherit styling, the normal `css` options
|
||||
// aren't available. Embedding styles is bad styling, and we'll fix it in the next style
|
||||
// refresh.
|
||||
protected override renderInputField(setValue: InputListener, code: boolean) {
|
||||
const wrap = this.revealed ? this.wrap : "soft";
|
||||
const code = this.inputHint === "code";
|
||||
|
||||
return html`
|
||||
<textarea
|
||||
style="flex: 1 1 auto; min-width: 0;"
|
||||
part="textarea"
|
||||
@input=${this}
|
||||
id=${ifDefined(this.fieldID)}
|
||||
@input=${setValue}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
rows=${ifDefined(this.rows)}
|
||||
@@ -117,12 +120,10 @@ export class AkHiddenTextAreaInput
|
||||
spellcheck=${code ? "false" : "true"}
|
||||
?required=${this.required}
|
||||
>
|
||||
${this.#visibleValue}</textarea
|
||||
${this.displayValue}</textarea
|
||||
>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { LitFC } from "#elements/types";
|
||||
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
import type { LabelHTMLAttributes } from "react";
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
export interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export const AKLabel: LitFC<FormLabelProps> = (
|
||||
{ required, htmlFor, ...labelAttributes } = {},
|
||||
children,
|
||||
) => {
|
||||
if (!children) return nothing;
|
||||
|
||||
return html`<label
|
||||
class="pf-c-form__label"
|
||||
for=${ifDefined(htmlFor)}
|
||||
?aria-required=${required}
|
||||
${spread(labelAttributes)}
|
||||
>
|
||||
<span class="pf-c-form__label-text">${children}</span>
|
||||
</label>`;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { HorizontalLightComponent } from "./HorizontalLightComponent.js";
|
||||
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
|
||||
import { kebabCase } from "change-case";
|
||||
|
||||
import { html } from "lit";
|
||||
@@ -54,7 +56,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
|
||||
// Do not stop propagation of this event; it must be sent up the tree so that a parent
|
||||
// component, such as a custom forms manager, may receive it.
|
||||
#touchListener = (ev: Event) => {
|
||||
protected handleTouch(ev: Event) {
|
||||
this.value = this.input.value = slugify(this.input.value);
|
||||
|
||||
// Reset 'touched' status if the slug & target have been reset
|
||||
@@ -66,9 +68,10 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
if (ev && ev.target && ev.target instanceof HTMLInputElement) {
|
||||
this.#touched = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#slugify = (ev: Event) => {
|
||||
@bound
|
||||
protected slugify(ev: Event) {
|
||||
if (!(ev && ev.target && ev.target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
@@ -111,18 +114,18 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
this.#origin?.removeEventListener("input", this.#slugify);
|
||||
|
||||
if (this.#origin) {
|
||||
this.#origin.removeEventListener("input", this.slugify);
|
||||
}
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public override renderControl() {
|
||||
return html`<input
|
||||
id=${ifDefined(this.fieldID)}
|
||||
@input=${this.#touchListener}
|
||||
@input=${(ev: Event) => this.handleTouch(ev)}
|
||||
type="text"
|
||||
value=${ifDefined(this.value)}
|
||||
class="pf-c-form-control"
|
||||
@@ -140,7 +143,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
|
||||
this.#origin = rootNode.querySelector(this.source);
|
||||
}
|
||||
if (this.#origin) {
|
||||
this.#origin.addEventListener("input", this.#slugify);
|
||||
this.#origin.addEventListener("input", this.slugify);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ export class AkSwitchInput extends AKElement {
|
||||
const helpText = this.help.trim();
|
||||
|
||||
return html` <ak-form-element-horizontal name=${this.name} ?required=${this.required}>
|
||||
<div slot="label" class="pf-c-form__group-label"></div>
|
||||
|
||||
<label class="pf-c-switch" for="${this.#fieldID}">
|
||||
<input
|
||||
id="${this.#fieldID}"
|
||||
|
||||
@@ -8,26 +8,26 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
||||
@customElement("ak-text-input")
|
||||
export class AkTextInput extends HorizontalLightComponent<string> {
|
||||
@property({ type: String, reflect: true })
|
||||
public value = "";
|
||||
value = "";
|
||||
|
||||
@property({ type: String })
|
||||
public autocomplete?: AutoFill;
|
||||
autocomplete?: string;
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder?: string;
|
||||
placeholder?: string;
|
||||
|
||||
#inputListener(ev: InputEvent) {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
}
|
||||
renderControl() {
|
||||
const setValue = (ev: InputEvent) => {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
public override renderControl() {
|
||||
const code = this.inputHint === "code";
|
||||
|
||||
return html` <input
|
||||
type="text"
|
||||
role="textbox"
|
||||
id=${ifDefined(this.fieldID)}
|
||||
@input=${this.#inputListener}
|
||||
@input=${setValue}
|
||||
value=${ifDefined(this.value)}
|
||||
class="${classMap({
|
||||
"pf-c-form-control": true,
|
||||
|
||||
@@ -9,18 +9,15 @@ export class AkTextareaInput extends HorizontalLightComponent<string> {
|
||||
@property({ type: String, reflect: true })
|
||||
public value = "";
|
||||
|
||||
#inputListener = (ev: InputEvent) => {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
};
|
||||
|
||||
public override renderControl() {
|
||||
const code = this.inputHint === "code";
|
||||
|
||||
const setValue = (ev: InputEvent) => {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
};
|
||||
// Prevent the leading spaces added by Prettier's whitespace algo
|
||||
// prettier-ignore
|
||||
return html`<textarea
|
||||
id=${ifDefined(this.fieldID)}
|
||||
@input=${this.#inputListener}
|
||||
@input=${setValue}
|
||||
class="pf-c-form-control"
|
||||
?required=${this.required}
|
||||
name=${this.name}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class AkToggleGroup extends CustomEmitterElement(AKElement) {
|
||||
`,
|
||||
];
|
||||
|
||||
/**
|
||||
/*
|
||||
* The value (causes highlighting, value is returned)
|
||||
*
|
||||
* @attr
|
||||
|
||||
@@ -10,8 +10,8 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
export interface VisibilityToggleProps {
|
||||
open: boolean;
|
||||
disabled: boolean;
|
||||
revealContentLabel: string;
|
||||
hideContentLabel: string;
|
||||
showMessage: string;
|
||||
hideMessage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,19 +48,19 @@ export class VisibilityToggle extends AKElement implements VisibilityToggleProps
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "show-message" })
|
||||
revealContentLabel = msg("Show field content");
|
||||
showMessage = msg("Show field content");
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String, attribute: "hide-message" })
|
||||
hideContentLabel = msg("Hide field content");
|
||||
hideMessage = msg("Hide field content");
|
||||
|
||||
render() {
|
||||
const [label, icon] = this.open
|
||||
? [this.hideContentLabel, "fa-eye"]
|
||||
: [this.revealContentLabel, "fa-eye-slash"];
|
||||
? [this.hideMessage, "fa-eye"]
|
||||
: [this.showMessage, "fa-eye-slash"];
|
||||
|
||||
const onClick = (ev: PointerEvent) => {
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -49,11 +49,11 @@ A text-input field with a visibility control, so you can show/hide sensitive fie
|
||||
options: ["text", "code"],
|
||||
description: "Input type hint for styling and behavior",
|
||||
},
|
||||
revealContentLabel: {
|
||||
showMessage: {
|
||||
control: "text",
|
||||
description: "Custom message for show action",
|
||||
},
|
||||
hideContentLabel: {
|
||||
hideMessage: {
|
||||
control: "text",
|
||||
description: "Custom message for hide action",
|
||||
},
|
||||
@@ -78,8 +78,8 @@ const Template: Story = {
|
||||
placeholder=${ifDefined(args.placeholder)}
|
||||
?required=${args.required}
|
||||
input-hint=${ifDefined(args.inputHint)}
|
||||
show-message=${ifDefined(args.revealContentLabel)}
|
||||
hide-message=${ifDefined(args.hideContentLabel)}
|
||||
show-message=${ifDefined(args.showMessage)}
|
||||
hide-message=${ifDefined(args.hideMessage)}
|
||||
></ak-hidden-text-input>
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -52,11 +52,11 @@ A textarea input field with a visibility control, so you can show/hide sensitive
|
||||
options: ["text", "code"],
|
||||
description: "Input type hint for styling and behavior",
|
||||
},
|
||||
revealContentLabel: {
|
||||
showMessage: {
|
||||
control: "text",
|
||||
description: "Custom message for show action",
|
||||
},
|
||||
hideContentLabel: {
|
||||
hideMessage: {
|
||||
control: "text",
|
||||
description: "Custom message for hide action",
|
||||
},
|
||||
@@ -104,8 +104,8 @@ const Template: Story = {
|
||||
wrap=${ifDefined(args.wrap)}
|
||||
?required=${args.required}
|
||||
input-hint=${ifDefined(args.inputHint)}
|
||||
show-message=${ifDefined(args.revealContentLabel)}
|
||||
hide-message=${ifDefined(args.hideContentLabel)}
|
||||
show-message=${ifDefined(args.showMessage)}
|
||||
hide-message=${ifDefined(args.hideMessage)}
|
||||
></ak-hidden-textarea-input>
|
||||
`,
|
||||
};
|
||||
@@ -134,8 +134,8 @@ kPFn6jeMHyiq0Pqnf82T6M2EDuneMLzAgMBAAE=
|
||||
inputHint: "code",
|
||||
rows: 15,
|
||||
resize: "vertical",
|
||||
revealContentLabel: "Show certificate content",
|
||||
hideContentLabel: "Hide certificate content",
|
||||
showMessage: "Show certificate content",
|
||||
hideMessage: "Hide certificate content",
|
||||
autocomplete: "off",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ const metadata: Meta<VisibilityToggleProps> = {
|
||||
# Visibility Toggle Component
|
||||
|
||||
A straightforward two-state iconic button for toggling the visibility of sensitive content such as passwords, private keys, or other secret information.
|
||||
|
||||
|
||||
- Use for sensitive content that users might want to temporarily reveal
|
||||
- There are default hide/show messages for screen readers, but they can be overridden
|
||||
- Clients always handle the state
|
||||
@@ -33,12 +33,12 @@ A straightforward two-state iconic button for toggling the visibility of sensiti
|
||||
control: "boolean",
|
||||
description: "Whether the toggle is in the 'show' state (true) or 'hide' state (false)",
|
||||
},
|
||||
revealContentLabel: {
|
||||
showMessage: {
|
||||
control: "text",
|
||||
description:
|
||||
'Message for screen readers when in hide state (default: "Show field content")',
|
||||
},
|
||||
hideContentLabel: {
|
||||
hideMessage: {
|
||||
control: "text",
|
||||
description:
|
||||
'Message for screen readers when in show state (default: "Hide field content")',
|
||||
@@ -57,14 +57,14 @@ type Story = StoryObj<VisibilityToggle>;
|
||||
const Template: Story = {
|
||||
args: {
|
||||
open: false,
|
||||
revealContentLabel: "Show field content",
|
||||
hideContentLabel: "Hide field content",
|
||||
showMessage: "Show field content",
|
||||
hideMessage: "Hide field content",
|
||||
},
|
||||
render: (args) => html`
|
||||
<ak-visibility-toggle
|
||||
?open=${args.open}
|
||||
show-message=${ifDefined(args.revealContentLabel)}
|
||||
hide-message=${ifDefined(args.hideContentLabel)}
|
||||
show-message=${ifDefined(args.showMessage)}
|
||||
hide-message=${ifDefined(args.hideMessage)}
|
||||
@click=${(e: Event) => {
|
||||
const target = e.target as VisibilityToggle;
|
||||
target.open = !target.open;
|
||||
@@ -78,8 +78,8 @@ const Template: Story = {
|
||||
// Password field integration example
|
||||
export const PasswordFieldExample: Story = {
|
||||
args: {
|
||||
revealContentLabel: "Reveal password",
|
||||
hideContentLabel: "Conceal password",
|
||||
showMessage: "Reveal password",
|
||||
hideMessage: "Conceal password",
|
||||
},
|
||||
render: () => {
|
||||
let isVisible = false;
|
||||
|
||||
@@ -28,6 +28,6 @@ export abstract class Interface extends AKElement {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.dataset.testId = "interface-root";
|
||||
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ type ContentValue = SlottedTemplateResult | undefined;
|
||||
*/
|
||||
export function akLoadingOverlay(
|
||||
properties: ILoadingOverlay = {},
|
||||
content: string | ILoadingOverlayContent = {},
|
||||
content: ILoadingOverlayContent = {},
|
||||
) {
|
||||
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
|
||||
// slot-name.
|
||||
|
||||
@@ -46,7 +46,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
|
||||
/* The array of key/value pairs this pane is currently showing */
|
||||
@property({ type: Array })
|
||||
public readonly options?: DualSelectPair[];
|
||||
readonly options: DualSelectPair[] = [];
|
||||
|
||||
/**
|
||||
* A set (set being easy for lookups) of keys with all the pairs selected,
|
||||
@@ -54,7 +54,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
* can be marked and their clicks ignored.
|
||||
*/
|
||||
@property({ type: Object })
|
||||
public readonly selected: Set<string> = new Set();
|
||||
readonly selected: Set<string> = new Set();
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -75,17 +75,11 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
|
||||
//#region Refs
|
||||
|
||||
#listRef = createRef<HTMLDivElement>();
|
||||
|
||||
#scrollAnimationFrame = -1;
|
||||
|
||||
#scrollIntoView = (): void => {
|
||||
this.#listRef.value?.scrollTo(0, 0);
|
||||
};
|
||||
protected listRef = createRef<HTMLDivElement>();
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public overrideconnectedCallback() {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
for (const [attr, value] of hostAttributes) {
|
||||
@@ -95,11 +89,9 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
}
|
||||
}
|
||||
|
||||
protected override updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("options") && this.options?.length) {
|
||||
cancelAnimationFrame(this.#scrollAnimationFrame);
|
||||
|
||||
this.#scrollAnimationFrame = requestAnimationFrame(this.#scrollIntoView);
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("options")) {
|
||||
this.listRef.value?.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +118,10 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
this.toMove.add(key);
|
||||
}
|
||||
|
||||
const moved = [...this.toMove].sort();
|
||||
|
||||
this.dispatchCustomEvent(DualSelectEventType.MoveChanged, moved);
|
||||
this.dispatchCustomEvent(
|
||||
DualSelectEventType.MoveChanged,
|
||||
Array.from(this.toMove.values()).sort(),
|
||||
);
|
||||
|
||||
this.dispatchCustomEvent(DualSelectEventType.Move);
|
||||
|
||||
@@ -152,7 +145,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div ${ref(this.#listRef)} class="pf-c-dual-list-selector__menu">
|
||||
<div ${ref(this.listRef)} class="pf-c-dual-list-selector__menu">
|
||||
<ul class="pf-c-dual-list-selector__list">
|
||||
${map(this.options, ([key, label]) => {
|
||||
const selected = classMap({
|
||||
|
||||
@@ -100,11 +100,9 @@ export const globalVariables = css`
|
||||
--pf-c-dual-list-selector__list-item-row--BackgroundColor: var(
|
||||
--ak-dark-background-light-ish
|
||||
);
|
||||
|
||||
--pf-c-dual-list-selector__list-item-row--focus-within--BackgroundColor: var(
|
||||
--ak-dark-background-darker
|
||||
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
|
||||
--ak-dark-background-lighter;
|
||||
);
|
||||
|
||||
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
|
||||
--pf-global--BackgroundColor--400
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { groupOptions, isVisibleInScrollRegion } from "./utils.js";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
import type { GroupedOptions, SelectGroup, SelectOption, SelectOptions } from "#elements/types";
|
||||
import { randomId } from "#elements/utils/randomId";
|
||||
|
||||
@@ -15,7 +16,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
export interface IListSelect {
|
||||
options: SelectOptions;
|
||||
value?: string | null;
|
||||
value?: string;
|
||||
emptyOption?: string;
|
||||
}
|
||||
|
||||
@@ -67,23 +68,21 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
`,
|
||||
];
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* See the search options type, described in the `./types` file, for the relevant types.
|
||||
*
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
public set options(options: SelectOptions) {
|
||||
this.#options = groupOptions(options);
|
||||
set options(options: SelectOptions) {
|
||||
this._options = groupOptions(options);
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return this.#options;
|
||||
get options() {
|
||||
return this._options;
|
||||
}
|
||||
|
||||
#options!: GroupedOptions;
|
||||
_options!: GroupedOptions;
|
||||
|
||||
/**
|
||||
* The current value of the menu.
|
||||
@@ -91,7 +90,7 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
public value?: string | null = null;
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* The string representation that means an empty option. If not present, no empty option is
|
||||
@@ -100,57 +99,36 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
* @prop
|
||||
*/
|
||||
@property()
|
||||
public emptyOption?: string;
|
||||
emptyOption?: string;
|
||||
|
||||
// We have two different states that we're tracking in this component: the `value`, which is the
|
||||
// element that is currently selected according to the client, and the `index`, which is the
|
||||
// element that is being tracked for keyboard interaction. On a click, the index points to the
|
||||
// value element; on Keyup.Enter, the value becomes whatever the index points to.
|
||||
// value element; on Keydown.Enter, the value becomes whatever the index points to.
|
||||
@state()
|
||||
protected indexOfFocusedItem = 0;
|
||||
indexOfFocusedItem = 0;
|
||||
|
||||
@query("#ak-list-select-list")
|
||||
protected ul!: HTMLUListElement;
|
||||
|
||||
//#endregion
|
||||
ul!: HTMLUListElement;
|
||||
|
||||
get json(): string {
|
||||
return this.value ?? "";
|
||||
}
|
||||
|
||||
//#region Lifecycle
|
||||
public constructor() {
|
||||
super();
|
||||
this.addEventListener("focus", this.onFocus);
|
||||
this.addEventListener("blur", this.onBlur);
|
||||
}
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.addEventListener("focus", this.#focusListener);
|
||||
this.addEventListener("blur", this.#blurListener);
|
||||
|
||||
this.setAttribute("data-ouia-component-type", "ak-menu-select");
|
||||
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
|
||||
this.setIndexOfFocusedItemFromValue();
|
||||
this.highlightFocusedItem();
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.removeEventListener("focus", this.#focusListener);
|
||||
this.removeEventListener("blur", this.#blurListener);
|
||||
}
|
||||
|
||||
public override performUpdate() {
|
||||
this.removeAttribute("data-ouia-component-safe");
|
||||
super.performUpdate();
|
||||
}
|
||||
|
||||
public override updated(changed: PropertyValueMap<this>) {
|
||||
super.updated(changed);
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
public get hasFocus() {
|
||||
return this.renderRoot.contains(document.activeElement) || document.activeElement === this;
|
||||
}
|
||||
@@ -193,29 +171,30 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
currentElement.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
}
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
#focusListener = () => {
|
||||
@bound
|
||||
onFocus() {
|
||||
// Allow the event to propagate.
|
||||
this.currentElement?.focus();
|
||||
this.addEventListener("keyup", this.#delegateKey);
|
||||
};
|
||||
this.addEventListener("keydown", this.onKeydown);
|
||||
}
|
||||
|
||||
#blurListener = () => {
|
||||
@bound
|
||||
onBlur() {
|
||||
// Allow the event to propagate.
|
||||
this.removeEventListener("keyup", this.#delegateKey);
|
||||
this.removeEventListener("keydown", this.onKeydown);
|
||||
this.indexOfFocusedItem = 0;
|
||||
};
|
||||
}
|
||||
|
||||
#clickListener = (value: string | null) => {
|
||||
@bound
|
||||
onClick(value: string | undefined) {
|
||||
// let the click through, but include the change event.
|
||||
this.value = value;
|
||||
|
||||
this.setIndexOfFocusedItemFromValue();
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
||||
};
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
}
|
||||
|
||||
#delegateKey = (event: KeyboardEvent) => {
|
||||
@bound
|
||||
onKeydown(event: KeyboardEvent) {
|
||||
const key = event.key;
|
||||
const lastItem = this.displayedElements.length - 1;
|
||||
const current = this.indexOfFocusedItem;
|
||||
@@ -229,9 +208,8 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
|
||||
const setValueAndDispatch = () => {
|
||||
event.preventDefault();
|
||||
this.value = this.currentElement?.getAttribute("value");
|
||||
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
||||
this.value = this.currentElement?.getAttribute("value") ?? undefined;
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
};
|
||||
|
||||
const pageBy = (direction: number) => {
|
||||
@@ -251,9 +229,17 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
.with({ key: "End" }, () => updateIndex(lastItem))
|
||||
.with({ key: " " }, () => setValueAndDispatch())
|
||||
.with({ key: "Enter" }, () => setValueAndDispatch());
|
||||
};
|
||||
}
|
||||
|
||||
//#region Render
|
||||
public override performUpdate() {
|
||||
this.removeAttribute("data-ouia-component-safe");
|
||||
super.performUpdate();
|
||||
}
|
||||
|
||||
public override updated(changed: PropertyValueMap<this>) {
|
||||
super.updated(changed);
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
private renderEmptyMenuItem() {
|
||||
return html`<li role="option" class="ak-select-item" part="ak-list-select-option">
|
||||
@@ -261,7 +247,7 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
class="pf-c-dropdown__menu-item"
|
||||
role="option"
|
||||
tabindex="0"
|
||||
@click=${() => this.#clickListener(null)}
|
||||
@click=${() => this.onClick(undefined)}
|
||||
part="ak-list-select-button"
|
||||
>
|
||||
${this.emptyOption}
|
||||
@@ -282,7 +268,7 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
class="pf-c-dropdown__menu-item pf-m-description"
|
||||
value="${value}"
|
||||
tabindex="0"
|
||||
@click=${() => this.#clickListener(value)}
|
||||
@click=${() => this.onClick(value)}
|
||||
part="ak-list-select-button"
|
||||
>
|
||||
<div class="pf-c-dropdown__menu-item-main" part="ak-list-select-label">
|
||||
@@ -330,15 +316,13 @@ export class ListSelect extends AKElement implements IListSelect {
|
||||
tabindex="0"
|
||||
part="ak-list-select"
|
||||
>
|
||||
${this.emptyOption ? this.renderEmptyMenuItem() : nothing}
|
||||
${this.#options.grouped
|
||||
? this.renderMenuGroups(this.#options.options)
|
||||
: this.renderMenuItems(this.#options.options)}
|
||||
${this.emptyOption === undefined ? nothing : this.renderEmptyMenuItem()}
|
||||
${this._options.grouped
|
||||
? this.renderMenuGroups(this._options.options)
|
||||
: this.renderMenuItems(this._options.options)}
|
||||
</ul>
|
||||
</div> `;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -51,13 +51,13 @@ export class ModalOrchestrationController implements ReactiveController {
|
||||
#knownModals: ModalElement[] = [];
|
||||
|
||||
public hostConnected() {
|
||||
window.addEventListener("keyup", this.#keyupListener);
|
||||
window.addEventListener("keyup", this.handleKeyup);
|
||||
window.addEventListener("ak-modal-show", this.#addModal);
|
||||
window.addEventListener("ak-modal-hide", this.closeModal);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
window.removeEventListener("keyup", this.#keyupListener);
|
||||
window.removeEventListener("keyup", this.handleKeyup);
|
||||
window.removeEventListener("ak-modal-show", this.#addModal);
|
||||
window.removeEventListener("ak-modal-hide", this.closeModal);
|
||||
}
|
||||
@@ -108,16 +108,8 @@ export class ModalOrchestrationController implements ReactiveController {
|
||||
this.#knownModals = knownModals;
|
||||
};
|
||||
|
||||
#keyupListener = ({ key, defaultPrevented }: KeyboardEvent) => {
|
||||
handleKeyup = ({ key }: KeyboardEvent) => {
|
||||
// The latter handles Firefox 37 and earlier.
|
||||
if (key !== "Escape" && key !== "Esc") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow an event listener within the modal to prevent
|
||||
// our default behavior of closing the modal.
|
||||
if (defaultPrevented) return;
|
||||
|
||||
if (key === "Escape" || key === "Esc") {
|
||||
this.#removeTopmostModal();
|
||||
}
|
||||
|
||||
70
web/src/elements/forms/FormElement.ts
Normal file
70
web/src/elements/forms/FormElement.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { ErrorDetail } from "@goauthentik/api";
|
||||
|
||||
import { CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
/**
|
||||
* This is used in two places outside of Flow, and in both cases is used primarily to
|
||||
* display content, not take input. It displays the TOTP QR code, and the static
|
||||
* recovery tokens. But it's used a lot in Flow.
|
||||
*/
|
||||
|
||||
@customElement("ak-form-element")
|
||||
export class FormElement extends AKElement {
|
||||
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl];
|
||||
|
||||
@property()
|
||||
label?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
set errors(value: ErrorDetail[] | undefined) {
|
||||
this._errors = value;
|
||||
const hasError = (value || []).length > 0;
|
||||
this.querySelectorAll("input").forEach((input) => {
|
||||
input.setAttribute("aria-invalid", hasError.toString());
|
||||
});
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
_errors?: ErrorDetail[];
|
||||
|
||||
updated(): void {
|
||||
this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach((input) => {
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label">
|
||||
<span class="pf-c-form__label-text">${this.label}</span>
|
||||
${this.required
|
||||
? html`<span class="pf-c-form__label-required" aria-hidden="true">*</span>`
|
||||
: html``}
|
||||
</label>
|
||||
<slot></slot>
|
||||
${(this._errors || []).map((error) => {
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error">
|
||||
<span class="pf-c-form__helper-text-icon">
|
||||
<i class="fas fa-exclamation-circle" aria-hidden="true"></i> </span
|
||||
>${error.string}
|
||||
</p>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-form-element": FormElement;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
@@ -20,6 +20,15 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
*/
|
||||
@customElement("ak-form-group")
|
||||
export class AKFormGroup extends AKElement {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public open = false;
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public label = msg("Details");
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public description?: string;
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
PFBase,
|
||||
PFForm,
|
||||
@@ -37,6 +46,27 @@ export class AKFormGroup extends AKElement {
|
||||
}
|
||||
|
||||
details {
|
||||
&::details-content {
|
||||
height: 0;
|
||||
overflow: clip;
|
||||
transition-behavior: normal, allow-discrete;
|
||||
transition-duration: var(--pf-global--TransitionDuration);
|
||||
transition-timing-function: var(--pf-global--TimingFunction);
|
||||
transition-property: height, content-visibility;
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition-duration: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (interpolate-size: allow-keywords) {
|
||||
interpolate-size: allow-keywords;
|
||||
|
||||
&[open]::details-content {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
&::details-content {
|
||||
padding-inline-start: var(
|
||||
--pf-c-form__field-group--GridTemplateColumns--toggle
|
||||
@@ -72,39 +102,12 @@ export class AKFormGroup extends AKElement {
|
||||
`,
|
||||
];
|
||||
|
||||
//region Properties
|
||||
formRef = createRef<HTMLFormElement>();
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public open = false;
|
||||
scrollAnimationFrame = -1;
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public label = msg("Details");
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public description?: string;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>): void {
|
||||
const previousOpen = changedProperties.get("open");
|
||||
|
||||
if (typeof previousOpen !== "boolean") return;
|
||||
|
||||
if (this.open && this.open !== previousOpen) {
|
||||
cancelAnimationFrame(this.#scrollAnimationFrame);
|
||||
|
||||
this.#scrollAnimationFrame = requestAnimationFrame(this.#scrollIntoView);
|
||||
}
|
||||
}
|
||||
|
||||
#detailsRef = createRef<HTMLDetailsElement>();
|
||||
|
||||
#scrollAnimationFrame = -1;
|
||||
|
||||
#scrollIntoView = (): void => {
|
||||
this.#detailsRef.value?.scrollIntoView({
|
||||
scrollIntoView = (): void => {
|
||||
this.formRef.value?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
@@ -114,16 +117,19 @@ export class AKFormGroup extends AKElement {
|
||||
*/
|
||||
public toggle = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
cancelAnimationFrame(this.scrollAnimationFrame);
|
||||
|
||||
this.open = !this.open;
|
||||
};
|
||||
|
||||
//#region Render
|
||||
if (this.open) {
|
||||
this.scrollAnimationFrame = requestAnimationFrame(this.scrollIntoView);
|
||||
}
|
||||
};
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<details
|
||||
${ref(this.#detailsRef)}
|
||||
${ref(this.formRef)}
|
||||
?open=${this.open}
|
||||
?aria-expanded="${this.open}"
|
||||
role="group"
|
||||
@@ -161,8 +167,6 @@ export class AKFormGroup extends AKElement {
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { isControlElement } from "#elements/AkControlElement";
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { AKFormGroup } from "#elements/forms/FormGroup";
|
||||
import { isNameableElement } from "#elements/utils/inputs";
|
||||
|
||||
import { AKFormErrors, ErrorProp } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
@@ -33,6 +30,22 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
* being very few unique uses.
|
||||
*/
|
||||
|
||||
const isAkControl = (el: unknown): boolean =>
|
||||
el instanceof HTMLElement &&
|
||||
"dataset" in el &&
|
||||
el.dataset instanceof DOMStringMap &&
|
||||
"akControl" in el.dataset;
|
||||
|
||||
const nameables = new Set([
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"ak-codemirror",
|
||||
"ak-chip-group",
|
||||
"ak-search-select",
|
||||
"ak-radio",
|
||||
]);
|
||||
|
||||
@customElement("ak-form-element-horizontal")
|
||||
export class HorizontalFormElement extends AKElement {
|
||||
static styles: CSSResult[] = [
|
||||
@@ -46,40 +59,41 @@ export class HorizontalFormElement extends AKElement {
|
||||
var(--pf-c-form--m-horizontal__group-label--md--GridColumnWidth)
|
||||
var(--pf-c-form--m-horizontal__group-control--md--GridColumnWidth);
|
||||
}
|
||||
|
||||
.pf-c-form__group-label {
|
||||
padding-top: var(--pf-c-form--m-horizontal__group-label--md--PaddingTop);
|
||||
}
|
||||
|
||||
.pf-c-form__label[aria-required] .pf-c-form__label-text::after {
|
||||
content: "*";
|
||||
user-select: none;
|
||||
margin-left: var(--pf-c-form__label-required--MarginLeft);
|
||||
font-size: var(--pf-c-form__label-required--FontSize);
|
||||
color: var(--pf-c-form__label-required--Color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
public fieldID?: string;
|
||||
|
||||
/**
|
||||
* The label for the input control
|
||||
* @property
|
||||
* @attribute
|
||||
* @deprecated Labels cannot associate with inputs across DOM roots. Use the slotted `label` element instead.
|
||||
*/
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
public label = "";
|
||||
|
||||
@property({ type: Boolean })
|
||||
public required?: boolean;
|
||||
public required = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public errorMessages?: ErrorProp[];
|
||||
public errorMessages: string[] | string[][] = [];
|
||||
|
||||
#invalid = false;
|
||||
_invalid = false;
|
||||
|
||||
/* If this property changes, we want to make sure the parent control is "opened" so
|
||||
* that users can see the change.[1]
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
set invalid(v: boolean) {
|
||||
this.#invalid = v;
|
||||
this._invalid = v;
|
||||
// check if we're in a form group, and expand that form group
|
||||
const parent = this.parentElement?.parentElement;
|
||||
|
||||
@@ -88,64 +102,80 @@ export class HorizontalFormElement extends AKElement {
|
||||
}
|
||||
}
|
||||
get invalid(): boolean {
|
||||
return this.#invalid;
|
||||
return this._invalid;
|
||||
}
|
||||
|
||||
@property({ type: String })
|
||||
public name?: string;
|
||||
public name = "";
|
||||
|
||||
//#endregion
|
||||
@property({
|
||||
type: String,
|
||||
attribute: "flow-direction",
|
||||
})
|
||||
public flowDirection: "row" | "column" = "column";
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public override firstUpdated(): void {
|
||||
firstUpdated(): void {
|
||||
this.updated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that all inputs have a name attribute.
|
||||
*
|
||||
* TODO: Swap with `HTMLElement.prototype.attachInternals`.
|
||||
*/
|
||||
public override updated(): void {
|
||||
// If we don't have a name, we can't do anything.
|
||||
if (!this.name) return;
|
||||
updated(): void {
|
||||
this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach((input) => {
|
||||
input.focus();
|
||||
});
|
||||
this.querySelectorAll("*").forEach((input) => {
|
||||
if (isAkControl(input) && !input.getAttribute("name")) {
|
||||
input.setAttribute("name", this.name);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of this.querySelectorAll("*")) {
|
||||
// Is this element capable of being named?
|
||||
if (!isControlElement(element) && !isNameableElement(element)) continue;
|
||||
// And does the element already match the name?
|
||||
if (element.getAttribute("name") === this.name) continue;
|
||||
|
||||
element.setAttribute("name", this.name);
|
||||
}
|
||||
if (nameables.has(input.tagName.toLowerCase())) {
|
||||
input.setAttribute("name", this.name);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
render(): TemplateResult {
|
||||
this.updated();
|
||||
|
||||
return html`<div class="pf-c-form__group" role="group">
|
||||
${this.label
|
||||
? html`<div class="pf-c-form__group-label">
|
||||
${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label)}
|
||||
</div>`
|
||||
: nothing}
|
||||
<slot name="label"></slot>
|
||||
|
||||
return html`<div
|
||||
class="pf-c-form__group"
|
||||
role="group"
|
||||
aria-label="${this.label}"
|
||||
data-flow-direction="${this.flowDirection}"
|
||||
>
|
||||
<div class="pf-c-form__group-label">
|
||||
<label
|
||||
id="group-label"
|
||||
class="pf-c-form__label"
|
||||
?aria-required=${this.required}
|
||||
for="${ifDefined(this.fieldID)}"
|
||||
>
|
||||
<span class="pf-c-form__label-text">${this.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="pf-c-form__group-control">
|
||||
<slot class="pf-c-form__horizontal-group"></slot>
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
${AKFormErrors({ errors: this.errorMessages })}
|
||||
${this.errorMessages.map((message) => {
|
||||
if (message instanceof Object) {
|
||||
return html`${Object.entries(message).map(([field, errMsg]) => {
|
||||
return html`<p
|
||||
class="pf-c-form__helper-text pf-m-error"
|
||||
aria-live="polite"
|
||||
>
|
||||
${msg(str`${field}: ${errMsg}`)}
|
||||
</p>`;
|
||||
})}`;
|
||||
}
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
${message}
|
||||
</p>`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -22,140 +22,82 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
type Group<T> = [string, T[]];
|
||||
|
||||
export interface ISearchSelectBase<T> {
|
||||
blankable?: boolean;
|
||||
blankable: boolean;
|
||||
query?: string;
|
||||
objects?: T[];
|
||||
selectedObject: T | null;
|
||||
selectedObject?: T;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
placeholder: string;
|
||||
emptyOption: string;
|
||||
}
|
||||
|
||||
export abstract class SearchSelectBase<T>
|
||||
extends AkControlElement<string>
|
||||
implements ISearchSelectBase<T>
|
||||
{
|
||||
export class SearchSelectBase<T> extends AkControlElement<string> implements ISearchSelectBase<T> {
|
||||
static styles = [PFBase];
|
||||
|
||||
//#region Properties
|
||||
// A function which takes the query state object (accepting that it may be empty) and returns a
|
||||
// new collection of objects.
|
||||
fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
|
||||
/**
|
||||
* A function which takes the query state object (accepting that it may be empty)
|
||||
* and returns a
|
||||
* new collection of objects.
|
||||
*/
|
||||
public abstract fetchObjects: (query?: string) => Promise<T[]>;
|
||||
// A function passed to this object that extracts a string representation of items of the
|
||||
// collection under search.
|
||||
renderElement!: (element: T) => string;
|
||||
|
||||
/**
|
||||
* A function passed to this object that extracts a string representation of items of the
|
||||
* collection under search.
|
||||
*/
|
||||
public abstract renderElement: (element: T) => string;
|
||||
// A function passed to this object that extracts an HTML representation of additional
|
||||
// information for items of the collection under search.
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
|
||||
/**
|
||||
* A function passed to this object that extracts an HTML representation of additional
|
||||
* information for items of the collection under search.
|
||||
*/
|
||||
public abstract renderDescription?: (element: T) => string | TemplateResult;
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
value!: (element: T | undefined) => string;
|
||||
|
||||
/**
|
||||
* A function which returns the currently selected object's primary key, used for serialization
|
||||
* into forms.
|
||||
*/
|
||||
public abstract value: (element: T | null) => string;
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
// fetching the data; sets an initial default value.
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
|
||||
/**
|
||||
* A function passed to this object that determines an object in the collection under search
|
||||
* should be automatically selected. Only used when the search itself is responsible for
|
||||
* fetching the data; sets an initial default value.
|
||||
*/
|
||||
public abstract selected?: (element: T, elements: T[]) => boolean;
|
||||
|
||||
/**
|
||||
* A function passed to this object (or using the default below) that groups objects in the
|
||||
* collection under search into categories.
|
||||
*/
|
||||
public groupBy: (items: T[]) => [string, T[]][] = (items) => {
|
||||
return groupBy(items, () => "");
|
||||
// A function passed to this object (or using the default below) that groups objects in the
|
||||
// collection under search into categories.
|
||||
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
|
||||
return groupBy(items, () => {
|
||||
return "";
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether or not the dropdown component can be left blank
|
||||
* @property
|
||||
* @attr
|
||||
*/
|
||||
// Whether or not the dropdown component can be left blank
|
||||
@property({ type: Boolean })
|
||||
public blankable?: boolean;
|
||||
blankable = false;
|
||||
|
||||
/**
|
||||
* An initial string to filter the search contents,
|
||||
* and the value of the input which further serves to restrict the search.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String })
|
||||
public query?: string;
|
||||
// An initial string to filter the search contents, and the value of the input which further
|
||||
// serves to restrict the search
|
||||
@property()
|
||||
query?: string;
|
||||
|
||||
// The objects currently available under search
|
||||
@property({ attribute: false })
|
||||
public objects?: T[];
|
||||
objects?: T[];
|
||||
|
||||
/**
|
||||
* The currently selected object.
|
||||
* @property
|
||||
*/
|
||||
// The currently selected object
|
||||
@property({ attribute: false })
|
||||
public selectedObject: T | null = null;
|
||||
selectedObject?: T;
|
||||
|
||||
/**
|
||||
* Used to inform the form of the name of the object
|
||||
* @property
|
||||
*/
|
||||
// Used to inform the form of the name of the object
|
||||
@property()
|
||||
public name?: string;
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
protected fieldID?: string;
|
||||
|
||||
/**
|
||||
* Used to inform the form of the input label.
|
||||
* @property
|
||||
*/
|
||||
// The textual placeholder for the search's <input> object, if currently empty. Used as the
|
||||
// native <input> object's `placeholder` field.
|
||||
@property()
|
||||
public label?: string;
|
||||
placeholder: string = msg("Select an object.");
|
||||
|
||||
/**
|
||||
* The textual placeholder for the search's <input> object, if currently empty.
|
||||
*
|
||||
* Used as the native <input> object's `placeholder` field.
|
||||
* @property
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public placeholder?: string = msg("Select an object.");
|
||||
// A textual string representing "The user has affirmed they want to leave the selection blank."
|
||||
// Only used if `blankable` above is true.
|
||||
@property()
|
||||
emptyOption = "---------";
|
||||
|
||||
/**
|
||||
* A textual string representing "The user has affirmed they want to leave the selection blank."
|
||||
* Only used if `blankable` above is true.
|
||||
*
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String })
|
||||
public emptyOption = "---------";
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region State
|
||||
|
||||
#loading = false;
|
||||
isFetchingData = false;
|
||||
|
||||
@state()
|
||||
protected error?: APIError;
|
||||
|
||||
//#endregion
|
||||
error?: APIError;
|
||||
|
||||
public toForm(): string {
|
||||
if (!this.objects) {
|
||||
@@ -168,7 +110,7 @@ export abstract class SearchSelectBase<T>
|
||||
return this.toForm();
|
||||
}
|
||||
|
||||
protected dispatchChangeEvent(value: T | null) {
|
||||
protected dispatchChangeEvent(value: T | undefined) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ak-change", {
|
||||
composed: true,
|
||||
@@ -179,27 +121,26 @@ export abstract class SearchSelectBase<T>
|
||||
}
|
||||
|
||||
public async updateData() {
|
||||
if (this.#loading) {
|
||||
if (this.isFetchingData) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.#loading = true;
|
||||
this.isFetchingData = true;
|
||||
this.dispatchEvent(new Event("loading"));
|
||||
|
||||
return this.fetchObjects(this.query)
|
||||
.then((nextObjects) => {
|
||||
const selectedObject = nextObjects.find((obj) => this.selected?.(obj, nextObjects));
|
||||
|
||||
if (selectedObject) {
|
||||
this.selectedObject = selectedObject;
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
nextObjects.forEach((obj) => {
|
||||
if (this.selected && this.selected(obj, nextObjects || [])) {
|
||||
this.selectedObject = obj;
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
});
|
||||
|
||||
this.objects = nextObjects;
|
||||
this.#loading = false;
|
||||
this.isFetchingData = false;
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
this.#loading = false;
|
||||
this.isFetchingData = false;
|
||||
this.objects = undefined;
|
||||
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
@@ -222,11 +163,10 @@ export abstract class SearchSelectBase<T>
|
||||
this.removeEventListener(EVENT_REFRESH, this.updateData);
|
||||
}
|
||||
|
||||
#searchListener = (event: InputEvent) => {
|
||||
private onSearch(event: InputEvent) {
|
||||
const value = (event.target as SearchSelectView).rawValue;
|
||||
|
||||
if (!value) {
|
||||
this.selectedObject = null;
|
||||
if (value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -234,23 +174,19 @@ export abstract class SearchSelectBase<T>
|
||||
this.updateData()?.then(() => {
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private onSelect(event: InputEvent) {
|
||||
const value = (event.target as SearchSelectView).value;
|
||||
|
||||
if (!value) {
|
||||
this.selectedObject = null;
|
||||
this.dispatchChangeEvent(null);
|
||||
|
||||
if (value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
this.dispatchChangeEvent(undefined);
|
||||
return;
|
||||
}
|
||||
const selected = this.objects?.find((obj) => this.value(obj) === value) || null;
|
||||
|
||||
const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === value);
|
||||
if (!selected) {
|
||||
console.warn(`ak-search-select: No corresponding object found for value (${value}`);
|
||||
}
|
||||
|
||||
this.selectedObject = selected;
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
@@ -319,26 +255,24 @@ export abstract class SearchSelectBase<T>
|
||||
|
||||
return html`<ak-search-select-view
|
||||
managed
|
||||
.fieldID=${this.fieldID}
|
||||
.options=${options}
|
||||
value=${ifDefined(value)}
|
||||
?blankable=${this.blankable}
|
||||
label=${ifDefined(this.label)}
|
||||
name=${ifDefined(this.name)}
|
||||
placeholder=${this.placeholder}
|
||||
emptyOption=${ifDefined(this.blankable ? this.emptyOption : undefined)}
|
||||
@input=${this.#searchListener}
|
||||
@input=${this.onSearch}
|
||||
@change=${this.onSelect}
|
||||
></ak-search-select-view> `;
|
||||
}
|
||||
|
||||
public override updated(changed: PropertyValues<this>) {
|
||||
if (!this.#loading && changed.has("objects")) {
|
||||
if (!this.isFetchingData && changed.has("objects")) {
|
||||
this.dispatchEvent(new Event("ready"));
|
||||
}
|
||||
// It is not safe for automated tests to interact with this component while it is fetching
|
||||
// data.
|
||||
if (!this.#loading) {
|
||||
if (!this.isFetchingData) {
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface ISearchSelectApi<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | null) => string;
|
||||
value: (element: T | undefined) => string;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy?: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
@@ -47,26 +47,18 @@ export interface ISearchSelectEz<T> extends ISearchSelectBase<T> {
|
||||
export class SearchSelectEz<T> extends SearchSelectBase<T> implements ISearchSelectEz<T> {
|
||||
static styles = [...SearchSelectBase.styles];
|
||||
|
||||
public fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
public renderElement!: (element: T) => string;
|
||||
public renderDescription?: ((element: T) => string | TemplateResult) | undefined;
|
||||
public value!: (element: T | null) => string;
|
||||
public selected?: ((element: T, elements: T[]) => boolean) | undefined;
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
public config!: ISearchSelectApi<T>;
|
||||
config!: ISearchSelectApi<T>;
|
||||
|
||||
public override connectedCallback() {
|
||||
connectedCallback() {
|
||||
this.fetchObjects = this.config.fetchObjects;
|
||||
this.renderElement = this.config.renderElement;
|
||||
this.renderDescription = this.config.renderDescription;
|
||||
this.value = this.config.value;
|
||||
this.selected = this.config.selected;
|
||||
|
||||
if (this.config.groupBy) {
|
||||
if (this.config.groupBy !== undefined) {
|
||||
this.groupBy = this.config.groupBy;
|
||||
}
|
||||
|
||||
super.connectedCallback();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import { findFlatOptions, findOptionsSubset, groupOptions, optionsToFlat } from
|
||||
|
||||
import { ListSelect } from "#elements/ak-list-select/ak-list-select";
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
import type { GroupedOptions, SelectOption, SelectOptions } from "#elements/types";
|
||||
import { randomId } from "#elements/utils/randomId";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html, nothing, PropertyValues } from "lit";
|
||||
import { html, nothing, PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
||||
@@ -69,9 +70,7 @@ export interface ISearchSelectView {
|
||||
*/
|
||||
@customElement("ak-search-select-view")
|
||||
export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
static styles: CSSResult[] = [PFBase, PFForm, PFFormControl, PFSelect];
|
||||
|
||||
//#region Properties
|
||||
static styles = [PFBase, PFForm, PFFormControl, PFSelect];
|
||||
|
||||
/**
|
||||
* The options collection. The simplest variant is just [key, label, optional<description>]. See
|
||||
@@ -80,16 +79,16 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
public set options(options: SelectOptions) {
|
||||
this.#options = groupOptions(options);
|
||||
this.#flatOptions = optionsToFlat(this.#options);
|
||||
set options(options: SelectOptions) {
|
||||
this._options = groupOptions(options);
|
||||
this.flatOptions = optionsToFlat(this._options);
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return this.#options;
|
||||
get options() {
|
||||
return this._options;
|
||||
}
|
||||
|
||||
#options!: GroupedOptions;
|
||||
_options!: GroupedOptions;
|
||||
|
||||
/**
|
||||
* The current value. Must be one of the keys in the options group above.
|
||||
@@ -97,7 +96,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @prop
|
||||
*/
|
||||
@property({ type: String, reflect: true })
|
||||
public value?: string;
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* Whether or not the dropdown is open
|
||||
@@ -105,7 +104,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public open = false;
|
||||
open = false;
|
||||
|
||||
/**
|
||||
* If set to true, this object MAY return undefined in no value is passed in and none is set
|
||||
@@ -114,7 +113,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
public blankable = false;
|
||||
blankable = false;
|
||||
|
||||
/**
|
||||
* If not managed, make the matcher case-sensitive during interaction. If managed,
|
||||
@@ -123,23 +122,15 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "case-sensitive" })
|
||||
public caseSensitive = false;
|
||||
caseSensitive = false;
|
||||
|
||||
/**
|
||||
* The name of the input, for forms.
|
||||
* The name of the input, for forms
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public name?: string;
|
||||
|
||||
/**
|
||||
* The label of the input, for forms.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* The textual placeholder for the search's <input> object, if currently empty. Used as the
|
||||
@@ -148,14 +139,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
public placeholder: string = msg("Select an object.");
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
protected fieldID?: string;
|
||||
placeholder: string = msg("Select an object.");
|
||||
|
||||
/**
|
||||
* If true, the component only sends an input message up to a parent component. If false, the
|
||||
@@ -165,7 +149,7 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
*@attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
public managed = false;
|
||||
managed = false;
|
||||
|
||||
/**
|
||||
* A textual string representing "The user has affirmed they want to leave the selection blank."
|
||||
@@ -174,50 +158,36 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
* @attr
|
||||
*/
|
||||
@property()
|
||||
public emptyOption = "---------";
|
||||
emptyOption = "---------";
|
||||
|
||||
//#endregion
|
||||
// Handle the behavior of the drop-down when the :host scrolls off the page.
|
||||
scrollHandler?: () => void;
|
||||
|
||||
//#region State
|
||||
// observer: IntersectionObserver;
|
||||
|
||||
@state()
|
||||
protected displayValue = "";
|
||||
displayValue = "";
|
||||
|
||||
// Tracks when the inputRef is populated, so we can safely reschedule the
|
||||
// render of the dropdown with respect to it.
|
||||
@state()
|
||||
protected inputRefIsAvailable = false;
|
||||
inputRefIsAvailable = false;
|
||||
|
||||
/**
|
||||
* Permanent identity with the portal so focus events can be checked.
|
||||
*/
|
||||
#menuRef: Ref<ListSelect> = createRef();
|
||||
menuRef: Ref<ListSelect> = createRef();
|
||||
|
||||
/**
|
||||
* Permanent identify for the input object, so the floating portal can find where to anchor
|
||||
* itself.
|
||||
*/
|
||||
#inputRef: Ref<HTMLInputElement> = createRef();
|
||||
inputRef: Ref<HTMLInputElement> = createRef();
|
||||
|
||||
/**
|
||||
* Maps a value from the portal to labels to be put into the <input> field>
|
||||
* Maps a value from the portal to labels to be put into the <input> field>
|
||||
*/
|
||||
#flatOptions: [label: string, option: SelectOption][] = [];
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public override updated() {
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
public override firstUpdated() {
|
||||
// Route around Lit's scheduling algorithm complaining about re-renders
|
||||
window.setTimeout(() => {
|
||||
this.inputRefIsAvailable = Boolean(this.#inputRef?.value);
|
||||
}, 0);
|
||||
}
|
||||
flatOptions: [string, SelectOption][] = [];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -233,90 +203,73 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
// TODO
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
#clickListener = (_ev: Event) => {
|
||||
@bound
|
||||
onClick(_ev: Event) {
|
||||
this.open = !this.open;
|
||||
this.#inputRef.value?.focus();
|
||||
};
|
||||
this.inputRef.value?.focus();
|
||||
}
|
||||
|
||||
setFromMatchList(value?: string) {
|
||||
if (!value) return;
|
||||
|
||||
const probableValue = this.#flatOptions.find(([label]) => label === this.value);
|
||||
|
||||
if (probableValue && this.#inputRef.value) {
|
||||
this.#inputRef.value.value = probableValue[1][1];
|
||||
setFromMatchList(value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
const probableValue = this.flatOptions.find((option) => option[0] === this.value);
|
||||
if (probableValue && this.inputRef.value) {
|
||||
this.inputRef.value.value = probableValue[1][1];
|
||||
}
|
||||
}
|
||||
|
||||
#searchKeyupListener = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
@bound
|
||||
onKeydown(event: KeyboardEvent) {
|
||||
if (event.code === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
this.#menuRef.value?.currentElement?.focus();
|
||||
if (event.code === "ArrowDown" || event.code === "ArrowUp") {
|
||||
this.open = true;
|
||||
}
|
||||
};
|
||||
|
||||
#searchKeydownListener = (event: KeyboardEvent) => {
|
||||
if (!this.open) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
this.setFromMatchList(this.value);
|
||||
break;
|
||||
case "Tab":
|
||||
event.preventDefault();
|
||||
this.setFromMatchList(this.value);
|
||||
|
||||
this.#menuRef.value?.currentElement?.focus();
|
||||
if (event.code === "Tab" && this.open) {
|
||||
event.preventDefault();
|
||||
this.setFromMatchList(this.value);
|
||||
this.menuRef.value?.currentElement?.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#blurListener = (event: FocusEvent) => {
|
||||
@bound
|
||||
onListBlur(event: FocusEvent) {
|
||||
// If we lost focus but the menu got it, don't do anything;
|
||||
const relatedTarget = event.relatedTarget as HTMLElement | undefined;
|
||||
if (
|
||||
relatedTarget &&
|
||||
(this.contains(relatedTarget) ||
|
||||
this.renderRoot.contains(relatedTarget) ||
|
||||
this.#menuRef.value?.contains(relatedTarget) ||
|
||||
this.#menuRef.value?.renderRoot.contains(relatedTarget))
|
||||
this.menuRef.value?.contains(relatedTarget) ||
|
||||
this.menuRef.value?.renderRoot.contains(relatedTarget))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.open = false;
|
||||
if (!this.value) {
|
||||
if (this.#inputRef.value) {
|
||||
this.#inputRef.value.value = "";
|
||||
if (this.value === undefined) {
|
||||
if (this.inputRef.value) {
|
||||
this.inputRef.value.value = "";
|
||||
}
|
||||
this.setValue(undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setValue(newValue: string | undefined) {
|
||||
this.value = newValue;
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, composed: true })); // prettier-ignore
|
||||
}
|
||||
|
||||
findValueForInput() {
|
||||
const value = this.#inputRef.value?.value;
|
||||
const value = this.inputRef.value?.value;
|
||||
if (value === undefined || value.trim() === "") {
|
||||
this.setValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchesFound = findFlatOptions(this.#flatOptions, value);
|
||||
const matchesFound = findFlatOptions(this.flatOptions, value);
|
||||
if (matchesFound.length > 0) {
|
||||
const newValue = matchesFound[0][0];
|
||||
if (newValue === value) {
|
||||
@@ -328,52 +281,47 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
}
|
||||
}
|
||||
|
||||
#inputListener = (_ev: InputEvent) => {
|
||||
@bound
|
||||
onInput(_ev: InputEvent) {
|
||||
if (!this.managed) {
|
||||
this.findValueForInput();
|
||||
this.requestUpdate();
|
||||
}
|
||||
this.open = true;
|
||||
};
|
||||
}
|
||||
|
||||
#listKeyupListener = (event: KeyboardEvent) => {
|
||||
@bound
|
||||
onListKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
|
||||
this.open = false;
|
||||
this.#inputRef.value?.focus();
|
||||
this.inputRef.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
#listKeydownListener = (event: KeyboardEvent) => {
|
||||
if (event.key === "Tab" && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
|
||||
this.#inputRef.value?.focus();
|
||||
this.inputRef.value?.focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#changeListener = (event: InputEvent) => {
|
||||
@bound
|
||||
onListChange(event: InputEvent) {
|
||||
if (!event.target) {
|
||||
return;
|
||||
}
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
if (value) {
|
||||
if (value !== undefined) {
|
||||
const newDisplayValue = this.findDisplayForValue(value);
|
||||
if (this.#inputRef.value) {
|
||||
this.#inputRef.value.value = newDisplayValue ?? "";
|
||||
if (this.inputRef.value) {
|
||||
this.inputRef.value.value = newDisplayValue ?? "";
|
||||
}
|
||||
} else if (this.#inputRef.value) {
|
||||
this.#inputRef.value.value = "";
|
||||
} else if (this.inputRef.value) {
|
||||
this.inputRef.value.value = "";
|
||||
}
|
||||
this.open = false;
|
||||
this.setValue(value);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
findDisplayForValue(value: string) {
|
||||
const newDisplayValue = this.#flatOptions.find((option) => option[0] === value);
|
||||
const newDisplayValue = this.flatOptions.find((option) => option[0] === value);
|
||||
return newDisplayValue ? newDisplayValue[1][1] : undefined;
|
||||
}
|
||||
|
||||
@@ -392,17 +340,15 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
}
|
||||
|
||||
get rawValue() {
|
||||
return this.#inputRef.value?.value ?? "";
|
||||
return this.inputRef.value?.value ?? "";
|
||||
}
|
||||
|
||||
get managedOptions() {
|
||||
return this.managed
|
||||
? this.#options
|
||||
: findOptionsSubset(this.#options, this.rawValue, this.caseSensitive);
|
||||
? this._options
|
||||
: findOptionsSubset(this._options, this.rawValue, this.caseSensitive);
|
||||
}
|
||||
|
||||
//#region Render
|
||||
|
||||
public override render() {
|
||||
const emptyOption = this.blankable ? this.emptyOption : undefined;
|
||||
const open = this.open;
|
||||
@@ -411,22 +357,17 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
<div class="pf-c-select__toggle pf-m-typeahead" part="ak-search-select-toggle">
|
||||
<div class="pf-c-select__toggle-wrapper" part="ak-search-select-wrapper">
|
||||
<input
|
||||
?required=${!this.blankable}
|
||||
part="ak-search-select-toggle-typeahead"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control pf-c-select__toggle-typeahead"
|
||||
type="text"
|
||||
id=${ifDefined(this.fieldID)}
|
||||
${ref(this.#inputRef)}
|
||||
${ref(this.inputRef)}
|
||||
placeholder=${this.placeholder}
|
||||
aria-label=${ifDefined(this.label)}
|
||||
name=${ifDefined(this.name)}
|
||||
spellcheck="false"
|
||||
@input=${this.#inputListener}
|
||||
@click=${this.#clickListener}
|
||||
@blur=${this.#blurListener}
|
||||
@keyup=${this.#searchKeyupListener}
|
||||
@keydown=${this.#searchKeydownListener}
|
||||
@input=${this.onInput}
|
||||
@click=${this.onClick}
|
||||
@blur=${this.onListBlur}
|
||||
@keydown=${this.onKeydown}
|
||||
value=${this.displayValue}
|
||||
/>
|
||||
</div>
|
||||
@@ -436,26 +377,34 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
|
||||
? html`
|
||||
<ak-portal
|
||||
name=${ifDefined(this.name)}
|
||||
.anchor=${this.#inputRef.value}
|
||||
.anchor=${this.inputRef.value}
|
||||
?open=${open}
|
||||
>
|
||||
<ak-list-select
|
||||
id="menu-${this.getAttribute("data-ouia-component-id")}"
|
||||
${ref(this.#menuRef)}
|
||||
${ref(this.menuRef)}
|
||||
.options=${this.managedOptions}
|
||||
value=${ifDefined(this.value)}
|
||||
@change=${this.#changeListener}
|
||||
@blur=${this.#blurListener}
|
||||
@change=${this.onListChange}
|
||||
@blur=${this.onListBlur}
|
||||
emptyOption=${ifDefined(emptyOption)}
|
||||
@keydown=${this.#listKeydownListener}
|
||||
@keyup=${this.#listKeyupListener}
|
||||
@keydown=${this.onListKeydown}
|
||||
></ak-list-select>
|
||||
</ak-portal>
|
||||
`
|
||||
: nothing}`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
public override updated() {
|
||||
this.setAttribute("data-ouia-component-safe", "true");
|
||||
}
|
||||
|
||||
public override firstUpdated() {
|
||||
// Route around Lit's scheduling algorithm complaining about re-renders
|
||||
window.setTimeout(() => {
|
||||
this.inputRefIsAvailable = Boolean(this.inputRef?.value);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ISearchSelect<T> extends ISearchSelectBase<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | null) => string;
|
||||
value: (element: T | undefined) => string;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
@@ -44,28 +44,44 @@ export interface ISearchSelect<T> extends ISearchSelectBase<T> {
|
||||
* consequence of the user typing or when selecting from the list.
|
||||
*
|
||||
*/
|
||||
|
||||
@customElement("ak-search-select")
|
||||
export class SearchSelect<T> extends SearchSelectBase<T> implements ISearchSelect<T> {
|
||||
static styles = [...SearchSelectBase.styles];
|
||||
|
||||
// A function which takes the query state object (accepting that it may be empty) and returns a
|
||||
// new collection of objects.
|
||||
@property({ attribute: false })
|
||||
public fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
fetchObjects!: (query?: string) => Promise<T[]>;
|
||||
|
||||
// A function passed to this object that extracts a string representation of items of the
|
||||
// collection under search.
|
||||
@property({ attribute: false })
|
||||
public renderElement!: (element: T) => string;
|
||||
renderElement!: (element: T) => string;
|
||||
|
||||
// A function passed to this object that extracts an HTML representation of additional
|
||||
// information for items of the collection under search.
|
||||
@property({ attribute: false })
|
||||
public renderDescription?: (element: T) => string | TemplateResult;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
@property({ attribute: false })
|
||||
public value!: (element: T | null) => string;
|
||||
value!: (element: T | undefined) => string;
|
||||
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
// fetching the data; sets an initial default value.
|
||||
@property({ attribute: false })
|
||||
public selected?: (element: T, elements: T[]) => boolean;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
|
||||
// A function passed to this object (or using the default below) that groups objects in the
|
||||
// collection under search into categories.
|
||||
@property({ attribute: false })
|
||||
public groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
|
||||
return groupBy(items, () => "");
|
||||
groupBy: (items: T[]) => [string, T[]][] = (items: T[]): [string, T[]][] => {
|
||||
return groupBy(items, () => {
|
||||
return "";
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export const GroupedAndEz = () => {
|
||||
const config: ISearchSelectApi<Sample> = {
|
||||
fetchObjects: getSamples,
|
||||
renderElement: (sample: Sample) => sample.name,
|
||||
value: (sample: Sample | null) => sample?.pk ?? "",
|
||||
value: (sample: Sample | undefined) => sample?.pk ?? "",
|
||||
groupBy: (samples: Sample[]) =>
|
||||
groupBy(samples, (sample: Sample) => sample.season[0] ?? ""),
|
||||
};
|
||||
|
||||
@@ -54,19 +54,6 @@ export type LitPropertyRecord<T extends object> = {
|
||||
*/
|
||||
export type LitPropertyKey<K> = K extends string ? `.${K}` | `?${K}` | K : K;
|
||||
|
||||
/**
|
||||
* A React-like functional component. Used to render a component in a template.
|
||||
*
|
||||
* @template P The type of the props object.
|
||||
* @param props The props object.
|
||||
* @param children The children to render.
|
||||
* @returns The rendered template.
|
||||
*/
|
||||
export type LitFC<P> = (
|
||||
props: P,
|
||||
children?: SlottedTemplateResult,
|
||||
) => SlottedTemplateResult | SlottedTemplateResult[];
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Host/Controller
|
||||
|
||||
@@ -7,11 +7,6 @@ export type NamedElement<T = Element> = T & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type predicate to check if an element currently has a `name` attribute.
|
||||
*
|
||||
* @see {@linkcode isNameableElement} to check if an element is nameable.
|
||||
*/
|
||||
export function isNamedElement(element: Element): element is NamedElement {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
@@ -20,57 +15,27 @@ export function isNamedElement(element: Element): element is NamedElement {
|
||||
return "name" in element.attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of elements that can be named, i.e. have a `name` attribute.
|
||||
*
|
||||
* @deprecated This should be replaced with a less brittle approach.
|
||||
*/
|
||||
const NameableElements = new Set([
|
||||
"INPUT",
|
||||
"TEXTAREA",
|
||||
"SELECT",
|
||||
"AK-CODEMIRROR",
|
||||
"AK-CHIP-GROUP",
|
||||
"AK-SEARCH-SELECT",
|
||||
"AK-RADIO",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Type predicate to check if an element is nameable.
|
||||
*
|
||||
* @see {@linkcode isNamedElement} to check if an element currently has a `name` attribute.
|
||||
*/
|
||||
export function isNameableElement(element: Element): element is NamedElement {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return NameableElements.has(element.tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a map of files provided by input elements within the given iterable.
|
||||
*/
|
||||
export function createFileMap<T extends PropertyKey = PropertyKey>(
|
||||
fileInputParents?: Iterable<LitElement> | null,
|
||||
fileInputParents?: Iterable<NamedElement<LitElement>> | null,
|
||||
): Map<T, File> {
|
||||
const record = new Map<T, File>();
|
||||
|
||||
for (const element of fileInputParents || []) {
|
||||
element.requestUpdate();
|
||||
|
||||
if (!isNamedElement(element)) continue;
|
||||
|
||||
const inputElement = element.querySelector<HTMLInputElement>("input[type=file]");
|
||||
|
||||
if (!inputElement) continue;
|
||||
|
||||
const file = inputElement.files?.[0];
|
||||
const name = element.name as T;
|
||||
const name = element.name;
|
||||
|
||||
if (!file || !name) continue;
|
||||
|
||||
record.set(name, file);
|
||||
record.set(name as T, file);
|
||||
}
|
||||
|
||||
return record;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user