diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 8978eeee7a..66975dbc25 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 2024.10.0
+current_version = 2024.10.4
tag = True
commit = True
parse = (?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P[a-zA-Z-]+)(?P[1-9]\\d*))?
diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml
index 4812a57a03..7c223fac8c 100644
--- a/.github/workflows/ci-main.yml
+++ b/.github/workflows/ci-main.yml
@@ -116,7 +116,7 @@ jobs:
poetry run make test
poetry run coverage xml
- if: ${{ always() }}
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
@@ -140,7 +140,7 @@ jobs:
poetry run coverage run manage.py test tests/integration
poetry run coverage xml
- if: ${{ always() }}
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
flags: integration
token: ${{ secrets.CODECOV_TOKEN }}
@@ -198,7 +198,7 @@ jobs:
poetry run coverage run manage.py test ${{ matrix.job.glob }}
poetry run coverage xml
- if: ${{ always() }}
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
flags: e2e
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/Dockerfile b/Dockerfile
index c75fa81a52..ea9f491a12 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -80,7 +80,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP
-FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.0.1 AS geoip
+FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="1"
diff --git a/authentik/__init__.py b/authentik/__init__.py
index 259d0f83f6..38e760de9b 100644
--- a/authentik/__init__.py
+++ b/authentik/__init__.py
@@ -2,7 +2,7 @@
from os import environ
-__version__ = "2024.10.0"
+__version__ = "2024.10.4"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
diff --git a/authentik/api/templates/api/browser.html b/authentik/api/templates/api/browser.html
index a84ef3cb25..e753d79f21 100644
--- a/authentik/api/templates/api/browser.html
+++ b/authentik/api/templates/api/browser.html
@@ -7,7 +7,7 @@ API Browser - {{ brand.branding_title }}
{% endblock %}
{% block head %}
-{% versioned_script "dist/standalone/api-browser/index-%v.js" %}
+
{% endblock %}
diff --git a/authentik/blueprints/tests/test_packaged.py b/authentik/blueprints/tests/test_packaged.py
index 443173bac2..32d392447f 100644
--- a/authentik/blueprints/tests/test_packaged.py
+++ b/authentik/blueprints/tests/test_packaged.py
@@ -27,7 +27,8 @@ def blueprint_tester(file_name: Path) -> Callable:
base = Path("blueprints/")
rel_path = Path(file_name).relative_to(base)
importer = Importer.from_string(BlueprintInstance(path=str(rel_path)).retrieve())
- self.assertTrue(importer.validate()[0])
+ validation, logs = importer.validate()
+ self.assertTrue(validation, logs)
self.assertTrue(importer.apply())
return tester
diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py
index f0cc804f52..ff272b5c62 100644
--- a/authentik/blueprints/v1/importer.py
+++ b/authentik/blueprints/v1/importer.py
@@ -293,7 +293,11 @@ class Importer:
serializer_kwargs = {}
model_instance = existing_models.first()
- if not isinstance(model(), BaseMetaModel) and model_instance:
+ if (
+ not isinstance(model(), BaseMetaModel)
+ and model_instance
+ and entry.state != BlueprintEntryDesiredState.MUST_CREATED
+ ):
self.logger.debug(
"Initialise serializer with instance",
model=model,
@@ -303,11 +307,12 @@ class Importer:
serializer_kwargs["instance"] = model_instance
serializer_kwargs["partial"] = True
elif model_instance and entry.state == BlueprintEntryDesiredState.MUST_CREATED:
+ msg = (
+ f"State is set to {BlueprintEntryDesiredState.MUST_CREATED.value} "
+ "and object exists already",
+ )
raise EntryInvalidError.from_entry(
- (
- f"State is set to {BlueprintEntryDesiredState.MUST_CREATED} "
- "and object exists already",
- ),
+ ValidationError({k: msg for k in entry.identifiers.keys()}, "unique"),
entry,
)
else:
diff --git a/authentik/brands/middleware.py b/authentik/brands/middleware.py
index 71650cc621..52af854e33 100644
--- a/authentik/brands/middleware.py
+++ b/authentik/brands/middleware.py
@@ -4,7 +4,7 @@ from collections.abc import Callable
from django.http.request import HttpRequest
from django.http.response import HttpResponse
-from django.utils.translation import activate
+from django.utils.translation import override
from authentik.brands.utils import get_brand_for_request
@@ -18,10 +18,12 @@ class BrandMiddleware:
self.get_response = get_response
def __call__(self, request: HttpRequest) -> HttpResponse:
+ locale_to_set = None
if not hasattr(request, "brand"):
brand = get_brand_for_request(request)
request.brand = brand
locale = brand.default_locale
if locale != "":
- activate(locale)
- return self.get_response(request)
+ locale_to_set = locale
+ with override(locale_to_set):
+ return self.get_response(request)
diff --git a/authentik/core/api/devices.py b/authentik/core/api/devices.py
index 58040df835..cc8f77f558 100644
--- a/authentik/core/api/devices.py
+++ b/authentik/core/api/devices.py
@@ -1,5 +1,6 @@
"""Authenticator Devices API Views"""
+from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.fields import (
@@ -40,7 +41,11 @@ class DeviceSerializer(MetaNameSerializer):
def get_extra_description(self, instance: Device) -> str:
"""Get extra description"""
if isinstance(instance, WebAuthnDevice):
- return instance.device_type.description
+ return (
+ instance.device_type.description
+ if instance.device_type
+ else _("Extra description not available")
+ )
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return ""
diff --git a/authentik/core/api/transactional_applications.py b/authentik/core/api/transactional_applications.py
index 1e47096ca6..44e390ecd1 100644
--- a/authentik/core/api/transactional_applications.py
+++ b/authentik/core/api/transactional_applications.py
@@ -1,10 +1,12 @@
"""transactional application and provider creation"""
from django.apps import apps
+from django.db.models import Model
+from django.utils.translation import gettext as _
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_field
-from rest_framework.exceptions import ValidationError
+from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import BooleanField, CharField, ChoiceField, DictField, ListField
-from rest_framework.permissions import IsAdminUser
+from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -22,6 +24,7 @@ from authentik.core.api.applications import ApplicationSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Provider
from authentik.lib.utils.reflection import all_subclasses
+from authentik.policies.api.bindings import PolicyBindingSerializer
def get_provider_serializer_mapping():
@@ -45,6 +48,13 @@ class TransactionProviderField(DictField):
"""Dictionary field which can hold provider creation data"""
+class TransactionPolicyBindingSerializer(PolicyBindingSerializer):
+ """PolicyBindingSerializer which does not require target as target is set implicitly"""
+
+ class Meta(PolicyBindingSerializer.Meta):
+ fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"]
+
+
class TransactionApplicationSerializer(PassiveSerializer):
"""Serializer for creating a provider and an application in one transaction"""
@@ -52,6 +62,8 @@ class TransactionApplicationSerializer(PassiveSerializer):
provider_model = ChoiceField(choices=list(get_provider_serializer_mapping().keys()))
provider = TransactionProviderField()
+ policy_bindings = TransactionPolicyBindingSerializer(many=True, required=False)
+
_provider_model: type[Provider] = None
def validate_provider_model(self, fq_model_name: str) -> str:
@@ -96,6 +108,19 @@ class TransactionApplicationSerializer(PassiveSerializer):
id="app",
)
)
+ for binding in attrs.get("policy_bindings", []):
+ binding["target"] = KeyOf(None, ScalarNode(tag="", value="app"))
+ for key, value in binding.items():
+ if not isinstance(value, Model):
+ continue
+ binding[key] = value.pk
+ blueprint.entries.append(
+ BlueprintEntry(
+ model="authentik_policies.policybinding",
+ state=BlueprintEntryDesiredState.MUST_CREATED,
+ identifiers=binding,
+ )
+ )
importer = Importer(blueprint, {})
try:
valid, _ = importer.validate(raise_validation_errors=True)
@@ -120,8 +145,7 @@ class TransactionApplicationResponseSerializer(PassiveSerializer):
class TransactionalApplicationView(APIView):
"""Create provider and application and attach them in a single transaction"""
- # TODO: Migrate to a more specific permission
- permission_classes = [IsAdminUser]
+ permission_classes = [IsAuthenticated]
@extend_schema(
request=TransactionApplicationSerializer(),
@@ -133,8 +157,23 @@ class TransactionalApplicationView(APIView):
"""Convert data into a blueprint, validate it and apply it"""
data = TransactionApplicationSerializer(data=request.data)
data.is_valid(raise_exception=True)
-
- importer = Importer(data.validated_data, {})
+ blueprint: Blueprint = data.validated_data
+ for entry in blueprint.entries:
+ full_model = entry.get_model(blueprint)
+ app, __, model = full_model.partition(".")
+ if not request.user.has_perm(f"{app}.add_{model}"):
+ raise PermissionDenied(
+ {
+ entry.id: _(
+ "User lacks permission to create {model}".format_map(
+ {
+ "model": full_model,
+ }
+ )
+ )
+ }
+ )
+ importer = Importer(blueprint, {})
applied = importer.apply()
response = {"applied": False, "logs": []}
response["applied"] = applied
diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py
index 37656933d5..7280a05881 100644
--- a/authentik/core/api/users.py
+++ b/authentik/core/api/users.py
@@ -666,7 +666,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
@permission_required("authentik_core.impersonate")
@extend_schema(
- request=OpenApiTypes.NONE,
+ request=inline_serializer(
+ "ImpersonationSerializer",
+ {
+ "reason": CharField(required=True),
+ },
+ ),
responses={
"204": OpenApiResponse(description="Successfully started impersonation"),
"401": OpenApiResponse(description="Access denied"),
@@ -679,6 +684,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401)
user_to_be = self.get_object()
+ reason = request.data.get("reason", "")
# Check both object-level perms and global perms
if not request.user.has_perm(
"authentik_core.impersonate", user_to_be
@@ -688,11 +694,16 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if user_to_be.pk == self.request.user.pk:
LOGGER.debug("User attempted to impersonate themselves", user=request.user)
return Response(status=401)
+ if not reason and request.tenant.impersonation_require_reason:
+ LOGGER.debug(
+ "User attempted to impersonate without providing a reason", user=request.user
+ )
+ return Response(status=401)
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
- Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
+ Event.new(EventAction.IMPERSONATION_STARTED, reason=reason).from_http(request, user_to_be)
return Response(status=201)
diff --git a/authentik/core/middleware.py b/authentik/core/middleware.py
index f59b9aa6b7..1d20455a1b 100644
--- a/authentik/core/middleware.py
+++ b/authentik/core/middleware.py
@@ -5,7 +5,7 @@ from contextvars import ContextVar
from uuid import uuid4
from django.http import HttpRequest, HttpResponse
-from django.utils.translation import activate
+from django.utils.translation import override
from sentry_sdk.api import set_tag
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
@@ -31,17 +31,19 @@ class ImpersonateMiddleware:
def __call__(self, request: HttpRequest) -> HttpResponse:
# No permission checks are done here, they need to be checked before
# SESSION_KEY_IMPERSONATE_USER is set.
+ locale_to_set = None
if request.user.is_authenticated:
locale = request.user.locale(request)
if locale != "":
- activate(locale)
+ locale_to_set = locale
if SESSION_KEY_IMPERSONATE_USER in request.session:
request.user = request.session[SESSION_KEY_IMPERSONATE_USER]
# Ensure that the user is active, otherwise nothing will work
request.user.is_active = True
- return self.get_response(request)
+ with override(locale_to_set):
+ return self.get_response(request)
class RequestIDMiddleware:
diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py
index 7b1e115e09..3b23ed78f0 100644
--- a/authentik/core/sources/flow_manager.py
+++ b/authentik/core/sources/flow_manager.py
@@ -129,6 +129,11 @@ class SourceFlowManager:
)
new_connection.user = self.request.user
new_connection = self.update_user_connection(new_connection, **kwargs)
+ if existing := self.user_connection_type.objects.filter(
+ source=self.source, identifier=self.identifier
+ ).first():
+ existing = self.update_user_connection(existing)
+ return Action.AUTH, existing
return Action.LINK, new_connection
action, connection = self.matcher.get_user_action(self.identifier, self.user_properties)
diff --git a/authentik/core/templates/base/skeleton.html b/authentik/core/templates/base/skeleton.html
index 86da52e949..074fcc1556 100644
--- a/authentik/core/templates/base/skeleton.html
+++ b/authentik/core/templates/base/skeleton.html
@@ -15,8 +15,8 @@
{% endblock %}
- {% versioned_script "dist/poly-%v.js" %}
- {% versioned_script "dist/standalone/loading/index-%v.js" %}
+
+
{% block head %}
{% endblock %}
diff --git a/authentik/core/templates/if/admin.html b/authentik/core/templates/if/admin.html
index 9a548dfd9d..51d1569dfb 100644
--- a/authentik/core/templates/if/admin.html
+++ b/authentik/core/templates/if/admin.html
@@ -3,7 +3,7 @@
{% load authentik_core %}
{% block head %}
-{% versioned_script "dist/admin/AdminInterface-%v.js" %}
+
{% include "base/header_js.html" %}
diff --git a/authentik/core/templates/if/user.html b/authentik/core/templates/if/user.html
index 84d88dac3b..7ce1714ac7 100644
--- a/authentik/core/templates/if/user.html
+++ b/authentik/core/templates/if/user.html
@@ -3,7 +3,7 @@
{% load authentik_core %}
{% block head %}
-{% versioned_script "dist/user/UserInterface-%v.js" %}
+
{% include "base/header_js.html" %}
diff --git a/authentik/core/templatetags/authentik_core.py b/authentik/core/templatetags/authentik_core.py
index 7f8a80a5fa..44ac3a4ffc 100644
--- a/authentik/core/templatetags/authentik_core.py
+++ b/authentik/core/templatetags/authentik_core.py
@@ -2,7 +2,6 @@
from django import template
from django.templatetags.static import static as static_loader
-from django.utils.safestring import mark_safe
from authentik import get_full_version
@@ -12,10 +11,4 @@ register = template.Library()
@register.simple_tag()
def versioned_script(path: str) -> str:
"""Wrapper around {% static %} tag that supports setting the version"""
- returned_lines = [
- (
- f''
- ),
- ]
- return mark_safe("".join(returned_lines)) # nosec
+ return static_loader(path.replace("%v", get_full_version()))
diff --git a/authentik/core/tests/test_applications_api.py b/authentik/core/tests/test_applications_api.py
index 1244776b2a..192adc458b 100644
--- a/authentik/core/tests/test_applications_api.py
+++ b/authentik/core/tests/test_applications_api.py
@@ -12,7 +12,7 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
-from authentik.providers.oauth2.models import OAuth2Provider
+from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
from authentik.providers.proxy.models import ProxyProvider
from authentik.providers.saml.models import SAMLProvider
@@ -24,7 +24,7 @@ class TestApplicationsAPI(APITestCase):
self.user = create_test_admin_user()
self.provider = OAuth2Provider.objects.create(
name="test",
- redirect_uris="http://some-other-domain",
+ redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://some-other-domain")],
authorization_flow=create_test_flow(),
)
self.allowed: Application = Application.objects.create(
diff --git a/authentik/core/tests/test_impersonation.py b/authentik/core/tests/test_impersonation.py
index d877e55c9e..7333971fab 100644
--- a/authentik/core/tests/test_impersonation.py
+++ b/authentik/core/tests/test_impersonation.py
@@ -29,7 +29,8 @@ class TestImpersonation(APITestCase):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
- )
+ ),
+ data={"reason": "some reason"},
)
response = self.client.get(reverse("authentik_api:user-me"))
@@ -55,7 +56,8 @@ class TestImpersonation(APITestCase):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
- )
+ ),
+ data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 201)
@@ -75,7 +77,8 @@ class TestImpersonation(APITestCase):
reverse(
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
- )
+ ),
+ data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 201)
@@ -89,7 +92,8 @@ class TestImpersonation(APITestCase):
self.client.force_login(self.other_user)
response = self.client.post(
- reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})
+ reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
+ data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 403)
@@ -105,7 +109,8 @@ class TestImpersonation(APITestCase):
self.client.force_login(self.user)
response = self.client.post(
- reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk})
+ reverse("authentik_api:user-impersonate", kwargs={"pk": self.other_user.pk}),
+ data={"reason": "some reason"},
)
self.assertEqual(response.status_code, 401)
@@ -118,7 +123,22 @@ class TestImpersonation(APITestCase):
self.client.force_login(self.user)
response = self.client.post(
- reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk})
+ reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
+ data={"reason": "some reason"},
+ )
+ self.assertEqual(response.status_code, 401)
+
+ response = self.client.get(reverse("authentik_api:user-me"))
+ response_body = loads(response.content.decode())
+ self.assertEqual(response_body["user"]["username"], self.user.username)
+
+ def test_impersonate_reason_required(self):
+ """test impersonation that user must provide reason"""
+ self.client.force_login(self.user)
+
+ response = self.client.post(
+ reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}),
+ data={"reason": ""},
)
self.assertEqual(response.status_code, 401)
diff --git a/authentik/core/tests/test_source_flow_manager.py b/authentik/core/tests/test_source_flow_manager.py
index bcd38449c6..c9346fce85 100644
--- a/authentik/core/tests/test_source_flow_manager.py
+++ b/authentik/core/tests/test_source_flow_manager.py
@@ -81,6 +81,22 @@ class TestSourceFlowManager(TestCase):
reverse("authentik_core:if-user") + "#/settings;page-sources",
)
+ def test_authenticated_auth(self):
+ """Test authenticated user linking"""
+ user = User.objects.create(username="foo", email="foo@bar.baz")
+ UserOAuthSourceConnection.objects.create(
+ user=user, source=self.source, identifier=self.identifier
+ )
+ request = get_request("/", user=user)
+ flow_manager = OAuthSourceFlowManager(
+ self.source, request, self.identifier, {"info": {}}, {}
+ )
+ action, connection = flow_manager.get_action()
+ self.assertEqual(action, Action.AUTH)
+ self.assertIsNotNone(connection.pk)
+ response = flow_manager.get_flow()
+ self.assertEqual(response.status_code, 302)
+
def test_unauthenticated_link(self):
"""Test un-authenticated user linking"""
flow_manager = OAuthSourceFlowManager(
diff --git a/authentik/core/tests/test_transactional_applications_api.py b/authentik/core/tests/test_transactional_applications_api.py
index d0804fb3b6..c6fcfb1946 100644
--- a/authentik/core/tests/test_transactional_applications_api.py
+++ b/authentik/core/tests/test_transactional_applications_api.py
@@ -1,11 +1,13 @@
"""Test Transactional API"""
from django.urls import reverse
+from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
-from authentik.core.models import Application
-from authentik.core.tests.utils import create_test_admin_user, create_test_flow
+from authentik.core.models import Application, Group
+from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.lib.generators import generate_id
+from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import OAuth2Provider
@@ -13,7 +15,9 @@ class TestTransactionalApplicationsAPI(APITestCase):
"""Test Transactional API"""
def setUp(self) -> None:
- self.user = create_test_admin_user()
+ self.user = create_test_user()
+ assign_perm("authentik_core.add_application", self.user)
+ assign_perm("authentik_providers_oauth2.add_oauth2provider", self.user)
def test_create_transactional(self):
"""Test transactional Application + provider creation"""
@@ -31,6 +35,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
"name": uid,
"authorization_flow": str(create_test_flow().pk),
"invalidation_flow": str(create_test_flow().pk),
+ "redirect_uris": [],
},
},
)
@@ -41,6 +46,66 @@ class TestTransactionalApplicationsAPI(APITestCase):
self.assertIsNotNone(app)
self.assertEqual(app.provider.pk, provider.pk)
+ def test_create_transactional_permission_denied(self):
+ """Test transactional Application + provider creation (missing permissions)"""
+ self.client.force_login(self.user)
+ uid = generate_id()
+ response = self.client.put(
+ reverse("authentik_api:core-transactional-application"),
+ data={
+ "app": {
+ "name": uid,
+ "slug": uid,
+ },
+ "provider_model": "authentik_providers_saml.samlprovider",
+ "provider": {
+ "name": uid,
+ "authorization_flow": str(create_test_flow().pk),
+ "invalidation_flow": str(create_test_flow().pk),
+ "acs_url": "https://goauthentik.io",
+ },
+ },
+ )
+ self.assertJSONEqual(
+ response.content.decode(),
+ {"provider": "User lacks permission to create authentik_providers_saml.samlprovider"},
+ )
+
+ def test_create_transactional_bindings(self):
+ """Test transactional Application + provider creation"""
+ assign_perm("authentik_policies.add_policybinding", self.user)
+ self.client.force_login(self.user)
+ uid = generate_id()
+ group = Group.objects.create(name=generate_id())
+ authorization_flow = create_test_flow()
+ response = self.client.put(
+ reverse("authentik_api:core-transactional-application"),
+ data={
+ "app": {
+ "name": uid,
+ "slug": uid,
+ },
+ "provider_model": "authentik_providers_oauth2.oauth2provider",
+ "provider": {
+ "name": uid,
+ "authorization_flow": str(authorization_flow.pk),
+ "invalidation_flow": str(authorization_flow.pk),
+ "redirect_uris": [],
+ },
+ "policy_bindings": [{"group": group.pk, "order": 0}],
+ },
+ )
+ self.assertJSONEqual(response.content.decode(), {"applied": True, "logs": []})
+ provider = OAuth2Provider.objects.filter(name=uid).first()
+ self.assertIsNotNone(provider)
+ app = Application.objects.filter(slug=uid).first()
+ self.assertIsNotNone(app)
+ self.assertEqual(app.provider.pk, provider.pk)
+ binding = PolicyBinding.objects.filter(target=app).first()
+ self.assertIsNotNone(binding)
+ self.assertEqual(binding.target, app)
+ self.assertEqual(binding.group, group)
+
def test_create_transactional_invalid(self):
"""Test transactional Application + provider creation"""
self.client.force_login(self.user)
@@ -57,6 +122,7 @@ class TestTransactionalApplicationsAPI(APITestCase):
"name": uid,
"authorization_flow": "",
"invalidation_flow": "",
+ "redirect_uris": [],
},
},
)
@@ -69,3 +135,32 @@ class TestTransactionalApplicationsAPI(APITestCase):
}
},
)
+
+ def test_create_transactional_duplicate_name_provider(self):
+ """Test transactional Application + provider creation"""
+ self.client.force_login(self.user)
+ uid = generate_id()
+ OAuth2Provider.objects.create(
+ name=uid,
+ authorization_flow=create_test_flow(),
+ invalidation_flow=create_test_flow(),
+ )
+ response = self.client.put(
+ reverse("authentik_api:core-transactional-application"),
+ data={
+ "app": {
+ "name": uid,
+ "slug": uid,
+ },
+ "provider_model": "authentik_providers_oauth2.oauth2provider",
+ "provider": {
+ "name": uid,
+ "authorization_flow": str(create_test_flow().pk),
+ "invalidation_flow": str(create_test_flow().pk),
+ },
+ },
+ )
+ self.assertJSONEqual(
+ response.content.decode(),
+ {"provider": {"name": ["State is set to must_created and object exists already"]}},
+ )
diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py
index 5bd2665347..ed31e82137 100644
--- a/authentik/crypto/api.py
+++ b/authentik/crypto/api.py
@@ -24,6 +24,7 @@ from rest_framework.fields import (
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request
from rest_framework.response import Response
+from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
@@ -181,7 +182,10 @@ class CertificateDataSerializer(PassiveSerializer):
class CertificateGenerationSerializer(PassiveSerializer):
"""Certificate generation parameters"""
- common_name = CharField()
+ common_name = CharField(
+ validators=[UniqueValidator(queryset=CertificateKeyPair.objects.all())],
+ source="name",
+ )
subject_alt_name = CharField(required=False, allow_blank=True, label=_("Subject-alt name"))
validity_days = IntegerField(initial=365)
alg = ChoiceField(default=PrivateKeyAlg.RSA, choices=PrivateKeyAlg.choices)
@@ -242,11 +246,10 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
def generate(self, request: Request) -> Response:
"""Generate a new, self-signed certificate-key pair"""
data = CertificateGenerationSerializer(data=request.data)
- if not data.is_valid():
- return Response(data.errors, status=400)
+ data.is_valid(raise_exception=True)
raw_san = data.validated_data.get("subject_alt_name", "")
sans = raw_san.split(",") if raw_san != "" else []
- builder = CertificateBuilder(data.validated_data["common_name"])
+ builder = CertificateBuilder(data.validated_data["name"])
builder.alg = data.validated_data["alg"]
builder.build(
subject_alt_names=sans,
diff --git a/authentik/crypto/tests.py b/authentik/crypto/tests.py
index e2dc755e7c..0e3c886d11 100644
--- a/authentik/crypto/tests.py
+++ b/authentik/crypto/tests.py
@@ -18,7 +18,7 @@ from authentik.crypto.models import CertificateKeyPair
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id, generate_key
-from authentik.providers.oauth2.models import OAuth2Provider
+from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
class TestCrypto(APITestCase):
@@ -89,6 +89,17 @@ class TestCrypto(APITestCase):
self.assertIsInstance(ext[1], DNSName)
self.assertEqual(ext[1].value, "baz")
+ def test_builder_api_duplicate(self):
+ """Test Builder (via API)"""
+ cert = create_test_cert()
+ self.client.force_login(create_test_admin_user())
+ res = self.client.post(
+ reverse("authentik_api:certificatekeypair-generate"),
+ data={"common_name": cert.name, "subject_alt_name": "bar,baz", "validity_days": 3},
+ )
+ self.assertEqual(res.status_code, 400)
+ self.assertJSONEqual(res.content, {"common_name": ["This field must be unique."]})
+
def test_builder_api_empty_san(self):
"""Test Builder (via API)"""
self.client.force_login(create_test_admin_user())
@@ -263,7 +274,7 @@ class TestCrypto(APITestCase):
client_id="test",
client_secret=generate_key(),
authorization_flow=create_test_flow(),
- redirect_uris="http://localhost",
+ redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=keypair,
)
response = self.client.get(
@@ -295,7 +306,7 @@ class TestCrypto(APITestCase):
client_id="test",
client_secret=generate_key(),
authorization_flow=create_test_flow(),
- redirect_uris="http://localhost",
+ redirect_uris=[RedirectURI(RedirectURIMatchingMode.STRICT, "http://localhost")],
signing_key=keypair,
)
response = self.client.get(
diff --git a/authentik/enterprise/providers/rac/api/providers.py b/authentik/enterprise/providers/rac/api/providers.py
index 892e081c96..9d0439ee7e 100644
--- a/authentik/enterprise/providers/rac/api/providers.py
+++ b/authentik/enterprise/providers/rac/api/providers.py
@@ -16,13 +16,28 @@ class RACProviderSerializer(EnterpriseRequiredMixin, ProviderSerializer):
class Meta:
model = RACProvider
- fields = ProviderSerializer.Meta.fields + [
+ fields = [
+ "pk",
+ "name",
+ "authentication_flow",
+ "authorization_flow",
+ "property_mappings",
+ "component",
+ "assigned_application_slug",
+ "assigned_application_name",
+ "assigned_backchannel_application_slug",
+ "assigned_backchannel_application_name",
+ "verbose_name",
+ "verbose_name_plural",
+ "meta_model_name",
"settings",
"outpost_set",
"connection_expiry",
"delete_token_on_disconnect",
]
- extra_kwargs = ProviderSerializer.Meta.extra_kwargs
+ extra_kwargs = {
+ "authorization_flow": {"required": True, "allow_null": False},
+ }
class RACProviderViewSet(UsedByMixin, ModelViewSet):
diff --git a/authentik/enterprise/providers/rac/templates/if/rac.html b/authentik/enterprise/providers/rac/templates/if/rac.html
index ade8bd8b2f..fde3f30ad8 100644
--- a/authentik/enterprise/providers/rac/templates/if/rac.html
+++ b/authentik/enterprise/providers/rac/templates/if/rac.html
@@ -3,7 +3,7 @@
{% load authentik_core %}
{% block head %}
-{% versioned_script "dist/enterprise/rac/index-%v.js" %}
+
diff --git a/authentik/enterprise/providers/rac/tests/test_api.py b/authentik/enterprise/providers/rac/tests/test_api.py
new file mode 100644
index 0000000000..da71133e80
--- /dev/null
+++ b/authentik/enterprise/providers/rac/tests/test_api.py
@@ -0,0 +1,46 @@
+"""Test RAC Provider"""
+
+from datetime import timedelta
+from time import mktime
+from unittest.mock import MagicMock, patch
+
+from django.urls import reverse
+from django.utils.timezone import now
+from rest_framework.test import APITestCase
+
+from authentik.core.tests.utils import create_test_admin_user, create_test_flow
+from authentik.enterprise.license import LicenseKey
+from authentik.enterprise.models import License
+from authentik.lib.generators import generate_id
+
+
+class TestAPI(APITestCase):
+ """Test Provider API"""
+
+ def setUp(self) -> None:
+ self.user = create_test_admin_user()
+
+ @patch(
+ "authentik.enterprise.license.LicenseKey.validate",
+ MagicMock(
+ return_value=LicenseKey(
+ aud="",
+ exp=int(mktime((now() + timedelta(days=3000)).timetuple())),
+ name=generate_id(),
+ internal_users=100,
+ external_users=100,
+ )
+ ),
+ )
+ def test_create(self):
+ """Test creation of RAC Provider"""
+ License.objects.create(key=generate_id())
+ self.client.force_login(self.user)
+ response = self.client.post(
+ reverse("authentik_api:racprovider-list"),
+ data={
+ "name": generate_id(),
+ "authorization_flow": create_test_flow().pk,
+ },
+ )
+ self.assertEqual(response.status_code, 201)
diff --git a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
index 4916e74ed5..1ad9b70daf 100644
--- a/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
+++ b/authentik/enterprise/providers/rac/tests/test_endpoints_api.py
@@ -68,7 +68,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
- "invalidation_flow": None,
"property_mappings": [],
"connection_expiry": "hours=8",
"delete_token_on_disconnect": False,
@@ -121,7 +120,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
- "invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
@@ -151,7 +149,6 @@ class TestEndpointsAPI(APITestCase):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
- "invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
diff --git a/authentik/events/models.py b/authentik/events/models.py
index 2f6a5b9d7e..1a21462b2d 100644
--- a/authentik/events/models.py
+++ b/authentik/events/models.py
@@ -60,7 +60,7 @@ def default_event_duration():
"""Default duration an Event is saved.
This is used as a fallback when no brand is available"""
try:
- tenant = get_current_tenant()
+ tenant = get_current_tenant(only=["event_retention"])
return now() + timedelta_from_string(tenant.event_retention)
except Tenant.DoesNotExist:
return now() + timedelta(days=365)
diff --git a/authentik/flows/templates/if/flow.html b/authentik/flows/templates/if/flow.html
index a5b3d7f592..2cf3246db3 100644
--- a/authentik/flows/templates/if/flow.html
+++ b/authentik/flows/templates/if/flow.html
@@ -18,7 +18,7 @@ window.authentik.flow = {
{% endblock %}
{% block head %}
-{% versioned_script "dist/flow/FlowInterface-%v.js" %}
+
+
+ ${
+ // @ts-expect-error The types for web components are not well-defined }
+ story()
+ }
+
+
+ `;
+ },
+ ],
+};
+
+export default metadata;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () =>
+ html` `,
+};
diff --git a/web/src/admin/admin-settings/stories/AdminSettingsFooterLinks.test.ts b/web/src/admin/admin-settings/stories/AdminSettingsFooterLinks.test.ts
new file mode 100644
index 0000000000..8458b77d01
--- /dev/null
+++ b/web/src/admin/admin-settings/stories/AdminSettingsFooterLinks.test.ts
@@ -0,0 +1,68 @@
+import { render } from "@goauthentik/elements/tests/utils.js";
+import { $, expect } from "@wdio/globals";
+
+import { html } from "lit";
+
+import "../AdminSettingsFooterLinks.js";
+
+describe("ak-admin-settings-footer-link", () => {
+ afterEach(async () => {
+ await browser.execute(async () => {
+ await document.body.querySelector("ak-admin-settings-footer-link")?.remove();
+ if (document.body["_$litPart$"]) {
+ // @ts-expect-error expression of type '"_$litPart$"' is added by Lit
+ await delete document.body["_$litPart$"];
+ }
+ });
+ });
+
+ it("should render an empty control", async () => {
+ render(html``);
+ const link = await $("ak-admin-settings-footer-link");
+ await expect(await link.getProperty("isValid")).toStrictEqual(false);
+ await expect(await link.getProperty("toJson")).toEqual({ name: "", href: "" });
+ });
+
+ it("should not be valid if just a name is filled in", async () => {
+ render(html``);
+ const link = await $("ak-admin-settings-footer-link");
+ await link.$('input[name="name"]').setValue("foo");
+ await expect(await link.getProperty("isValid")).toStrictEqual(false);
+ await expect(await link.getProperty("toJson")).toEqual({ name: "foo", href: "" });
+ });
+
+ it("should be valid if just a URL is filled in", async () => {
+ render(html``);
+ const link = await $("ak-admin-settings-footer-link");
+ await link.$('input[name="href"]').setValue("https://foo.com");
+ await expect(await link.getProperty("isValid")).toStrictEqual(true);
+ await expect(await link.getProperty("toJson")).toEqual({
+ name: "",
+ href: "https://foo.com",
+ });
+ });
+
+ it("should be valid if both are filled in", async () => {
+ render(html``);
+ const link = await $("ak-admin-settings-footer-link");
+ await link.$('input[name="name"]').setValue("foo");
+ await link.$('input[name="href"]').setValue("https://foo.com");
+ await expect(await link.getProperty("isValid")).toStrictEqual(true);
+ await expect(await link.getProperty("toJson")).toEqual({
+ name: "foo",
+ href: "https://foo.com",
+ });
+ });
+
+ it("should not be valid if the URL is not valid", async () => {
+ render(html``);
+ const link = await $("ak-admin-settings-footer-link");
+ await link.$('input[name="name"]').setValue("foo");
+ await link.$('input[name="href"]').setValue("never://foo.com");
+ await expect(await link.getProperty("toJson")).toEqual({
+ name: "foo",
+ href: "never://foo.com",
+ });
+ await expect(await link.getProperty("isValid")).toStrictEqual(false);
+ });
+});
diff --git a/web/src/admin/applications/ApplicationListPage.ts b/web/src/admin/applications/ApplicationListPage.ts
index 5bc688deeb..ad18f185ff 100644
--- a/web/src/admin/applications/ApplicationListPage.ts
+++ b/web/src/admin/applications/ApplicationListPage.ts
@@ -2,6 +2,7 @@ import "@goauthentik/admin/applications/ApplicationForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import MDApplication from "@goauthentik/docs/add-secure-apps/applications/index.md";
import "@goauthentik/elements/AppIcon.js";
+import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
@@ -12,7 +13,7 @@ import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
-import { msg } from "@lit/localize";
+import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -40,7 +41,7 @@ export const applicationListStyle = css`
`;
@customElement("ak-application-list")
-export class ApplicationListPage extends TablePage {
+export class ApplicationListPage extends WithBrandConfig(TablePage) {
searchEnabled(): boolean {
return true;
}
@@ -49,7 +50,7 @@ export class ApplicationListPage extends TablePage {
}
pageDescription(): string {
return msg(
- "External applications that use authentik as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access.",
+ str`External applications that use ${this.brand.brandingTitle || "authentik"} as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access.`,
);
}
pageIcon(): string {
diff --git a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts
index 9eac8ae988..77fa04d8ff 100644
--- a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts
+++ b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts
@@ -28,6 +28,7 @@ import {
type TransactionApplicationRequest,
type TransactionApplicationResponse,
ValidationError,
+ instanceOfValidationError,
} from "@goauthentik/api";
import BasePanel from "../BasePanel";
@@ -77,6 +78,8 @@ const successState: State = {
};
type StrictProviderModelEnum = Exclude;
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const isValidationError = (v: any): v is ValidationError => instanceOfValidationError(v);
@customElement("ak-application-wizard-commit-application")
export class ApplicationWizardCommitApplication extends BasePanel {
@@ -152,7 +155,23 @@ export class ApplicationWizardCommitApplication extends BasePanel {
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch(async (resolution: any) => {
- this.errors = await parseAPIError(resolution);
+ const errors = await parseAPIError(resolution);
+ console.log(errors);
+
+ // THIS is a really gross special case; if the user is duplicating the name of an
+ // existing provider, the error appears on the `app` (!) error object. We have to
+ // move that to the `provider.name` error field so it shows up in the right place.
+ if (isValidationError(errors) && Array.isArray(errors?.app?.provider)) {
+ const providerError = errors.app.provider;
+ errors.provider = errors.provider ?? {};
+ errors.provider.name = providerError;
+ delete errors.app.provider;
+ if (Object.keys(errors.app).length === 0) {
+ delete errors.app;
+ }
+ }
+
+ this.errors = errors;
this.dispatchWizardUpdate({
update: {
...this.wizard,
diff --git a/web/src/admin/groups/RelatedUserList.ts b/web/src/admin/groups/RelatedUserList.ts
index 72a9b62a18..ed133ede3d 100644
--- a/web/src/admin/groups/RelatedUserList.ts
+++ b/web/src/admin/groups/RelatedUserList.ts
@@ -1,9 +1,11 @@
import "@goauthentik/admin/users/ServiceAccountForm";
import "@goauthentik/admin/users/UserActiveForm";
import "@goauthentik/admin/users/UserForm";
+import "@goauthentik/admin/users/UserImpersonateForm";
import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import { PFSize } from "@goauthentik/common/enums.js";
import { MessageLevel } from "@goauthentik/common/messages";
import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils";
@@ -213,20 +215,22 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
${canImpersonate
? html`
- {
- return new CoreApi(DEFAULT_CONFIG)
- .coreUsersImpersonateCreate({
- id: item.pk,
- })
- .then(() => {
- window.location.href = "/";
- });
- }}
- >
- ${msg("Impersonate")}
-
+
+ ${msg("Impersonate")}
+ ${msg("Impersonate")} ${item.username}
+
+
+
`
: html``}`,
];
diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts
index 61a6ec1843..b2762e7f45 100644
--- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts
+++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts
@@ -1,6 +1,18 @@
import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import "@goauthentik/components/ak-radio-input";
+import "@goauthentik/components/ak-text-input";
+import "@goauthentik/components/ak-textarea-input";
+import "@goauthentik/elements/ak-array-input.js";
+import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
+import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
+import "@goauthentik/elements/forms/FormGroup";
+import "@goauthentik/elements/forms/HorizontalFormElement";
+import "@goauthentik/elements/forms/Radio";
+import "@goauthentik/elements/forms/SearchSelect";
+import "@goauthentik/elements/utils/TimeDeltaHelp";
+import { css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientTypeEnum, OAuth2Provider, ProvidersApi } from "@goauthentik/api";
@@ -19,6 +31,14 @@ export class OAuth2ProviderFormPage extends BaseProviderForm {
@state()
showClientSecret = true;
+ static get styles() {
+ return super.styles.concat(css`
+ ak-array-input {
+ width: 100%;
+ }
+ `);
+ }
+
async loadInstance(pk: number): Promise {
const provider = await new ProvidersApi(DEFAULT_CONFIG).providersOauth2Retrieve({
id: pk,
diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts
index cbe94ce699..2a23390c7d 100644
--- a/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts
+++ b/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts
@@ -1,9 +1,14 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
-import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils";
+import {
+ IRedirectURIInput,
+ akOAuthRedirectURIInput,
+} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderRedirectURI";
+import { ascii_letters, digits, randomString } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-text-input";
import "@goauthentik/components/ak-textarea-input";
+import "@goauthentik/elements/ak-array-input.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
import "@goauthentik/elements/forms/FormGroup";
@@ -20,7 +25,9 @@ import {
ClientTypeEnum,
FlowsInstancesListDesignationEnum,
IssuerModeEnum,
+ MatchingModeEnum,
OAuth2Provider,
+ RedirectURI,
SubModeEnum,
ValidationError,
} from "@goauthentik/api";
@@ -95,13 +102,13 @@ export const issuerModeOptions = [
const redirectUriHelpMessages = [
msg(
- "Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.",
+ "Valid redirect URIs after a successful authorization flow. Also specify any origins here for Implicit flows.",
),
msg(
"If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.",
),
msg(
- 'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.',
+ 'To allow any redirect URI, set the mode to Regex and the value to ".*". Be aware of the possible security implications this can have.',
),
];
@@ -156,27 +163,36 @@ export function renderForm(
-
-
+ ({ matchingMode: MatchingModeEnum.Strict, url: "" })}
+ .row=${(f?: RedirectURI) =>
+ akOAuthRedirectURIInput({
+ ".redirectURI": f,
+ "style": "width: 100%",
+ "name": "oauth2-redirect-uri",
+ } as unknown as IRedirectURIInput)}
+ >
+
+ ${redirectUriHelp}
+
@@ -238,7 +254,7 @@ export function renderForm(
name="accessCodeValidity"
label=${msg("Access code validity")}
required
- value="${first(provider?.accessCodeValidity, "minutes=1")}"
+ value="${provider?.accessCodeValidity ?? "minutes=1"}"
.bighelp=${html`
${msg("Configure how long access codes are valid for.")}
@@ -248,7 +264,7 @@ export function renderForm(
${msg("Configure how long access tokens are valid for.")}
@@ -260,7 +276,7 @@ export function renderForm(
${msg("Configure how long refresh tokens are valid for.")}
@@ -296,7 +312,7 @@ export function renderForm(
{
+ static get styles() {
+ return [
+ PFBase,
+ PFInputGroup,
+ PFFormControl,
+ css`
+ .pf-c-input-group select {
+ width: 10em;
+ }
+ `,
+ ];
+ }
+
+ @property({ type: Object, attribute: false })
+ redirectURI: RedirectURI = {
+ matchingMode: MatchingModeEnum.Strict,
+ url: "",
+ };
+
+ @queryAll(".ak-form-control")
+ controls?: HTMLInputElement[];
+
+ json() {
+ return Object.fromEntries(
+ Array.from(this.controls ?? []).map((control) => [control.name, control.value]),
+ ) as unknown as RedirectURI;
+ }
+
+ get isValid() {
+ return true;
+ }
+
+ render() {
+ const onChange = () => {
+ this.dispatchEvent(new Event("change", { composed: true, bubbles: true }));
+ };
+
+ return html`
+
+
+
`;
+ }
+}
+
+export function akOAuthRedirectURIInput(properties: IRedirectURIInput) {
+ return html``;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ak-provider-oauth2-redirect-uri": OAuth2ProviderRedirectURI;
+ }
+}
diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts b/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts
index 6bba6b3bcc..d1245b4e94 100644
--- a/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts
+++ b/web/src/admin/providers/oauth2/OAuth2ProviderViewPage.ts
@@ -234,7 +234,11 @@ export class OAuth2ProviderViewPage extends AKElement {
- ${this.provider.redirectUris}
+
+ ${this.provider.redirectUris.map((ru) => {
+ return html`- ${ru.matchingMode}: ${ru.url}
`;
+ })}
+
diff --git a/web/src/admin/providers/proxy/ProxyProviderViewPage.ts b/web/src/admin/providers/proxy/ProxyProviderViewPage.ts
index bf68b9ba1b..c7e42a3228 100644
--- a/web/src/admin/providers/proxy/ProxyProviderViewPage.ts
+++ b/web/src/admin/providers/proxy/ProxyProviderViewPage.ts
@@ -392,9 +392,13 @@ export class ProxyProviderViewPage extends AKElement {
- ${this.provider.redirectUris.split("\n").map((url) => {
- return html`${url} `;
- })}
+
+ ${this.provider.redirectUris.map((ru) => {
+ return html`-
+ ${ru.matchingMode}: ${ru.url}
+
`;
+ })}
+
diff --git a/web/src/admin/stages/captcha/CaptchaStageForm.ts b/web/src/admin/stages/captcha/CaptchaStageForm.ts
index 5bf58079e1..fff23929b6 100644
--- a/web/src/admin/stages/captcha/CaptchaStageForm.ts
+++ b/web/src/admin/stages/captcha/CaptchaStageForm.ts
@@ -2,6 +2,7 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-number-input";
+import "@goauthentik/components/ak-switch-input";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
@@ -80,6 +81,15 @@ export class CaptchaStageForm extends BaseStageForm {
)}
+
+
{
@property({ attribute: false })
group?: Group;
+ @property()
+ defaultPath: string = "users";
+
static get defaultUserAttributes(): { [key: string]: unknown } {
return {};
}
@@ -172,7 +175,7 @@ export class UserForm extends ModelForm {
diff --git a/web/src/admin/users/UserImpersonateForm.ts b/web/src/admin/users/UserImpersonateForm.ts
new file mode 100644
index 0000000000..756f1ae7ab
--- /dev/null
+++ b/web/src/admin/users/UserImpersonateForm.ts
@@ -0,0 +1,40 @@
+import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import "@goauthentik/components/ak-text-input";
+import { Form } from "@goauthentik/elements/forms/Form";
+
+import { msg } from "@lit/localize";
+import { TemplateResult, html } from "lit";
+import { customElement, property } from "lit/decorators.js";
+
+import { CoreApi, ImpersonationRequest } from "@goauthentik/api";
+
+@customElement("ak-user-impersonate-form")
+export class UserImpersonateForm extends Form {
+ @property({ type: Number })
+ instancePk?: number;
+
+ async send(data: ImpersonationRequest): Promise {
+ return new CoreApi(DEFAULT_CONFIG)
+ .coreUsersImpersonateCreate({
+ id: this.instancePk || 0,
+ impersonationRequest: data,
+ })
+ .then(() => {
+ window.location.href = "/";
+ });
+ }
+
+ renderForm(): TemplateResult {
+ return html``;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ak-user-impersonate-form": UserImpersonateForm;
+ }
+}
diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts
index 1264c00814..215b8882bc 100644
--- a/web/src/admin/users/UserListPage.ts
+++ b/web/src/admin/users/UserListPage.ts
@@ -2,6 +2,7 @@ import { AdminInterface } from "@goauthentik/admin/AdminInterface";
import "@goauthentik/admin/users/ServiceAccountForm";
import "@goauthentik/admin/users/UserActiveForm";
import "@goauthentik/admin/users/UserForm";
+import "@goauthentik/admin/users/UserImpersonateForm";
import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
@@ -266,20 +267,22 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
${canImpersonate
? html`
- {
- return new CoreApi(DEFAULT_CONFIG)
- .coreUsersImpersonateCreate({
- id: item.pk,
- })
- .then(() => {
- window.location.href = "/";
- });
- }}
- >
- ${msg("Impersonate")}
-
+
+ ${msg("Impersonate")}
+ ${msg("Impersonate")} ${item.username}
+
+
+
`
: html``}`,
];
@@ -392,7 +395,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
${msg("Create")}
${msg("Create User")}
-
+
@@ -414,6 +417,9 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
) => {
+ this.activePath = ev.detail.path;
+ }}
>
diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts
index 119ffdb371..80f0ceed17 100644
--- a/web/src/admin/users/UserViewPage.ts
+++ b/web/src/admin/users/UserViewPage.ts
@@ -5,6 +5,7 @@ import "@goauthentik/admin/users/UserActiveForm";
import "@goauthentik/admin/users/UserApplicationTable";
import "@goauthentik/admin/users/UserChart";
import "@goauthentik/admin/users/UserForm";
+import "@goauthentik/admin/users/UserImpersonateForm";
import {
renderRecoveryEmailRequest,
requestRecoveryLink,
@@ -208,26 +209,22 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
${canImpersonate
? html`
- {
- return new CoreApi(DEFAULT_CONFIG)
- .coreUsersImpersonateCreate({
- id: user.pk,
- })
- .then(() => {
- window.location.href = "/";
- });
- }}
- >
-
- ${msg("Impersonate")}
-
-
+
+ ${msg("Impersonate")}
+ ${msg("Impersonate")} ${user.username}
+
+
+
`
: nothing}
`;
diff --git a/web/src/common/constants.ts b/web/src/common/constants.ts
index 8dd39b249d..6576af3e47 100644
--- a/web/src/common/constants.ts
+++ b/web/src/common/constants.ts
@@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current";
-export const VERSION = "2024.10.0";
+export const VERSION = "2024.10.4";
export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";";
diff --git a/web/src/common/purify.ts b/web/src/common/purify.ts
index 78fcb61b5d..38f13f635b 100644
--- a/web/src/common/purify.ts
+++ b/web/src/common/purify.ts
@@ -10,10 +10,14 @@ export const DOM_PURIFY_STRICT: DOMPurify.Config = {
ALLOWED_TAGS: ["#text"],
};
+export async function renderStatic(input: TemplateResult): Promise {
+ return await collectResult(render(input));
+}
+
export function purify(input: TemplateResult): TemplateResult {
return html`${until(
(async () => {
- const rendered = await collectResult(render(input));
+ const rendered = await renderStatic(input);
const purified = DOMPurify.sanitize(rendered);
return html`${unsafeHTML(purified)}`;
})(),
diff --git a/web/src/elements/AkControlElement.ts b/web/src/elements/AkControlElement.ts
index 33dc7f2d86..984d5504e8 100644
--- a/web/src/elements/AkControlElement.ts
+++ b/web/src/elements/AkControlElement.ts
@@ -8,13 +8,21 @@ import { AKElement } from "./Base";
* extracting the value.
*
*/
-export class AkControlElement extends AKElement {
+export class AkControlElement extends AKElement {
constructor() {
super();
this.dataset.akControl = "true";
}
- json() {
+ json(): T {
throw new Error("Controllers using this protocol must override this method");
}
+
+ get toJson(): T {
+ return this.json();
+ }
+
+ get isValid(): boolean {
+ return true;
+ }
}
diff --git a/web/src/elements/TreeView.ts b/web/src/elements/TreeView.ts
index fc31040d1f..5fff2d8492 100644
--- a/web/src/elements/TreeView.ts
+++ b/web/src/elements/TreeView.ts
@@ -89,6 +89,9 @@ export class TreeViewNode extends AKElement {
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
+ detail: {
+ path: this.fullPath,
+ },
}),
);
}}
diff --git a/web/src/elements/ak-array-input.ts b/web/src/elements/ak-array-input.ts
new file mode 100644
index 0000000000..86addde310
--- /dev/null
+++ b/web/src/elements/ak-array-input.ts
@@ -0,0 +1,173 @@
+import { AkControlElement } from "@goauthentik/elements/AkControlElement";
+import { bound } from "@goauthentik/elements/decorators/bound";
+import { type Spread } from "@goauthentik/elements/types";
+import { randomId } from "@goauthentik/elements/utils/randomId.js";
+import { spread } from "@open-wc/lit-helpers";
+
+import { msg } from "@lit/localize";
+import { TemplateResult, css, html, nothing } from "lit";
+import { customElement, property, queryAll } from "lit/decorators.js";
+import { repeat } from "lit/directives/repeat.js";
+
+import PFButton from "@patternfly/patternfly/components/Button/button.css";
+import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
+import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
+import PFBase from "@patternfly/patternfly/patternfly-base.css";
+
+type InputCell = (el: T) => TemplateResult | typeof nothing;
+
+export interface IArrayInput {
+ row: InputCell;
+ newItem: () => T;
+ items: T[];
+ validate?: boolean;
+ validator?: (_: T[]) => boolean;
+}
+
+type Keyed = { key: string; item: T };
+
+@customElement("ak-array-input")
+export class ArrayInput extends AkControlElement implements IArrayInput {
+ static get styles() {
+ return [
+ PFBase,
+ PFButton,
+ PFInputGroup,
+ PFFormControl,
+ css`
+ select.pf-c-form-control {
+ width: 100px;
+ }
+ .pf-c-input-group {
+ padding-bottom: 0;
+ }
+ .ak-plus-button {
+ display: flex;
+ justify-content: flex-end;
+ flex-direction: row;
+ }
+ .ak-input-group {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ }
+ `,
+ ];
+ }
+
+ @property({ type: Boolean })
+ validate = false;
+
+ @property({ type: Object, attribute: false })
+ validator?: (_: T[]) => boolean;
+
+ @property({ type: Array, attribute: false })
+ row!: InputCell;
+
+ @property({ type: Object, attribute: false })
+ newItem!: () => T;
+
+ _items: Keyed[] = [];
+
+ // This magic creates a semi-reliable key on which Lit's `repeat` directive can control its
+ // interaction. Without it, we get undefined behavior in the re-rendering of the array.
+ @property({ type: Array, attribute: false })
+ set items(items: T[]) {
+ const olditems = new Map(
+ (this._items ?? []).map((key, item) => [JSON.stringify(item), key]),
+ );
+ const newitems = items.map((item) => ({
+ item,
+ key: olditems.get(JSON.stringify(item))?.key ?? randomId(),
+ }));
+ this._items = newitems;
+ }
+
+ get items() {
+ return this._items.map(({ item }) => item);
+ }
+
+ @queryAll("div.ak-input-group")
+ inputGroups?: HTMLDivElement[];
+
+ json() {
+ if (!this.inputGroups) {
+ throw new Error("Could not find input group collection in ak-array-input");
+ }
+ return this.items;
+ }
+
+ get isValid() {
+ if (!this.validate) {
+ return true;
+ }
+
+ const oneIsValid = (g: HTMLDivElement) =>
+ g.querySelector>("[name]")?.isValid ?? true;
+ const allAreValid = Array.from(this.inputGroups ?? []).every(oneIsValid);
+ return allAreValid && (this.validator ? this.validator(this.items) : true);
+ }
+
+ itemsFromDom(): T[] {
+ return Array.from(this.inputGroups ?? [])
+ .map(
+ (group) =>
+ group.querySelector>("[name]")?.json() ??
+ null,
+ )
+ .filter((i) => i !== null);
+ }
+
+ sendChange() {
+ this.dispatchEvent(new Event("change", { composed: true, bubbles: true }));
+ }
+
+ @bound
+ onChange() {
+ this.items = this.itemsFromDom();
+ this.sendChange();
+ }
+
+ @bound
+ addNewGroup() {
+ this.items = [...this.itemsFromDom(), this.newItem()];
+ this.sendChange();
+ }
+
+ renderDeleteButton(idx: number) {
+ const deleteOneGroup = () => {
+ this.items = [...this.items.slice(0, idx), ...this.items.slice(idx + 1)];
+ this.sendChange();
+ };
+
+ return html``;
+ }
+
+ render() {
+ return html`
+ ${repeat(
+ this._items,
+ (item: Keyed
) => item.key,
+ (item: Keyed, idx) =>
+ html` this.onChange()}>
+ ${this.row(item.item)}${this.renderDeleteButton(idx)}
+
`,
+ )}
+
+ `;
+ }
+}
+
+export function akArrayInput(properties: IArrayInput) {
+ return html``;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ak-array-input": ArrayInput;
+ }
+}
diff --git a/web/src/elements/ak-locale-context/definitions.ts b/web/src/elements/ak-locale-context/definitions.ts
index 76eafccf6e..8d22a024bc 100644
--- a/web/src/elements/ak-locale-context/definitions.ts
+++ b/web/src/elements/ak-locale-context/definitions.ts
@@ -42,18 +42,19 @@ const debug: LocaleRow = [
// prettier-ignore
const LOCALE_TABLE: LocaleRow[] = [
+ ["de", /^de([_-]|$)/i, () => msg("German"), async () => await import("@goauthentik/locales/de")],
["en", /^en([_-]|$)/i, () => msg("English"), async () => await import("@goauthentik/locales/en")],
["es", /^es([_-]|$)/i, () => msg("Spanish"), async () => await import("@goauthentik/locales/es")],
- ["de", /^de([_-]|$)/i, () => msg("German"), async () => await import("@goauthentik/locales/de")],
["fr", /^fr([_-]|$)/i, () => msg("French"), async () => await import("@goauthentik/locales/fr")],
+ ["it", /^it([_-]|$)/i, () => msg("Italian"), async () => await import("@goauthentik/locales/it")],
["ko", /^ko([_-]|$)/i, () => msg("Korean"), async () => await import("@goauthentik/locales/ko")],
["nl", /^nl([_-]|$)/i, () => msg("Dutch"), async () => await import("@goauthentik/locales/nl")],
["pl", /^pl([_-]|$)/i, () => msg("Polish"), async () => await import("@goauthentik/locales/pl")],
["ru", /^ru([_-]|$)/i, () => msg("Russian"), async () => await import("@goauthentik/locales/ru")],
["tr", /^tr([_-]|$)/i, () => msg("Turkish"), async () => await import("@goauthentik/locales/tr")],
- ["zh-Hant", /^zh[_-](HK|Hant)/i, () => msg("Chinese (traditional)"), async () => await import("@goauthentik/locales/zh-Hant")],
["zh_TW", /^zh[_-]TW$/i, () => msg("Taiwanese Mandarin"), async () => await import("@goauthentik/locales/zh_TW")],
["zh-Hans", /^zh(\b|_)/i, () => msg("Chinese (simplified)"), async () => await import("@goauthentik/locales/zh-Hans")],
+ ["zh-Hant", /^zh[_-](HK|Hant)/i, () => msg("Chinese (traditional)"), async () => await import("@goauthentik/locales/zh-Hant")],
debug
];
diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts
index 10609d5208..08ca5c2982 100644
--- a/web/src/elements/forms/Form.ts
+++ b/web/src/elements/forms/Form.ts
@@ -35,7 +35,7 @@ export interface KeyUnknown {
// Literally the only field `assignValue()` cares about.
type HTMLNamedElement = Pick;
-type AkControlElement = HTMLInputElement & { json: () => string | string[] };
+export type AkControlElement = HTMLInputElement & { json: () => T };
/**
* Recursively assign `value` into `json` while interpreting the dot-path of `element.name`
diff --git a/web/src/elements/forms/HorizontalFormElement.ts b/web/src/elements/forms/HorizontalFormElement.ts
index 5e4b9bcc6e..8d995a48e7 100644
--- a/web/src/elements/forms/HorizontalFormElement.ts
+++ b/web/src/elements/forms/HorizontalFormElement.ts
@@ -2,7 +2,7 @@ import { convertToSlug } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { FormGroup } from "@goauthentik/elements/forms/FormGroup";
-import { msg } from "@lit/localize";
+import { msg, str } from "@lit/localize";
import { CSSResult, css } from "lit";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -33,7 +33,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
* where the field isn't available for the user to view unless they explicitly request to be able
* to see the content; otherwise, a dead password field is shown. There are 10 uses of this
* feature.
- *
+ *
*/
const isAkControl = (el: unknown): boolean =>
@@ -86,7 +86,7 @@ export class HorizontalFormElement extends AKElement {
writeOnlyActivated = false;
@property({ attribute: false })
- errorMessages: string[] = [];
+ errorMessages: string[] | string[][] = [];
@property({ type: Boolean })
slugMode = false;
@@ -183,6 +183,16 @@ export class HorizontalFormElement extends AKElement {
`
: html``}
${this.errorMessages.map((message) => {
+ if (message instanceof Object) {
+ return html`${Object.entries(message).map(([field, errMsg]) => {
+ return html`
+ ${msg(str`${field}: ${errMsg}`)}
+
`;
+ })}`;
+ }
return html`
${message}
`;
diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts
index 662e70b0e1..daf292a2f6 100644
--- a/web/src/elements/forms/SearchSelect/SearchSelect.ts
+++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts
@@ -4,7 +4,6 @@ import { groupBy } from "@goauthentik/common/utils";
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import type { GroupedOptions, SelectGroup, SelectOption } from "@goauthentik/elements/types.js";
-import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { randomId } from "@goauthentik/elements/utils/randomId.js";
import { msg } from "@lit/localize";
@@ -32,10 +31,7 @@ export interface ISearchSelectBase {
emptyOption: string;
}
-export class SearchSelectBase
- extends CustomEmitterElement(AkControlElement)
- implements ISearchSelectBase
-{
+export class SearchSelectBase extends AkControlElement implements ISearchSelectBase {
static get styles() {
return [PFBase];
}
@@ -54,7 +50,7 @@ export class SearchSelectBase
// A function which returns the currently selected object's primary key, used for serialization
// into forms.
- value!: (element: T | undefined) => unknown;
+ 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
@@ -105,7 +101,7 @@ export class SearchSelectBase
@state()
error?: APIErrorTypes;
- public toForm(): unknown {
+ public toForm(): string {
if (!this.objects) {
throw new PreventFormSubmit(msg("Loading options..."));
}
@@ -116,6 +112,16 @@ export class SearchSelectBase
return this.toForm();
}
+ protected dispatchChangeEvent(value: T | undefined) {
+ this.dispatchEvent(
+ new CustomEvent("ak-change", {
+ composed: true,
+ bubbles: true,
+ detail: { value },
+ }),
+ );
+ }
+
public async updateData() {
if (this.isFetchingData) {
return Promise.resolve();
@@ -127,7 +133,7 @@ export class SearchSelectBase
objects.forEach((obj) => {
if (this.selected && this.selected(obj, objects || [])) {
this.selectedObject = obj;
- this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
+ this.dispatchChangeEvent(this.selectedObject);
}
});
this.objects = objects;
@@ -165,7 +171,7 @@ export class SearchSelectBase
this.query = value;
this.updateData()?.then(() => {
- this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
+ this.dispatchChangeEvent(this.selectedObject);
});
}
@@ -173,7 +179,7 @@ export class SearchSelectBase
const value = (event.target as SearchSelectView).value;
if (value === undefined) {
this.selectedObject = undefined;
- this.dispatchCustomEvent("ak-change", { value: undefined });
+ this.dispatchChangeEvent(undefined);
return;
}
const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === value);
@@ -181,7 +187,7 @@ export class SearchSelectBase
console.warn(`ak-search-select: No corresponding object found for value (${value}`);
}
this.selectedObject = selected;
- this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
+ this.dispatchChangeEvent(this.selectedObject);
}
private getGroupedItems(): GroupedOptions {
diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts
index 1f55bb32c8..9d0c5524b5 100644
--- a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts
+++ b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts
@@ -7,7 +7,7 @@ export interface ISearchSelectApi {
fetchObjects: (query?: string) => Promise;
renderElement: (element: T) => string;
renderDescription?: (element: T) => string | TemplateResult;
- value: (element: T | undefined) => unknown;
+ value: (element: T | undefined) => string;
selected?: (element: T, elements: T[]) => boolean;
groupBy?: (items: T[]) => [string, T[]][];
}
diff --git a/web/src/elements/forms/SearchSelect/ak-search-select.ts b/web/src/elements/forms/SearchSelect/ak-search-select.ts
index 284ae02098..52af07c266 100644
--- a/web/src/elements/forms/SearchSelect/ak-search-select.ts
+++ b/web/src/elements/forms/SearchSelect/ak-search-select.ts
@@ -9,7 +9,7 @@ export interface ISearchSelect extends ISearchSelectBase {
fetchObjects: (query?: string) => Promise;
renderElement: (element: T) => string;
renderDescription?: (element: T) => string | TemplateResult;
- value: (element: T | undefined) => unknown;
+ value: (element: T | undefined) => string;
selected?: (element: T, elements: T[]) => boolean;
groupBy: (items: T[]) => [string, T[]][];
}
@@ -69,7 +69,7 @@ export class SearchSelect extends SearchSelectBase implements ISearchSelec
// A function which returns the currently selected object's primary key, used for serialization
// into forms.
@property({ attribute: false })
- value!: (element: T | undefined) => unknown;
+ 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
diff --git a/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts b/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts
index bcc3f59a6f..8ff000fb23 100644
--- a/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts
+++ b/web/src/elements/forms/SearchSelect/stories/ak-search-select.stories.ts
@@ -92,7 +92,7 @@ export const GroupedAndEz = () => {
const config: ISearchSelectApi = {
fetchObjects: getSamples,
renderElement: (sample: Sample) => sample.name,
- value: (sample: Sample | undefined) => sample?.pk,
+ value: (sample: Sample | undefined) => sample?.pk ?? "",
groupBy: (samples: Sample[]) =>
groupBy(samples, (sample: Sample) => sample.season[0] ?? ""),
};
diff --git a/web/src/elements/stories/ak-array-input.stories.ts b/web/src/elements/stories/ak-array-input.stories.ts
new file mode 100644
index 0000000000..c48d802df9
--- /dev/null
+++ b/web/src/elements/stories/ak-array-input.stories.ts
@@ -0,0 +1,96 @@
+import "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
+import { FooterLinkInput } from "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
+import "@goauthentik/elements/messages/MessageContainer";
+import { Meta, StoryObj, WebComponentsRenderer } from "@storybook/web-components";
+import { DecoratorFunction } from "storybook/internal/types";
+
+import { html } from "lit";
+
+import { FooterLink } from "@goauthentik/api";
+
+import "../ak-array-input.js";
+import { IArrayInput } from "../ak-array-input.js";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type Decorator = DecoratorFunction;
+
+const metadata: Meta> = {
+ title: "Elements / Array Input",
+ component: "ak-array-input",
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "A table input object, in which multiple rows of related inputs can be grouped.",
+ },
+ },
+ },
+ decorators: [
+ (story: Decorator) => {
+ window.setTimeout(() => {
+ const menu = document.getElementById("ak-array-input");
+ if (!menu) {
+ throw new Error("Test was not initialized correctly.");
+ }
+ const messages = document.getElementById("reported-value");
+ menu.addEventListener("change", (event: Event) => {
+ if (!event?.target) {
+ return;
+ }
+ const target = event.target as FooterLinkInput;
+ messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.isValid ? "Yes" : "No"}`;
+ });
+ }, 250);
+
+ return html`
+
+
+
Story:
+ ${
+ // @ts-expect-error The types for web components are not well-defined in Storybook yet }
+ story()
+ }
+
+
+
`;
+ },
+ ],
+};
+
+export default metadata;
+
+type Story = StoryObj;
+
+const items: FooterLink[] = [
+ { name: "authentik", href: "https://goauthentik.io" },
+ { name: "authentik docs", href: "https://docs.goauthentik.io/docs/" },
+];
+
+export const Default: Story = {
+ render: () =>
+ html` ({ name: "", href: "" })}
+ .row=${(f?: FooterLink) =>
+ html`
+ `}
+ validate
+ >`,
+};
diff --git a/web/src/elements/tests/ak-array-input.test.ts b/web/src/elements/tests/ak-array-input.test.ts
new file mode 100644
index 0000000000..214178ff87
--- /dev/null
+++ b/web/src/elements/tests/ak-array-input.test.ts
@@ -0,0 +1,55 @@
+import "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
+import { render } from "@goauthentik/elements/tests/utils.js";
+import { $, expect } from "@wdio/globals";
+
+import { html } from "lit";
+
+import { FooterLink } from "@goauthentik/api";
+
+import "../ak-array-input.js";
+
+const sampleItems: FooterLink[] = [
+ { name: "authentik", href: "https://goauthentik.io" },
+ { name: "authentik docs", href: "https://docs.goauthentik.io/docs/" },
+];
+
+describe("ak-array-input", () => {
+ afterEach(async () => {
+ await browser.execute(async () => {
+ await document.body.querySelector("ak-array-input")?.remove();
+ if (document.body["_$litPart$"]) {
+ // @ts-expect-error expression of type '"_$litPart$"' is added by Lit
+ await delete document.body["_$litPart$"];
+ }
+ });
+ });
+
+ const component = (items: FooterLink[] = []) =>
+ render(
+ html` ({ name: "", href: "" })}
+ .row=${(f?: FooterLink) =>
+ html`
+ `}
+ validate
+ >`,
+ );
+
+ it("should render an empty control", async () => {
+ await component();
+ const link = await $("ak-array-input");
+ await browser.pause(500);
+ await expect(await link.getProperty("isValid")).toStrictEqual(true);
+ await expect(await link.getProperty("toJson")).toEqual([]);
+ });
+
+ it("should render a populated component", async () => {
+ await component(sampleItems);
+ const link = await $("ak-array-input");
+ await browser.pause(500);
+ await expect(await link.getProperty("isValid")).toStrictEqual(true);
+ await expect(await link.getProperty("toJson")).toEqual(sampleItems);
+ });
+});
diff --git a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn.ts b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn.ts
index 3bbb2e9892..5277f45d72 100644
--- a/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn.ts
+++ b/web/src/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn.ts
@@ -107,7 +107,7 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
?loading="${this.authenticating}"
header=${this.authenticating
? msg("Authenticating...")
- : this.errorMessage || msg("Failed to authenticate")}
+ : this.errorMessage || msg("Loading")}
icon="fa-times"
>
diff --git a/web/src/flow/stages/captcha/CaptchaStage.stories.ts b/web/src/flow/stages/captcha/CaptchaStage.stories.ts
index a6196d9766..bd4eb77916 100644
--- a/web/src/flow/stages/captcha/CaptchaStage.stories.ts
+++ b/web/src/flow/stages/captcha/CaptchaStage.stories.ts
@@ -10,7 +10,7 @@ import "../../../stories/flow-interface";
import "./CaptchaStage";
export default {
- title: "Flow / Stages / CaptchaStage",
+ title: "Flow / Stages / Captcha",
};
export const LoadingNoChallenge = () => {
@@ -25,92 +25,60 @@ export const LoadingNoChallenge = () => {
`;
};
-export const ChallengeGoogleReCaptcha: StoryObj = {
- render: ({ theme, challenge }) => {
- return html`
- `;
- },
- args: {
- theme: "automatic",
- challenge: {
- pendingUser: "foo",
- pendingUserAvatar: "https://picsum.photos/64",
- jsUrl: "https://www.google.com/recaptcha/api.js",
- siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
- } as CaptchaChallenge,
- },
- argTypes: {
- theme: {
- options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
- control: {
- type: "select",
+function captchaFactory(challenge: CaptchaChallenge): StoryObj {
+ return {
+ render: ({ theme, challenge }) => {
+ return html`
+ `;
+ },
+ args: {
+ theme: "automatic",
+ challenge: challenge,
+ },
+ argTypes: {
+ theme: {
+ options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
+ control: {
+ type: "select",
+ },
},
},
- },
-};
+ };
+}
-export const ChallengeHCaptcha: StoryObj = {
- render: ({ theme, challenge }) => {
- return html`
- `;
- },
- args: {
- theme: "automatic",
- challenge: {
- pendingUser: "foo",
- pendingUserAvatar: "https://picsum.photos/64",
- jsUrl: "https://js.hcaptcha.com/1/api.js",
- siteKey: "10000000-ffff-ffff-ffff-000000000001",
- } as CaptchaChallenge,
- },
- argTypes: {
- theme: {
- options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
- control: {
- type: "select",
- },
- },
- },
-};
+export const ChallengeHCaptcha = captchaFactory({
+ pendingUser: "foo",
+ pendingUserAvatar: "https://picsum.photos/64",
+ jsUrl: "https://js.hcaptcha.com/1/api.js",
+ siteKey: "10000000-ffff-ffff-ffff-000000000001",
+ interactive: true,
+} as CaptchaChallenge);
-export const ChallengeTurnstile: StoryObj = {
- render: ({ theme, challenge }) => {
- return html`
- `;
- },
- args: {
- theme: "automatic",
- challenge: {
- pendingUser: "foo",
- pendingUserAvatar: "https://picsum.photos/64",
- jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
- siteKey: "1x00000000000000000000BB",
- } as CaptchaChallenge,
- },
- argTypes: {
- theme: {
- options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
- control: {
- type: "select",
- },
- },
- },
-};
+// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
+export const ChallengeTurnstileVisible = captchaFactory({
+ pendingUser: "foo",
+ pendingUserAvatar: "https://picsum.photos/64",
+ jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
+ siteKey: "1x00000000000000000000AA",
+ interactive: true,
+} as CaptchaChallenge);
+export const ChallengeTurnstileInvisible = captchaFactory({
+ pendingUser: "foo",
+ pendingUserAvatar: "https://picsum.photos/64",
+ jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
+ siteKey: "1x00000000000000000000BB",
+ interactive: true,
+} as CaptchaChallenge);
+export const ChallengeTurnstileForce = captchaFactory({
+ pendingUser: "foo",
+ pendingUserAvatar: "https://picsum.photos/64",
+ jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
+ siteKey: "3x00000000000000000000FF",
+ interactive: true,
+} as CaptchaChallenge);
diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts
index af37ab383c..24a19a6dab 100644
--- a/web/src/flow/stages/captcha/CaptchaStage.ts
+++ b/web/src/flow/stages/captcha/CaptchaStage.ts
@@ -1,16 +1,17 @@
///
+import { renderStatic } from "@goauthentik/common/purify";
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
+import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import type { TurnstileObject } from "turnstile-types";
import { msg } from "@lit/localize";
-import { CSSResult, PropertyValues, html } from "lit";
+import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
-import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
@@ -24,12 +25,22 @@ interface TurnstileWindow extends Window {
}
type TokenHandler = (token: string) => void;
-const captchaContainerID = "captcha-container";
-
@customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage {
static get styles(): CSSResult[] {
- return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
+ return [
+ PFBase,
+ PFLogin,
+ PFForm,
+ PFFormControl,
+ PFTitle,
+ css`
+ iframe {
+ width: 100%;
+ height: 73px; /* tmp */
+ }
+ `,
+ ];
}
handlers = [this.handleGReCaptcha, this.handleHCaptcha, this.handleTurnstile];
@@ -38,14 +49,17 @@ export class CaptchaStage extends BaseStage {
this.host.submit({ component: "ak-stage-captcha", token });
@@ -53,8 +67,70 @@ export class CaptchaStage extends BaseStage,
+ ) {
+ const msg = ev.data;
+ if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") {
+ return;
+ }
+ if (msg.message !== "captcha") {
+ return;
+ }
+ this.onTokenChange(msg.token);
+ }
+
+ async renderFrame(captchaElement: TemplateResult) {
+ this.captchaFrame.contentWindow?.document.open();
+ this.captchaFrame.contentWindow?.document.write(
+ await renderStatic(
+ html`
+
+
+ ${captchaElement}
+
+
+
+ `,
+ ),
+ );
+ this.captchaFrame.contentWindow?.document.close();
}
updated(changedProperties: PropertyValues) {
@@ -64,15 +140,15 @@ export class CaptchaStage extends BaseStage {
+ this.scriptElement.onload = async () => {
console.debug("authentik/stages/captcha: script loaded");
let found = false;
let lastError = undefined;
- this.handlers.forEach((handler) => {
+ this.handlers.forEach(async (handler) => {
let handlerFound = false;
try {
console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`);
- handlerFound = handler.apply(this);
+ handlerFound = await handler.apply(this);
if (handlerFound) {
console.debug(
`authentik/stages/captcha[${handler.name}]: handler succeeded`,
@@ -96,51 +172,79 @@ export class CaptchaStage extends BaseStage el.remove());
document.head.appendChild(this.scriptElement);
+ if (!this.challenge.interactive) {
+ document.body.appendChild(this.captchaDocumentContainer);
+ }
}
}
- handleGReCaptcha(): boolean {
+ async handleGReCaptcha(): Promise {
if (!Object.hasOwn(window, "grecaptcha")) {
return false;
}
- this.captchaInteractive = false;
- document.body.appendChild(this.captchaContainer);
- grecaptcha.ready(() => {
- const captchaId = grecaptcha.render(this.captchaContainer, {
+ if (this.challenge.interactive) {
+ this.renderFrame(
+ html``,
+ );
+ } else {
+ grecaptcha.ready(() => {
+ const captchaId = grecaptcha.render(this.captchaDocumentContainer, {
+ sitekey: this.challenge.siteKey,
+ callback: this.onTokenChange,
+ size: "invisible",
+ });
+ grecaptcha.execute(captchaId);
+ });
+ }
+ return true;
+ }
+
+ async handleHCaptcha(): Promise {
+ if (!Object.hasOwn(window, "hcaptcha")) {
+ return false;
+ }
+ if (this.challenge.interactive) {
+ this.renderFrame(
+ html` `,
+ );
+ } else {
+ const captchaId = hcaptcha.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
size: "invisible",
});
- grecaptcha.execute(captchaId);
- });
- return true;
- }
-
- handleHCaptcha(): boolean {
- if (!Object.hasOwn(window, "hcaptcha")) {
- return false;
+ hcaptcha.execute(captchaId);
}
- this.captchaInteractive = false;
- document.body.appendChild(this.captchaContainer);
- const captchaId = hcaptcha.render(this.captchaContainer, {
- sitekey: this.challenge.siteKey,
- callback: this.onTokenChange,
- size: "invisible",
- });
- hcaptcha.execute(captchaId);
return true;
}
- handleTurnstile(): boolean {
+ async handleTurnstile(): Promise {
if (!Object.hasOwn(window, "turnstile")) {
return false;
}
- this.captchaInteractive = false;
- document.body.appendChild(this.captchaContainer);
- (window as unknown as TurnstileWindow).turnstile.render(`#${captchaContainerID}`, {
- sitekey: this.challenge.siteKey,
- callback: this.onTokenChange,
- });
+ if (this.challenge.interactive) {
+ this.renderFrame(
+ html``,
+ );
+ } else {
+ (window as unknown as TurnstileWindow).turnstile.render(this.captchaDocumentContainer, {
+ sitekey: this.challenge.siteKey,
+ callback: this.onTokenChange,
+ });
+ }
return true;
}
@@ -148,13 +252,19 @@ export class CaptchaStage extends BaseStage `;
}
- if (this.captchaInteractive) {
- return html`${this.captchaContainer}`;
+ if (this.challenge.interactive) {
+ return html`${this.captchaFrame}`;
}
return html``;
}
render() {
+ if (this.embedded) {
+ if (!this.challenge.interactive) {
+ return html``;
+ }
+ return this.renderBody();
+ }
if (!this.challenge) {
return html` `;
}
diff --git a/web/src/flow/stages/identification/IdentificationStage.stories.ts b/web/src/flow/stages/identification/IdentificationStage.stories.ts
new file mode 100644
index 0000000000..af34e5b2ad
--- /dev/null
+++ b/web/src/flow/stages/identification/IdentificationStage.stories.ts
@@ -0,0 +1,87 @@
+import type { StoryObj } from "@storybook/web-components";
+
+import { html } from "lit";
+
+import "@patternfly/patternfly/components/Login/login.css";
+
+import { FlowDesignationEnum, IdentificationChallenge, UiThemeEnum } from "@goauthentik/api";
+
+import "../../../stories/flow-interface";
+import "./IdentificationStage";
+
+export default {
+ title: "Flow / Stages / Identification",
+};
+
+export const LoadingNoChallenge = () => {
+ return html`
+
+ `;
+};
+
+function identificationFactory(challenge: IdentificationChallenge): StoryObj {
+ return {
+ render: ({ theme, challenge }) => {
+ return html`
+ `;
+ },
+ args: {
+ theme: "automatic",
+ challenge: challenge,
+ },
+ argTypes: {
+ theme: {
+ options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
+ control: {
+ type: "select",
+ },
+ },
+ },
+ };
+}
+
+export const ChallengeDefault = identificationFactory({
+ userFields: ["username"],
+ passwordFields: false,
+ flowDesignation: FlowDesignationEnum.Authentication,
+ primaryAction: "Login",
+ showSourceLabels: false,
+ // jsUrl: "https://js.hcaptcha.com/1/api.js",
+ // siteKey: "10000000-ffff-ffff-ffff-000000000001",
+ // interactive: true,
+});
+
+// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
+export const ChallengeCaptchaTurnstileVisible = identificationFactory({
+ userFields: ["username"],
+ passwordFields: false,
+ flowDesignation: FlowDesignationEnum.Authentication,
+ primaryAction: "Login",
+ showSourceLabels: false,
+ flowInfo: {
+ layout: "stacked",
+ cancelUrl: "",
+ title: "Foo",
+ },
+ captchaStage: {
+ pendingUser: "",
+ pendingUserAvatar: "",
+ jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
+ siteKey: "1x00000000000000000000AA",
+ interactive: true,
+ },
+});
diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts
index 0983928eaa..8dd5cdaa43 100644
--- a/web/src/flow/stages/identification/IdentificationStage.ts
+++ b/web/src/flow/stages/identification/IdentificationStage.ts
@@ -282,11 +282,11 @@ export class IdentificationStage extends BaseStage<
? html`
{
this.captchaToken = token;
}}
+ embedded
>
`
: nothing}
diff --git a/web/src/locale-codes.ts b/web/src/locale-codes.ts
index 58fedc38ea..28d514ca1b 100644
--- a/web/src/locale-codes.ts
+++ b/web/src/locale-codes.ts
@@ -15,6 +15,7 @@ export const targetLocales = [
`en`,
`es`,
`fr`,
+ `it`,
`ko`,
`nl`,
`pl`,
@@ -36,6 +37,7 @@ export const allLocales = [
`en`,
`es`,
`fr`,
+ `it`,
`ko`,
`nl`,
`pl`,
diff --git a/web/tests/blueprints/test-admin-user.yaml b/web/tests/blueprints/test-admin-user.yaml
new file mode 100644
index 0000000000..1a0e85e173
--- /dev/null
+++ b/web/tests/blueprints/test-admin-user.yaml
@@ -0,0 +1,16 @@
+version: 1
+entries:
+ - attrs:
+ email: test-admin@goauthentik.io
+ is_active: true
+ name: authentik Default Admin
+ password: test-runner
+ path: users
+ type: internal
+ groups:
+ - !Find [authentik_core.group, [name, "authentik Admins"]]
+ conditions: []
+ identifiers:
+ username: akadmin
+ model: authentik_core.user
+ state: present
diff --git a/web/xliff/de.xlf b/web/xliff/de.xlf
index 6f14b4b8cf..37208473fd 100644
--- a/web/xliff/de.xlf
+++ b/web/xliff/de.xlf
@@ -5741,9 +5741,6 @@ Bindings to groups/users are checked against the user of the event.
One hint, 'New Application Wizard', is currently hidden
-
- External applications that use authentik as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access.
-
Deny message
@@ -6217,9 +6214,6 @@ Bindings to groups/users are checked against the user of the event.
Selected Applications
-
- This option configures the footer links on the flow executor pages. It must be a valid YAML or JSON list and can be used as follows:
-
Last used
@@ -7026,6 +7020,39 @@ Bindings to groups/users are checked against the user of the event.
Endpoint Google Chrome Device Trust is in preview.
+
+
+ Interactive
+
+
+ Enable this flag if the configured captcha requires User-interaction. Required for reCAPTCHA v2, hCaptcha and Cloudflare Turnstile.
+
+
+ Reason
+
+
+ Reason for impersonating the user
+
+
+ Require reason for impersonation
+
+
+ Require administrators to provide a reason for impersonating a user.
+
+
+ Italian
+
+
+ Add entry
+
+
+ Link Title
+
+
+ This option configures the footer links on the flow executor pages. The URL is limited to web and mail addresses. If the name is left blank, the URL will be shown.
+
+
+ External applications that use as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access.