Compare commits

..

4 Commits

Author SHA1 Message Date
13c8cbf03a fix rac tests 2025-03-28 12:55:14 -03:00
1776981f29 Add schema.yml 2025-03-27 18:18:28 -03:00
5a4df95011 Fix tests, add more tests 2025-03-27 18:17:28 -03:00
f2927e5725 first approach 2025-03-27 18:03:25 -03:00
211 changed files with 1305 additions and 2853 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2025.2.3 current_version = 2025.2.2
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
@ -17,8 +17,6 @@ optional_value = final
[bumpversion:file:pyproject.toml] [bumpversion:file:pyproject.toml]
[bumpversion:file:uv.lock]
[bumpversion:file:package.json] [bumpversion:file:package.json]
[bumpversion:file:docker-compose.yml] [bumpversion:file:docker-compose.yml]

View File

@ -43,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build RUN npm run build
# Stage 3: Build go proxy # Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@ -76,7 +76,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP # Stage 4: MaxMind GeoIP
@ -94,9 +94,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv # Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.11 AS uv FROM ghcr.io/astral-sh/uv:0.6.10 AS uv
# Stage 6: Base python image # Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \ ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \ PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2025.2.3" __version__ = "2025.2.2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -46,7 +46,7 @@ LOGGER = get_logger()
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str: def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
"""Cache key where application list for user is saved""" """Cache key where application list for user is saved"""
key = f"{CACHE_PREFIX}app_access/{user_pk}" key = f"{CACHE_PREFIX}/app_access/{user_pk}"
if page_number: if page_number:
key += f"/{page_number}" key += f"/{page_number}"
return key return key

View File

@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from django_filters.filters import BooleanFilter from django_filters.filters import BooleanFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from rest_framework import mixins from rest_framework import mixins
from rest_framework.fields import ReadOnlyField, SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from authentik.core.api.object_types import TypesMixin from authentik.core.api.object_types import TypesMixin
@ -18,10 +18,10 @@ from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer, MetaNameSerializer): class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer""" """Provider Serializer"""
assigned_application_slug = ReadOnlyField(source="application.slug") assigned_application_slug = SerializerMethodField()
assigned_application_name = ReadOnlyField(source="application.name") assigned_application_name = SerializerMethodField()
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug") assigned_backchannel_application_slug = SerializerMethodField()
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name") assigned_backchannel_application_name = SerializerMethodField()
component = SerializerMethodField() component = SerializerMethodField()
@ -31,6 +31,38 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
return "" return ""
return obj.component return obj.component
def get_assigned_application_slug(self, obj: Provider) -> str:
"""Get application slug, return empty string if no application exists"""
try:
return obj.application.slug
except Provider.application.RelatedObjectDoesNotExist:
return ""
def get_assigned_application_name(self, obj: Provider) -> str:
"""Get application name, return empty string if no application exists"""
try:
return obj.application.name
except Provider.application.RelatedObjectDoesNotExist:
return ""
def get_assigned_backchannel_application_slug(self, obj: Provider) -> str:
"""Get backchannel application slug.
Returns an empty string if no backchannel application exists.
"""
if not obj.backchannel_application:
return ""
return obj.backchannel_application.slug or ""
def get_assigned_backchannel_application_name(self, obj: Provider) -> str:
"""Get backchannel application name.
Returns an empty string if no backchannel application exists.
"""
if not obj.backchannel_application:
return ""
return obj.backchannel_application.name or ""
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [

View File

@ -1,14 +1,13 @@
"""User API Views""" """User API Views"""
from datetime import timedelta from datetime import timedelta
from importlib import import_module
from json import loads from json import loads
from typing import Any from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.base import SessionBase from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour from django.db.models.functions import ExtractHour
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -92,7 +91,6 @@ from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
class UserGroupSerializer(ModelSerializer): class UserGroupSerializer(ModelSerializer):
@ -375,7 +373,7 @@ class UsersFilter(FilterSet):
method="filter_attributes", method="filter_attributes",
) )
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser") is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
uuid = UUIDFilter(field_name="uuid") uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path") path = CharFilter(field_name="path")
@ -393,11 +391,6 @@ class UsersFilter(FilterSet):
queryset=Group.objects.all().order_by("name"), queryset=Group.objects.all().order_by("name"),
) )
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(ak_groups__is_superuser=True).distinct()
return queryset.exclude(ak_groups__is_superuser=True).distinct()
def filter_attributes(self, queryset, name, value): def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args""" """Filter attributes by query args"""
try: try:
@ -776,8 +769,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if not instance.is_active: if not instance.is_active:
sessions = AuthenticatedSession.objects.filter(user=instance) sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True) session_ids = sessions.values_list("session_key", flat=True)
for session in session_ids: cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
SessionStore(session).delete()
sessions.delete() sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username) LOGGER.debug("Deleted user's sessions", user=instance.username)
return response return response

View File

@ -761,17 +761,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
@property @property
def component(self) -> str: def component(self) -> str:
"""Return component used to edit this object""" """Return component used to edit this object"""
if self.managed == self.MANAGED_INBUILT:
return ""
raise NotImplementedError raise NotImplementedError
@property @property
def property_mapping_type(self) -> "type[PropertyMapping]": def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object""" """Return property mapping type used by this object"""
if self.managed == self.MANAGED_INBUILT:
from authentik.core.models import PropertyMapping
return PropertyMapping
raise NotImplementedError raise NotImplementedError
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None: def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
@ -786,14 +780,10 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]: def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user to build final properties upon.""" """Get base properties for a user to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError raise NotImplementedError
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]: def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a group to build final properties upon.""" """Get base properties for a group to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError raise NotImplementedError
def __str__(self): def __str__(self):

View File

@ -1,10 +1,7 @@
"""authentik core signals""" """authentik core signals"""
from importlib import import_module
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.base import SessionBase from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache
from django.core.signals import Signal from django.core.signals import Signal
from django.db.models import Model from django.db.models import Model
@ -28,7 +25,6 @@ password_changed = Signal()
login_failed = Signal() login_failed = Signal()
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
@receiver(post_save, sender=Application) @receiver(post_save, sender=Application)
@ -64,7 +60,8 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted""" """Delete session when authenticated session is deleted"""
SessionStore(instance.session_key).delete() cache_key = f"{KEY_PREFIX}{instance.session_key}"
cache.delete(cache_key)
@receiver(pre_save) @receiver(pre_save)

View File

@ -36,7 +36,6 @@ from authentik.flows.planner import (
) )
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET
from authentik.lib.utils.urls import is_url_absolute
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.denied import AccessDeniedResponse from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.utils import delete_none_values from authentik.policies.utils import delete_none_values
@ -210,8 +209,6 @@ class SourceFlowManager:
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user" NEXT_ARG_NAME, "authentik_core:if-user"
) )
if not is_url_absolute(final_redirect):
final_redirect = "authentik_core:if-user"
flow_context.update( flow_context.update(
{ {
# Since we authenticate the user by their token, they have no backend set # Since we authenticate the user by their token, they have no backend set

View File

@ -133,6 +133,8 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": { "provider_obj": {
"assigned_application_name": "allowed", "assigned_application_name": "allowed",
"assigned_application_slug": "allowed", "assigned_application_slug": "allowed",
"assigned_backchannel_application_name": "",
"assigned_backchannel_application_slug": "",
"authentication_flow": None, "authentication_flow": None,
"invalidation_flow": None, "invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk), "authorization_flow": str(self.provider.authorization_flow.pk),
@ -186,6 +188,8 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": { "provider_obj": {
"assigned_application_name": "allowed", "assigned_application_name": "allowed",
"assigned_application_slug": "allowed", "assigned_application_slug": "allowed",
"assigned_backchannel_application_name": "",
"assigned_backchannel_application_slug": "",
"authentication_flow": None, "authentication_flow": None,
"invalidation_flow": None, "invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk), "authorization_flow": str(self.provider.authorization_flow.pk),

View File

@ -3,7 +3,8 @@
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import PropertyMapping from authentik.core.api.providers import ProviderSerializer
from authentik.core.models import Application, PropertyMapping, Provider
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
@ -24,3 +25,51 @@ class TestProvidersAPI(APITestCase):
reverse("authentik_api:provider-types"), reverse("authentik_api:provider-types"),
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_provider_serializer_without_application(self):
"""Test that Provider serializer handles missing application gracefully"""
# Create a provider without an application
provider = Provider.objects.create(name="test-provider")
serializer = ProviderSerializer(instance=provider)
serialized_data = serializer.data
# Check that fields return empty strings when no application exists
self.assertEqual(serialized_data["assigned_application_slug"], "")
self.assertEqual(serialized_data["assigned_application_name"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_name"], "")
def test_provider_serializer_with_application(self):
"""Test that Provider serializer correctly includes application data"""
# Create an application
app = Application.objects.create(name="Test App", slug="test-app")
# Create a provider with an application
provider = Provider.objects.create(name="test-provider-with-app")
app.provider = provider
app.save()
serializer = ProviderSerializer(instance=provider)
serialized_data = serializer.data
# Check that fields return correct values when application exists
self.assertEqual(serialized_data["assigned_application_slug"], "test-app")
self.assertEqual(serialized_data["assigned_application_name"], "Test App")
self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_name"], "")
def test_provider_api_response(self):
"""Test that the API response includes empty strings for missing applications"""
# Create a provider without an application
provider = Provider.objects.create(name="test-provider-api")
response = self.client.get(
reverse("authentik_api:provider-detail", kwargs={"pk": provider.pk}),
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["assigned_application_slug"], "")
self.assertEqual(response.data["assigned_application_name"], "")
self.assertEqual(response.data["assigned_backchannel_application_slug"], "")
self.assertEqual(response.data["assigned_backchannel_application_name"], "")

View File

@ -1,19 +0,0 @@
from django.apps import apps
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.tests.utils import create_test_admin_user
class TestSourceAPI(APITestCase):
def setUp(self) -> None:
self.user = create_test_admin_user()
self.client.force_login(self.user)
def test_builtin_source_used_by(self):
"""Test Providers's types endpoint"""
apps.get_app_config("authentik_core").source_inbuilt()
response = self.client.get(
reverse("authentik_api:source-used-by", kwargs={"slug": "authentik-built-in"}),
)
self.assertEqual(response.status_code, 200)

View File

@ -1,7 +1,6 @@
"""Test Users API""" """Test Users API"""
from datetime import datetime from datetime import datetime
from json import loads
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache
@ -16,12 +15,7 @@ from authentik.core.models import (
User, User,
UserTypes, UserTypes,
) )
from authentik.core.tests.utils import ( from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
create_test_admin_user,
create_test_brand,
create_test_flow,
create_test_user,
)
from authentik.flows.models import FlowDesignation from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
@ -32,7 +26,7 @@ class TestUsersAPI(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.admin = create_test_admin_user() self.admin = create_test_admin_user()
self.user = create_test_user() self.user = User.objects.create(username="test-user")
def test_filter_type(self): def test_filter_type(self):
"""Test API filtering by type""" """Test API filtering by type"""
@ -47,35 +41,6 @@ class TestUsersAPI(APITestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_filter_is_superuser(self):
"""Test API filtering by superuser status"""
User.objects.all().delete()
admin = create_test_admin_user()
self.client.force_login(admin)
# Test superuser
response = self.client.get(
reverse("authentik_api:user-list"),
data={
"is_superuser": True,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1)
self.assertEqual(body["results"][0]["username"], admin.username)
# Test non-superuser
user = create_test_user()
response = self.client.get(
reverse("authentik_api:user-list"),
data={
"is_superuser": False,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1, body)
self.assertEqual(body["results"][0]["username"], user.username)
def test_list_with_groups(self): def test_list_with_groups(self):
"""Test listing with groups""" """Test listing with groups"""
self.client.force_login(self.admin) self.client.force_login(self.admin)
@ -134,8 +99,6 @@ class TestUsersAPI(APITestCase):
def test_recovery_email_no_flow(self): def test_recovery_email_no_flow(self):
"""Test user recovery link (no recovery flow set)""" """Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin) self.client.force_login(self.admin)
self.user.email = ""
self.user.save()
response = self.client.post( response = self.client.post(
reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk}) reverse("authentik_api:user-recovery-email", kwargs={"pk": self.user.pk})
) )

View File

@ -69,7 +69,6 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
SESSION_KEY_GET = "authentik/flows/get" SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post" SESSION_KEY_POST = "authentik/flows/post"
SESSION_KEY_HISTORY = "authentik/flows/history" SESSION_KEY_HISTORY = "authentik/flows/history"
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
QS_KEY_TOKEN = "flow_token" # nosec QS_KEY_TOKEN = "flow_token" # nosec
QS_QUERY = "query" QS_QUERY = "query"
@ -454,7 +453,6 @@ class FlowExecutorView(APIView):
SESSION_KEY_APPLICATION_PRE, SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_PLAN, SESSION_KEY_PLAN,
SESSION_KEY_GET, SESSION_KEY_GET,
SESSION_KEY_AUTH_STARTED,
# We might need the initial POST payloads for later requests # We might need the initial POST payloads for later requests
# SESSION_KEY_POST, # SESSION_KEY_POST,
# We don't delete the history on purpose, as a user might # We don't delete the history on purpose, as a user might

View File

@ -6,22 +6,14 @@ from django.shortcuts import get_object_or_404
from ua_parser.user_agent_parser import Parse from ua_parser.user_agent_parser import Parse
from authentik.core.views.interface import InterfaceView from authentik.core.views.interface import InterfaceView
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.models import Flow
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
class FlowInterfaceView(InterfaceView): class FlowInterfaceView(InterfaceView):
"""Flow interface""" """Flow interface"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["flow"] = flow
if (
not self.request.user.is_authenticated
and flow.designation == FlowDesignation.AUTHENTICATION
):
self.request.session[SESSION_KEY_AUTH_STARTED] = True
self.request.session.save()
kwargs["inspector"] = "inspector" in self.request.GET kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -18,15 +18,6 @@ class SerializerModel(models.Model):
@property @property
def serializer(self) -> type[BaseSerializer]: def serializer(self) -> type[BaseSerializer]:
"""Get serializer for this model""" """Get serializer for this model"""
# Special handling for built-in source
if (
hasattr(self, "managed")
and hasattr(self, "MANAGED_INBUILT")
and self.managed == self.MANAGED_INBUILT
):
from authentik.core.api.sources import SourceSerializer
return SourceSerializer
raise NotImplementedError raise NotImplementedError

View File

@ -35,4 +35,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies" label = "authentik_policies"
verbose_name = "authentik Policies" verbose_name = "authentik Policies"
default = True default = True
mountpoint = "policy/"

View File

@ -1,89 +0,0 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block head %}
{{ block.super }}
<script>
let redirecting = false;
const checkAuth = async () => {
if (redirecting) return true;
const url = "{{ check_auth_url }}";
console.debug("authentik/policies/buffer: Checking authentication...");
try {
const result = await fetch(url, {
method: "HEAD",
});
if (result.status >= 400) {
return false
}
console.debug("authentik/policies/buffer: Continuing");
redirecting = true;
if ("{{ auth_req_method }}" === "post") {
document.querySelector("form").submit();
} else {
window.location.assign("{{ continue_url|escapejs }}");
}
} catch {
return false;
}
};
let timeout = 100;
let offset = 20;
let attempt = 0;
const main = async () => {
attempt += 1;
await checkAuth();
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
setTimeout(main, timeout);
timeout += (offset * attempt);
if (timeout >= 2000) {
timeout = 2000;
}
}
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
await checkAuth();
});
main();
</script>
{% endblock %}
{% block title %}
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
{% trans 'Waiting for authentication...' %}
{% endblock %}
{% block card %}
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
{% if auth_req_method == "post" %}
{% for key, value in auth_req_body.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
{% endif %}
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<div class="pf-c-empty-state__icon">
<span class="pf-c-spinner pf-m-xl" role="progressbar">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
<h1 class="pf-c-title pf-m-lg">
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
</h1>
</div>
</div>
<div class="pf-c-form__group pf-m-action">
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
{% trans "Authenticate in this tab" %}
</a>
</div>
</form>
{% endblock %}

View File

@ -1,121 +0,0 @@
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.models import Application, Provider
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response
from authentik.policies.views import (
QS_BUFFER_ID,
SESSION_KEY_BUFFER,
BufferedPolicyAccessView,
BufferView,
PolicyAccessView,
)
class TestPolicyViews(TestCase):
"""Test PolicyAccessView"""
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.user = create_test_user()
def test_pav(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
class TestView(PolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = self.user
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, b"foo")
def test_pav_buffer(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
def test_pav_buffer_skip(self):
"""Test simple policy access view (skip buffer)"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/?skip_buffer=true")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
def test_buffer(self):
"""Test buffer view"""
uid = generate_id()
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
ts = generate_id()
req.session[SESSION_KEY_BUFFER % uid] = {
"method": "get",
"body": {},
"url": f"/{ts}",
}
req.session.save()
res = BufferView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertIn(ts, res.render().content.decode())

View File

@ -1,14 +1,7 @@
"""API URLs""" """API URLs"""
from django.urls import path
from authentik.policies.api.bindings import PolicyBindingViewSet from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.views import BufferView
urlpatterns = [
path("buffer", BufferView.as_view(), name="buffer"),
]
api_urlpatterns = [ api_urlpatterns = [
("policies/all", PolicyViewSet), ("policies/all", PolicyViewSet),

View File

@ -1,37 +1,23 @@
"""authentik access helper classes""" """authentik access helper classes"""
from typing import Any from typing import Any
from uuid import uuid4
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse, QueryDict from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic.base import TemplateView, View from django.views.generic.base import View
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Application, Provider, User from authentik.core.models import Application, Provider, User
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import (
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_AUTH_STARTED,
SESSION_KEY_PLAN,
SESSION_KEY_POST,
)
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.denied import AccessDeniedResponse from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyRequest, PolicyResult from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger() LOGGER = get_logger()
QS_BUFFER_ID = "af_bf_id"
QS_SKIP_BUFFER = "skip_buffer"
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
class RequestValidationError(SentryIgnoredException): class RequestValidationError(SentryIgnoredException):
@ -139,65 +125,3 @@ class PolicyAccessView(AccessMixin, View):
for message in result.messages: for message in result.messages:
messages.error(self.request, _(message)) messages.error(self.request, _(message))
return result return result
def url_with_qs(url: str, **kwargs):
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
parameters are retained"""
if "?" not in url:
return url + f"?{urlencode(kwargs)}"
url, _, qs = url.partition("?")
qs = QueryDict(qs, mutable=True)
qs.update(kwargs)
return url + f"?{urlencode(qs.items())}"
class BufferView(TemplateView):
"""Buffer view"""
template_name = "policies/buffer.html"
def get_context_data(self, **kwargs):
buf_id = self.request.GET.get(QS_BUFFER_ID)
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
kwargs["auth_req_method"] = buffer["method"]
kwargs["auth_req_body"] = buffer["body"]
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
return super().get_context_data(**kwargs)
class BufferedPolicyAccessView(PolicyAccessView):
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
def handle_no_permission(self):
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
if plan:
flow = Flow.objects.filter(pk=plan.flow_pk).first()
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
return super().handle_no_permission()
if not plan and authenticating is None:
LOGGER.debug("Not buffering request, no flow plan active")
return super().handle_no_permission()
if self.request.GET.get(QS_SKIP_BUFFER):
LOGGER.debug("Not buffering request, explicit skip")
return super().handle_no_permission()
buffer_id = str(uuid4())
LOGGER.debug("Buffering access request", bf_id=buffer_id)
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
"body": self.request.POST,
"url": self.request.build_absolute_uri(self.request.get_full_path()),
"method": self.request.method.lower(),
}
return redirect(
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
)
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if QS_BUFFER_ID in self.request.GET:
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
return response

View File

@ -30,7 +30,7 @@ from authentik.flows.stage import StageView
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.types import PolicyRequest from authentik.policies.types import PolicyRequest
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError from authentik.policies.views import PolicyAccessView, RequestValidationError
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
PKCE_METHOD_PLAIN, PKCE_METHOD_PLAIN,
PKCE_METHOD_S256, PKCE_METHOD_S256,
@ -328,7 +328,7 @@ class OAuthAuthorizationParams:
return code return code
class AuthorizationFlowInitView(BufferedPolicyAccessView): class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow""" """OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams params: OAuthAuthorizationParams

View File

@ -74,6 +74,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form", "component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug, "assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name, "assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"verbose_name": "RAC Provider", "verbose_name": "RAC Provider",
"verbose_name_plural": "RAC Providers", "verbose_name_plural": "RAC Providers",
"meta_model_name": "authentik_providers_rac.racprovider", "meta_model_name": "authentik_providers_rac.racprovider",
@ -124,6 +126,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form", "component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug, "assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name, "assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"connection_expiry": "hours=8", "connection_expiry": "hours=8",
"delete_token_on_disconnect": False, "delete_token_on_disconnect": False,
"verbose_name": "RAC Provider", "verbose_name": "RAC Provider",
@ -153,6 +157,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form", "component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug, "assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name, "assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"connection_expiry": "hours=8", "connection_expiry": "hours=8",
"delete_token_on_disconnect": False, "delete_token_on_disconnect": False,
"verbose_name": "RAC Provider", "verbose_name": "RAC Provider",

View File

@ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import RedirectStage from authentik.flows.stage import RedirectStage
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.views import BufferedPolicyAccessView from authentik.policies.views import PolicyAccessView
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
class RACStartView(BufferedPolicyAccessView): class RACStartView(PolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token""" """Start a RAC connection by checking access and creating a connection token"""
endpoint: Endpoint endpoint: Endpoint

View File

@ -1,22 +0,0 @@
# Generated by Django 5.0.13 on 2025-03-31 13:50
import authentik.lib.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_saml", "0017_samlprovider_authn_context_class_ref_mapping"),
]
operations = [
migrations.AlterField(
model_name="samlprovider",
name="acs_url",
field=models.TextField(
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="ACS URL",
),
),
]

View File

@ -10,7 +10,6 @@ from structlog.stdlib import get_logger
from authentik.core.api.object_types import CreatableType from authentik.core.api.object_types import CreatableType
from authentik.core.models import PropertyMapping, Provider from authentik.core.models import PropertyMapping, Provider
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.utils.time import timedelta_string_validator from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.processors.constants import ( from authentik.sources.saml.processors.constants import (
DSA_SHA1, DSA_SHA1,
@ -41,9 +40,7 @@ class SAMLBindings(models.TextChoices):
class SAMLProvider(Provider): class SAMLProvider(Provider):
"""SAML 2.0 Endpoint for applications which support SAML.""" """SAML 2.0 Endpoint for applications which support SAML."""
acs_url = models.TextField( acs_url = models.URLField(verbose_name=_("ACS URL"))
validators=[DomainlessURLValidator(schemes=("http", "https"))], verbose_name=_("ACS URL")
)
audience = models.TextField( audience = models.TextField(
default="", default="",
blank=True, blank=True,

View File

@ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_POST from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.views import BufferedPolicyAccessView from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
LOGGER = get_logger() LOGGER = get_logger()
class SAMLSSOView(BufferedPolicyAccessView): class SAMLSSOView(PolicyAccessView):
"""SAML SSO Base View, which plans a flow and injects our final stage. """SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler.""" Calls get/post handler."""
@ -83,7 +83,7 @@ class SAMLSSOView(BufferedPolicyAccessView):
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't """GET and POST use the same handler, but we can't
override .dispatch easily because BufferedPolicyAccessView's dispatch""" override .dispatch easily because PolicyAccessView's dispatch"""
return self.get(request, application_slug) return self.get(request, application_slug)

View File

@ -1,35 +0,0 @@
# Generated by Django 5.0.13 on 2025-03-31 13:53
import authentik.lib.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_saml", "0017_fix_x509subjectname"),
]
operations = [
migrations.AlterField(
model_name="samlsource",
name="slo_url",
field=models.TextField(
blank=True,
default=None,
help_text="Optional URL if your IDP supports Single-Logout.",
null=True,
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="SLO URL",
),
),
migrations.AlterField(
model_name="samlsource",
name="sso_url",
field=models.TextField(
help_text="URL that the initial Login request is sent to.",
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="SSO URL",
),
),
]

View File

@ -20,7 +20,6 @@ from authentik.crypto.models import CertificateKeyPair
from authentik.flows.challenge import RedirectChallenge from authentik.flows.challenge import RedirectChallenge
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.lib.expression.evaluator import BaseEvaluator from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.utils.time import timedelta_string_validator from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.processors.constants import ( from authentik.sources.saml.processors.constants import (
DSA_SHA1, DSA_SHA1,
@ -92,13 +91,11 @@ class SAMLSource(Source):
help_text=_("Also known as Entity ID. Defaults the Metadata URL."), help_text=_("Also known as Entity ID. Defaults the Metadata URL."),
) )
sso_url = models.TextField( sso_url = models.URLField(
validators=[DomainlessURLValidator(schemes=("http", "https"))],
verbose_name=_("SSO URL"), verbose_name=_("SSO URL"),
help_text=_("URL that the initial Login request is sent to."), help_text=_("URL that the initial Login request is sent to."),
) )
slo_url = models.TextField( slo_url = models.URLField(
validators=[DomainlessURLValidator(schemes=("http", "https"))],
default=None, default=None,
blank=True, blank=True,
null=True, null=True,

View File

@ -33,7 +33,6 @@ from authentik.flows.planner import (
) )
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.urls import is_url_absolute
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.providers.saml.utils.encoding import nice64 from authentik.providers.saml.utils.encoding import nice64
from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat
@ -74,8 +73,6 @@ class InitiateView(View):
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user" NEXT_ARG_NAME, "authentik_core:if-user"
) )
if not is_url_absolute(final_redirect):
final_redirect = "authentik_core:if-user"
kwargs.update( kwargs.update(
{ {
PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SSO: True,

File diff suppressed because one or more lines are too long

View File

@ -142,38 +142,35 @@ class IdentificationChallengeResponse(ChallengeResponse):
raise ValidationError("Failed to authenticate.") raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user self.pre_user = pre_user
# Password check
if current_stage.password_stage:
password = attrs.get("password", None)
if not password:
self.stage.logger.warning("Password not set for ident+auth attempt")
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
# Captcha check # Captcha check
if captcha_stage := current_stage.captcha_stage: if captcha_stage := current_stage.captcha_stage:
captcha_token = attrs.get("captcha_token", None) captcha_token = attrs.get("captcha_token", None)
if not captcha_token: if not captcha_token:
self.stage.logger.warning("Token not set for captcha attempt") self.stage.logger.warning("Token not set for captcha attempt")
verify_captcha_token(captcha_stage, captcha_token, client_ip) verify_captcha_token(captcha_stage, captcha_token, client_ip)
# Password check
if not current_stage.password_stage:
# No password stage select, don't validate the password
return attrs
password = attrs.get("password", None)
if not password:
self.stage.logger.warning("Password not set for ident+auth attempt")
try:
with start_span(
op="authentik.stages.identification.authenticate",
name="User authenticate call (combo stage)",
):
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
current_stage,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
return attrs return attrs

View File

@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema", "$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json", "$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object", "type": "object",
"title": "authentik 2025.2.3 Blueprint schema", "title": "authentik 2025.2.2 Blueprint schema",
"required": [ "required": [
"version", "version",
"entries" "entries"
@ -6423,6 +6423,8 @@
}, },
"acs_url": { "acs_url": {
"type": "string", "type": "string",
"format": "uri",
"maxLength": 200,
"minLength": 1, "minLength": 1,
"title": "ACS URL" "title": "ACS URL"
}, },
@ -8731,6 +8733,8 @@
}, },
"sso_url": { "sso_url": {
"type": "string", "type": "string",
"format": "uri",
"maxLength": 200,
"minLength": 1, "minLength": 1,
"title": "SSO URL", "title": "SSO URL",
"description": "URL that the initial Login request is sent to." "description": "URL that the initial Login request is sent to."
@ -8740,6 +8744,8 @@
"string", "string",
"null" "null"
], ],
"format": "uri",
"maxLength": 200,
"title": "SLO URL", "title": "SLO URL",
"description": "Optional URL if your IDP supports Single-Logout." "description": "Optional URL if your IDP supports Single-Logout."
}, },

View File

@ -31,7 +31,7 @@ services:
volumes: volumes:
- redis:/data - redis:/data
server: server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2}
restart: unless-stopped restart: unless-stopped
command: server command: server
environment: environment:
@ -54,7 +54,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
worker: worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.2}
restart: unless-stopped restart: unless-stopped
command: worker command: worker
environment: environment:

7
go.mod
View File

@ -1,6 +1,9 @@
module goauthentik.io module goauthentik.io
go 1.24.0 go 1.23.0
toolchain go1.24.0
require ( require (
beryju.io/ldap v0.1.0 beryju.io/ldap v0.1.0
github.com/coreos/go-oidc/v3 v3.13.0 github.com/coreos/go-oidc/v3 v3.13.0
@ -26,7 +29,7 @@ require (
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2 github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025023.2 goauthentik.io/api/v3 v3.2025022.6
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.28.0 golang.org/x/oauth2 v0.28.0
golang.org/x/sync v0.12.0 golang.org/x/sync v0.12.0

4
go.sum
View File

@ -299,8 +299,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.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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025023.2 h1:4XHlnykN5jQH78liQ4cp2Jf8eigvQImIJp+A+bsq1nA= goauthentik.io/api/v3 v3.2025022.6 h1:M5M8Cd/1N7E8KLkvYYh7VdcdKz5nfzjKPFLK+YOtOVg=
goauthentik.io/api/v3 v3.2025023.2/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= goauthentik.io/api/v3 v3.2025022.6/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

@ -29,4 +29,4 @@ func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion()) return fmt.Sprintf("authentik@%s", FullVersion())
} }
const VERSION = "2025.2.3" const VERSION = "2025.2.2"

View File

@ -0,0 +1,5 @@
//go:build requirefips
package backend
var FipsEnabled = true

View File

@ -0,0 +1,5 @@
//go:build !requirefips
package backend
var FipsEnabled = false

View File

@ -2,7 +2,6 @@ package ak
import ( import (
"context" "context"
"crypto/fips140"
"fmt" "fmt"
"math/rand" "math/rand"
"net/http" "net/http"
@ -204,7 +203,7 @@ func (a *APIController) getWebsocketPingArgs() map[string]interface{} {
"golangVersion": runtime.Version(), "golangVersion": runtime.Version(),
"opensslEnabled": cryptobackend.OpensslEnabled, "opensslEnabled": cryptobackend.OpensslEnabled,
"opensslVersion": cryptobackend.OpensslVersion(), "opensslVersion": cryptobackend.OpensslVersion(),
"fipsEnabled": fips140.Enabled(), "fipsEnabled": cryptobackend.FipsEnabled,
} }
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err == nil { if err == nil {

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Stage 1: Build # Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@ -27,7 +27,7 @@ COPY . .
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/ldap ./cmd/ldap go build -o /go/ldap ./cmd/ldap
# Stage 2: Run # Stage 2: Run

View File

@ -26,7 +26,7 @@ Parameters:
Description: authentik Docker image Description: authentik Docker image
AuthentikVersion: AuthentikVersion:
Type: String Type: String
Default: 2025.2.3 Default: 2025.2.2
Description: authentik Docker image tag Description: authentik Docker image tag
AuthentikServerCPU: AuthentikServerCPU:
Type: Number Type: Number

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-31 00:10+0000\n" "POT-Creation-Date: 2025-03-22 00:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -1220,20 +1220,6 @@ msgstr ""
msgid "Reputation Scores" msgid "Reputation Scores"
msgstr "" msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr ""
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr ""
#: authentik/policies/templates/policies/denied.html #: authentik/policies/templates/policies/denied.html
msgid "Permission denied" msgid "Permission denied"
msgstr "" msgstr ""

View File

@ -10,8 +10,8 @@
# Manuel Viens, 2023 # Manuel Viens, 2023
# Mordecai, 2023 # Mordecai, 2023
# nerdinator <florian.dupret@gmail.com>, 2024 # nerdinator <florian.dupret@gmail.com>, 2024
# Tina, 2024
# Charles Leclerc, 2025 # Charles Leclerc, 2025
# Tina, 2025
# Marc Schmitt, 2025 # Marc Schmitt, 2025
# #
#, fuzzy #, fuzzy
@ -19,7 +19,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-31 00:10+0000\n" "POT-Creation-Date: 2025-03-22 00:10+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n" "PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: Marc Schmitt, 2025\n" "Last-Translator: Marc Schmitt, 2025\n"
"Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n" "Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n"
@ -1347,22 +1347,6 @@ msgstr "Score de Réputation"
msgid "Reputation Scores" msgid "Reputation Scores"
msgstr "Scores de Réputation" msgstr "Scores de Réputation"
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr "En attente de l'authentification..."
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr ""
"Vous êtes déjà en cours d'authentification dans un autre onglet. Cette page "
"se rafraîchira lorsque l'authentification sera terminée."
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr "S'authentifier dans cet onglet"
#: authentik/policies/templates/policies/denied.html #: authentik/policies/templates/policies/denied.html
msgid "Permission denied" msgid "Permission denied"
msgstr "Permission refusée" msgstr "Permission refusée"

View File

@ -14,7 +14,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-31 00:10+0000\n" "POT-Creation-Date: 2025-03-22 00:10+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n" "PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: deluxghost, 2025\n" "Last-Translator: deluxghost, 2025\n"
"Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n" "Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n"
@ -1234,20 +1234,6 @@ msgstr "信誉分数"
msgid "Reputation Scores" msgid "Reputation Scores"
msgstr "信誉分数" msgstr "信誉分数"
#: authentik/policies/templates/policies/buffer.html
msgid "Waiting for authentication..."
msgstr "正在等待身份验证…"
#: authentik/policies/templates/policies/buffer.html
msgid ""
"You're already authenticating in another tab. This page will refresh once "
"authentication is completed."
msgstr "您正在另一个标签页中验证身份。身份验证完成后,此页面会刷新。"
#: authentik/policies/templates/policies/buffer.html
msgid "Authenticate in this tab"
msgstr "在此标签页中验证身份"
#: authentik/policies/templates/policies/denied.html #: authentik/policies/templates/policies/denied.html
msgid "Permission denied" msgid "Permission denied"
msgstr "权限被拒绝" msgstr "权限被拒绝"

View File

@ -1,5 +1,5 @@
{ {
"name": "@goauthentik/authentik", "name": "@goauthentik/authentik",
"version": "2025.2.3", "version": "2025.2.2",
"private": true "private": true
} }

View File

@ -17,7 +17,7 @@ COPY web .
RUN npm run build-proxy RUN npm run build-proxy
# Stage 2: Build # Stage 2: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@ -43,7 +43,7 @@ COPY . .
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/proxy ./cmd/proxy go build -o /go/proxy ./cmd/proxy
# Stage 3: Run # Stage 3: Run

View File

@ -1,6 +1,6 @@
[project] [project]
name = "authentik" name = "authentik"
version = "2025.2.3" version = "2025.2.2"
description = "" description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }] authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.12.*" requires-python = "==3.12.*"

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Stage 1: Build # Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@ -27,7 +27,7 @@ COPY . .
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/rac ./cmd/rac go build -o /go/rac ./cmd/rac
# Stage 2: Run # Stage 2: Run

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Stage 1: Build # Stage 1: Build
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS builder FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS builder
ARG TARGETOS ARG TARGETOS
ARG TARGETARCH ARG TARGETARCH
@ -27,7 +27,7 @@ COPY . .
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \ --mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \ if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/radius ./cmd/radius go build -o /go/radius ./cmd/radius
# Stage 2: Run # Stage 2: Run

View File

@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: authentik title: authentik
version: 2025.2.3 version: 2025.2.2
description: Making authentication simple. description: Making authentication simple.
contact: contact:
email: hello@goauthentik.io email: hello@goauthentik.io
@ -44141,11 +44141,17 @@ components:
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -45675,19 +45681,27 @@ components:
readOnly: true readOnly: true
assigned_application_slug: assigned_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: Get application slug, return empty string if no application
exists
readOnly: true readOnly: true
assigned_application_name: assigned_application_name:
type: string type: string
description: Application's display Name. description: Get application name, return empty string if no application
exists
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -46395,11 +46409,17 @@ components:
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -47022,19 +47042,27 @@ components:
readOnly: true readOnly: true
assigned_application_slug: assigned_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: Get application slug, return empty string if no application
exists
readOnly: true readOnly: true
assigned_application_name: assigned_application_name:
type: string type: string
description: Application's display Name. description: Get application name, return empty string if no application
exists
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -52245,8 +52273,9 @@ components:
format: uuid format: uuid
acs_url: acs_url:
type: string type: string
minLength: 1
format: uri format: uri
minLength: 1
maxLength: 200
audience: audience:
type: string type: string
description: Value of the audience restriction field of the assertion. When description: Value of the audience restriction field of the assertion. When
@ -52403,14 +52432,16 @@ components:
description: Also known as Entity ID. Defaults the Metadata URL. description: Also known as Entity ID. Defaults the Metadata URL.
sso_url: sso_url:
type: string type: string
format: uri
minLength: 1 minLength: 1
description: URL that the initial Login request is sent to. description: URL that the initial Login request is sent to.
format: uri maxLength: 200
slo_url: slo_url:
type: string type: string
format: uri
nullable: true nullable: true
description: Optional URL if your IDP supports Single-Logout. description: Optional URL if your IDP supports Single-Logout.
format: uri maxLength: 200
allow_idp_initiated: allow_idp_initiated:
type: boolean type: boolean
description: Allows authentication flows initiated by the IdP. This can description: Allows authentication flows initiated by the IdP. This can
@ -53845,19 +53876,27 @@ components:
readOnly: true readOnly: true
assigned_application_slug: assigned_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: Get application slug, return empty string if no application
exists
readOnly: true readOnly: true
assigned_application_name: assigned_application_name:
type: string type: string
description: Application's display Name. description: Get application name, return empty string if no application
exists
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -54086,19 +54125,27 @@ components:
readOnly: true readOnly: true
assigned_application_slug: assigned_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: Get application slug, return empty string if no application
exists
readOnly: true readOnly: true
assigned_application_name: assigned_application_name:
type: string type: string
description: Application's display Name. description: Get application name, return empty string if no application
exists
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -54405,19 +54452,27 @@ components:
readOnly: true readOnly: true
assigned_application_slug: assigned_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: Get application slug, return empty string if no application
exists
readOnly: true readOnly: true
assigned_application_name: assigned_application_name:
type: string type: string
description: Application's display Name. description: Get application name, return empty string if no application
exists
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -54570,19 +54625,27 @@ components:
readOnly: true readOnly: true
assigned_application_slug: assigned_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: Get application slug, return empty string if no application
exists
readOnly: true readOnly: true
assigned_application_name: assigned_application_name:
type: string type: string
description: Application's display Name. description: Get application name, return empty string if no application
exists
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -55182,19 +55245,27 @@ components:
readOnly: true readOnly: true
assigned_application_slug: assigned_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: Get application slug, return empty string if no application
exists
readOnly: true readOnly: true
assigned_application_name: assigned_application_name:
type: string type: string
description: Application's display Name. description: Get application name, return empty string if no application
exists
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string
@ -55211,6 +55282,7 @@ components:
acs_url: acs_url:
type: string type: string
format: uri format: uri
maxLength: 200
audience: audience:
type: string type: string
description: Value of the audience restriction field of the assertion. When description: Value of the audience restriction field of the assertion. When
@ -55377,8 +55449,9 @@ components:
format: uuid format: uuid
acs_url: acs_url:
type: string type: string
minLength: 1
format: uri format: uri
minLength: 1
maxLength: 200
audience: audience:
type: string type: string
description: Value of the audience restriction field of the assertion. When description: Value of the audience restriction field of the assertion. When
@ -55551,13 +55624,15 @@ components:
description: Also known as Entity ID. Defaults the Metadata URL. description: Also known as Entity ID. Defaults the Metadata URL.
sso_url: sso_url:
type: string type: string
description: URL that the initial Login request is sent to.
format: uri format: uri
description: URL that the initial Login request is sent to.
maxLength: 200
slo_url: slo_url:
type: string type: string
format: uri
nullable: true nullable: true
description: Optional URL if your IDP supports Single-Logout. description: Optional URL if your IDP supports Single-Logout.
format: uri maxLength: 200
allow_idp_initiated: allow_idp_initiated:
type: boolean type: boolean
description: Allows authentication flows initiated by the IdP. This can description: Allows authentication flows initiated by the IdP. This can
@ -55740,14 +55815,16 @@ components:
description: Also known as Entity ID. Defaults the Metadata URL. description: Also known as Entity ID. Defaults the Metadata URL.
sso_url: sso_url:
type: string type: string
format: uri
minLength: 1 minLength: 1
description: URL that the initial Login request is sent to. description: URL that the initial Login request is sent to.
format: uri maxLength: 200
slo_url: slo_url:
type: string type: string
format: uri
nullable: true nullable: true
description: Optional URL if your IDP supports Single-Logout. description: Optional URL if your IDP supports Single-Logout.
format: uri maxLength: 200
allow_idp_initiated: allow_idp_initiated:
type: boolean type: boolean
description: Allows authentication flows initiated by the IdP. This can description: Allows authentication flows initiated by the IdP. This can
@ -55891,11 +55968,17 @@ components:
readOnly: true readOnly: true
assigned_backchannel_application_slug: assigned_backchannel_application_slug:
type: string type: string
description: Internal application name, used in URLs. description: |-
Get backchannel application slug.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
assigned_backchannel_application_name: assigned_backchannel_application_name:
type: string type: string
description: Application's display Name. description: |-
Get backchannel application name.
Returns an empty string if no backchannel application exists.
readOnly: true readOnly: true
verbose_name: verbose_name:
type: string type: string

View File

@ -410,77 +410,3 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text, self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied", "Permission denied",
) )
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
@apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_implied_parallel(self):
"""test OpenID Provider flow (default authorization flow with implied consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider = OAuth2Provider.objects.create(
name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id,
client_secret=self.client_secret,
signing_key=create_test_cert(),
redirect_uris=[
RedirectURI(
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
)
],
authorization_flow=authorization_flow,
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
)
)
Application.objects.create(
name=generate_id(),
slug=self.app_slug,
provider=provider,
)
self.driver.get(self.live_server_url)
login_window = self.driver.current_window_handle
self.driver.switch_to.new_window("tab")
grafana_window = self.driver.current_window_handle
self.driver.get("http://localhost:3000")
self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click()
self.driver.switch_to.window(login_window)
self.login()
self.driver.switch_to.window(grafana_window)
self.wait_for_url("http://localhost:3000/?orgId=1")
self.driver.get("http://localhost:3000/profile")
self.assertEqual(
self.driver.find_element(By.CLASS_NAME, "page-header__title").text,
self.user.name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute("value"),
self.user.name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=email]").get_attribute("value"),
self.user.email,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=login]").get_attribute("value"),
self.user.email,
)

View File

@ -20,7 +20,7 @@ from tests.e2e.utils import SeleniumTestCase, retry
class TestProviderSAML(SeleniumTestCase): class TestProviderSAML(SeleniumTestCase):
"""test SAML Provider flow""" """test SAML Provider flow"""
def setup_client(self, provider: SAMLProvider, force_post: bool = False, **kwargs): def setup_client(self, provider: SAMLProvider, force_post: bool = False):
"""Setup client saml-sp container which we test SAML against""" """Setup client saml-sp container which we test SAML against"""
metadata_url = ( metadata_url = (
self.url( self.url(
@ -40,7 +40,6 @@ class TestProviderSAML(SeleniumTestCase):
"SP_ENTITY_ID": provider.issuer, "SP_ENTITY_ID": provider.issuer,
"SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", "SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"SP_METADATA_URL": metadata_url, "SP_METADATA_URL": metadata_url,
**kwargs,
}, },
) )
@ -112,74 +111,6 @@ class TestProviderSAML(SeleniumTestCase):
[self.user.email], [self.user.email],
) )
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
)
@reconcile_app("authentik_crypto")
def test_sp_initiated_implicit_post(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url="http://localhost:9009/saml/acs",
audience="authentik-e2e",
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
)
self.setup_client(provider, True)
self.driver.get("http://localhost:9009")
self.login()
self.wait_for_url("http://localhost:9009/")
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
[self.user.name],
)
self.assertEqual(
body["attr"][
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
[str(self.user.pk)],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
[self.user.email],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
[self.user.email],
)
@retry() @retry()
@apply_blueprint( @apply_blueprint(
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
@ -519,81 +450,3 @@ class TestProviderSAML(SeleniumTestCase):
lambda driver: driver.current_url.startswith(should_url), lambda driver: driver.current_url.startswith(should_url),
f"URL {self.driver.current_url} doesn't match expected URL {should_url}", f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
) )
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
)
@reconcile_app("authentik_crypto")
def test_sp_initiated_implicit_post_buffer(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url=f"http://{self.host}:9009/saml/acs",
audience="authentik-e2e",
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
)
self.setup_client(provider, True, SP_ROOT_URL=f"http://{self.host}:9009")
self.driver.get(self.live_server_url)
login_window = self.driver.current_window_handle
self.driver.switch_to.new_window("tab")
client_window = self.driver.current_window_handle
# We need to access the SP on the same host as the IdP for SameSite cookies
self.driver.get(f"http://{self.host}:9009")
self.driver.switch_to.window(login_window)
self.login()
self.driver.switch_to.window(client_window)
self.wait_for_url(f"http://{self.host}:9009/")
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
[self.user.name],
)
self.assertEqual(
body["attr"][
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
[str(self.user.pk)],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
[self.user.email],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
[self.user.email],
)

2
uv.lock generated
View File

@ -162,7 +162,7 @@ wheels = [
[[package]] [[package]]
name = "authentik" name = "authentik"
version = "2025.2.3" version = "2025.2.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "argon2-cffi" }, { name = "argon2-cffi" },

114
web/package-lock.json generated
View File

@ -24,7 +24,7 @@
"@floating-ui/dom": "^1.6.11", "@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.2.3-1743464496", "@goauthentik/api": "^2025.2.2-1742585853",
"@lit-labs/ssr": "^3.2.2", "@lit-labs/ssr": "^3.2.2",
"@lit/context": "^1.1.2", "@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2", "@lit/localize": "^0.12.2",
@ -1835,9 +1835,9 @@
} }
}, },
"node_modules/@goauthentik/api": { "node_modules/@goauthentik/api": {
"version": "2025.2.3-1743464496", "version": "2025.2.2-1742585853",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.2.3-1743464496.tgz", "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.2.2-1742585853.tgz",
"integrity": "sha512-35+SqFNoBZ+WNpyG2Xv/VKYKIIxjwRmIbgX5WZSpc9IlJVv7yyckUYvLpU2F0hZVUMDnxAUE5bsiNn7K4EQslw==" "integrity": "sha512-bg/816ljAuUixLxi8tZd3W7sEcHgG5aYl0IMkbTsFYOAuiOdl/5wqSWaVM8g8O9SQ9feP3v6xDLOGncMoJxh4g=="
}, },
"node_modules/@goauthentik/web": { "node_modules/@goauthentik/web": {
"resolved": "", "resolved": "",
@ -8815,80 +8815,50 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
}, },
"node_modules/bare-events": { "node_modules/bare-events": {
"version": "2.5.4", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.0.tgz",
"integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", "integrity": "sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"optional": true "optional": true
}, },
"node_modules/bare-fs": { "node_modules/bare-fs": {
"version": "4.0.2", "version": "2.3.5",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz", "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz",
"integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==", "integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"bare-events": "^2.5.4", "bare-events": "^2.0.0",
"bare-path": "^3.0.0", "bare-path": "^2.0.0",
"bare-stream": "^2.6.4" "bare-stream": "^2.0.0"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
} }
}, },
"node_modules/bare-os": { "node_modules/bare-os": {
"version": "3.6.1", "version": "2.4.4",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz",
"integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", "integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "optional": true
"optional": true,
"engines": {
"bare": ">=1.14.0"
}
}, },
"node_modules/bare-path": { "node_modules/bare-path": {
"version": "3.0.0", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"bare-os": "^3.0.1" "bare-os": "^2.1.0"
} }
}, },
"node_modules/bare-stream": { "node_modules/bare-stream": {
"version": "2.6.5", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.3.0.tgz",
"integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", "integrity": "sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"streamx": "^2.21.0" "b4a": "^1.6.6",
}, "streamx": "^2.20.0"
"peerDependencies": {
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
} }
}, },
"node_modules/base64-arraybuffer": { "node_modules/base64-arraybuffer": {
@ -20200,10 +20170,9 @@
} }
}, },
"node_modules/prebuild-install/node_modules/tar-fs": { "node_modules/prebuild-install/node_modules/tar-fs": {
"version": "2.1.2", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
"integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==",
"license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"chownr": "^1.1.1", "chownr": "^1.1.1",
@ -22785,13 +22754,13 @@
} }
}, },
"node_modules/streamx": { "node_modules/streamx": {
"version": "2.22.0", "version": "2.20.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz",
"integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", "integrity": "sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"fast-fifo": "^1.3.2", "fast-fifo": "^1.3.2",
"queue-tick": "^1.0.1",
"text-decoder": "^1.1.0" "text-decoder": "^1.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {
@ -23246,18 +23215,17 @@
} }
}, },
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "3.0.8", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz",
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"pump": "^3.0.0", "pump": "^3.0.0",
"tar-stream": "^3.1.5" "tar-stream": "^3.1.5"
}, },
"optionalDependencies": { "optionalDependencies": {
"bare-fs": "^4.0.1", "bare-fs": "^2.1.1",
"bare-path": "^3.0.0" "bare-path": "^2.1.0"
} }
}, },
"node_modules/tar-stream": { "node_modules/tar-stream": {
@ -24792,9 +24760,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.16", "version": "5.4.15",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.15.tgz",
"integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==", "integrity": "sha512-6ANcZRivqL/4WtwPGTKNaosuNJr5tWiftOC7liM7G9+rMb8+oeJeyzymDu4rTN93seySBmbjSfsS3Vzr19KNtA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@ -12,7 +12,7 @@
"@floating-ui/dom": "^1.6.11", "@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7", "@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0", "@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.2.3-1743464496", "@goauthentik/api": "^2025.2.2-1742585853",
"@lit-labs/ssr": "^3.2.2", "@lit-labs/ssr": "^3.2.2",
"@lit/context": "^1.1.2", "@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2", "@lit/localize": "^0.12.2",

View File

@ -16,8 +16,8 @@ import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/notifications/APIDrawer"; import "@goauthentik/elements/notifications/APIDrawer";
import "@goauthentik/elements/notifications/NotificationDrawer"; import "@goauthentik/elements/notifications/NotificationDrawer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/RouterOutlet"; import "@goauthentik/elements/router/RouterOutlet";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import "@goauthentik/elements/sidebar/Sidebar"; import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem"; import "@goauthentik/elements/sidebar/SidebarItem";
@ -37,10 +37,10 @@ import "./AdminSidebar";
@customElement("ak-interface-admin") @customElement("ak-interface-admin")
export class AdminInterface extends AuthenticatedInterface { export class AdminInterface extends AuthenticatedInterface {
@property({ type: Boolean }) @property({ type: Boolean })
notificationDrawerOpen = getRouteParameter("notificationDrawerOpen", false); notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@property({ type: Boolean }) @property({ type: Boolean })
apiDrawerOpen = getRouteParameter("apiDrawerOpen", false); apiDrawerOpen = getURLParam("apiDrawerOpen", false);
ws: WebsocketClient; ws: WebsocketClient;
@ -93,14 +93,14 @@ export class AdminInterface extends AuthenticatedInterface {
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen; this.notificationDrawerOpen = !this.notificationDrawerOpen;
patchRouteParams({ updateURLParams({
notificationDrawerOpen: this.notificationDrawerOpen, notificationDrawerOpen: this.notificationDrawerOpen,
}); });
}); });
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => { window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
this.apiDrawerOpen = !this.apiDrawerOpen; this.apiDrawerOpen = !this.apiDrawerOpen;
patchRouteParams({ updateURLParams({
apiDrawerOpen: this.apiDrawerOpen, apiDrawerOpen: this.apiDrawerOpen,
}); });
}); });
@ -123,7 +123,7 @@ export class AdminInterface extends AuthenticatedInterface {
super.connectedCallback(); super.connectedCallback();
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) { if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("src/development/build-observer"); const { ESBuildObserver } = await import("@goauthentik/common/client");
new ESBuildObserver(process.env.WATCHER_URL); new ESBuildObserver(process.env.WATCHER_URL);
} }
@ -158,7 +158,7 @@ export class AdminInterface extends AuthenticatedInterface {
class="pf-c-page__main" class="pf-c-page__main"
tabindex="-1" tabindex="-1"
id="main-content" id="main-content"
defaultURL="/administration/overview" defaultUrl="/administration/overview"
.routes=${ROUTES} .routes=${ROUTES}
> >
</ak-router-outlet> </ak-router-outlet>

View File

@ -6,7 +6,7 @@ import {
WithCapabilitiesConfig, WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider"; } from "@goauthentik/elements/Interface/capabilitiesProvider";
import { WithVersion } from "@goauthentik/elements/Interface/versionProvider"; import { WithVersion } from "@goauthentik/elements/Interface/versionProvider";
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router"; import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
import { spread } from "@open-wc/lit-helpers"; import { spread } from "@open-wc/lit-helpers";
@ -95,127 +95,62 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
} }
renderSidebarItems(): TemplateResult { renderSidebarItems(): TemplateResult {
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
// commonplace and singular enough to merit its own handler.
type SidebarEntry = [ type SidebarEntry = [
/** path: string | null,
* The pathname to match against. If null, this is a parent item.
*/
pathname: string | null,
/**
* The label to display in the sidebar.
*/
label: string, label: string,
/** attributes?: Record<string, any> | string[] | null, // eslint-disable-line
* The attributes to apply to the sidebar item. This is a map of attribute name to value.
*
* The second attribute type is of string[] to help with the 'activeWhen' control,
* which was commonplace and singular enough to merit its own handler.
*/
attributes?: Record<string, unknown> | string[] | null,
/**
* The children of this sidebar item. This is a recursive structure.
*/
children?: SidebarEntry[], children?: SidebarEntry[],
]; ];
// prettier-ignore
const sidebarContent: SidebarEntry[] = [ const sidebarContent: SidebarEntry[] = [
// --- [null, msg("Dashboards"), { "?expanded": true }, [
[ ["/administration/overview", msg("Overview")],
null, ["/administration/dashboard/users", msg("User Statistics")],
msg("Dashboards"), ["/administration/system-tasks", msg("System Tasks")]]],
{ "?expanded": true }, [null, msg("Applications"), null, [
[ ["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
["/administration/overview", msg("Overview")], ["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
["/administration/dashboard/users", msg("User Statistics")], ["/outpost/outposts", msg("Outposts")]]],
["/administration/system-tasks", msg("System Tasks")], [null, msg("Events"), null, [
], ["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
], ["/events/rules", msg("Notification Rules")],
[ ["/events/transports", msg("Notification Transports")]]],
null, [null, msg("Customization"), null, [
msg("Applications"), ["/policy/policies", msg("Policies")],
null, ["/core/property-mappings", msg("Property Mappings")],
[ ["/blueprints/instances", msg("Blueprints")],
[ ["/policy/reputation", msg("Reputation scores")]]],
"/core/applications", [null, msg("Flows and Stages"), null, [
msg("Applications"), ["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]],
[`/core/applications/:slug(${SLUG_PATTERN})`], ["/flow/stages", msg("Stages")],
], ["/flow/stages/prompts", msg("Prompts")]]],
["/core/providers", msg("Providers"), [`/core/providers/:id(${ID_PATTERN})`]], [null, msg("Directory"), null, [
["/outpost/outposts", msg("Outposts")], ["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
], ["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]],
], ["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]],
[ ["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]],
null, ["/core/tokens", msg("Tokens and App passwords")],
msg("Events"), ["/flow/stages/invitations", msg("Invitations")]]],
null, [null, msg("System"), null, [
[ ["/core/brands", msg("Brands")],
["/events/log", msg("Logs"), [`/events/log/:id(${UUID_PATTERN})`]], ["/crypto/certificates", msg("Certificates")],
["/events/rules", msg("Notification Rules")], ["/outpost/integrations", msg("Outpost Integrations")],
["/events/transports", msg("Notification Transports")], ["/admin/settings", msg("Settings")]]],
],
],
[
null,
msg("Customization"),
null,
[
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")],
],
],
[
null,
msg("Flows and Stages"),
null,
[
["/flow/flows", msg("Flows"), [`/flow/flows/:slug(${SLUG_PATTERN})`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")],
],
],
[
null,
msg("Directory"),
null,
[
["/identity/users", msg("Users"), [`/identity/users/:id(${ID_PATTERN})`]],
["/identity/groups", msg("Groups"), [`/identity/groups/:id(${UUID_PATTERN})`]],
["/identity/roles", msg("Roles"), [`/identity/roles/:id(${UUID_PATTERN})`]],
[
"/core/sources",
msg("Federation and Social login"),
[`/core/sources/:slug(${SLUG_PATTERN})`],
],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")],
],
],
[
null,
msg("System"),
null,
[
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")],
["/admin/settings", msg("Settings")],
],
],
]; ];
// Typescript requires the type here to correctly type the recursive path // Typescript requires the type here to correctly type the recursive path
type SidebarRenderer = (_: SidebarEntry) => TemplateResult; type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
const renderOneSidebarItem: SidebarRenderer = ([pathname, label, attributes, children]) => { const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
const properties = Array.isArray(attributes) const properties = Array.isArray(attributes)
? { ".activeWhen": attributes } ? { ".activeWhen": attributes }
: (attributes ?? {}); : (attributes ?? {});
if (path) {
if (pathname) { properties.path = path;
properties.pathname = pathname;
} }
return html`<ak-sidebar-item ${spread(properties)}> return html`<ak-sidebar-item ${spread(properties)}>
${label ? html`<span slot="label">${label}</span>` : nothing} ${label ? html`<span slot="label">${label}</span>` : nothing}
${map(children, renderOneSidebarItem)} ${map(children, renderOneSidebarItem)}

View File

@ -1,210 +1,155 @@
import "@goauthentik/admin/admin-overview/AdminOverviewPage"; import "@goauthentik/admin/admin-overview/AdminOverviewPage";
import { Route } from "@goauthentik/elements/router/Route"; import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router/constants";
import { html } from "lit"; import { html } from "lit";
interface IDParameters { export const ROUTES: Route[] = [
id: string;
}
interface SlugParameters {
slug: string;
}
interface UUIDParameters {
uuid: string;
}
export const ROUTES = [
// Prevent infinite Shell loops // Prevent infinite Shell loops
Route.redirect("^/$", "/administration/overview"), new Route(new RegExp("^/$")).redirect("/administration/overview"),
Route.redirect("^#.*", "/administration/overview"), new Route(new RegExp("^#.*")).redirect("/administration/overview"),
Route.redirect("^/library$", "/if/user/", true), new Route(new RegExp("^/library$")).redirect("/if/user/", true),
// statically imported since this is the default route // statically imported since this is the default route
new Route("/administration/overview", () => { new Route(new RegExp("^/administration/overview$"), async () => {
return html`<ak-admin-overview></ak-admin-overview>`; return html`<ak-admin-overview></ak-admin-overview>`;
}), }),
new Route("/administration/dashboard/users", async () => { new Route(new RegExp("^/administration/dashboard/users$"), async () => {
await import("@goauthentik/admin/admin-overview/DashboardUserPage"); await import("@goauthentik/admin/admin-overview/DashboardUserPage");
return html`<ak-admin-dashboard-users></ak-admin-dashboard-users>`; return html`<ak-admin-dashboard-users></ak-admin-dashboard-users>`;
}), }),
new Route("/administration/system-tasks", async () => { new Route(new RegExp("^/administration/system-tasks$"), async () => {
await import("@goauthentik/admin/system-tasks/SystemTaskListPage"); await import("@goauthentik/admin/system-tasks/SystemTaskListPage");
return html`<ak-system-task-list></ak-system-task-list>`; return html`<ak-system-task-list></ak-system-task-list>`;
}), }),
new Route("/core/providers", async () => { new Route(new RegExp("^/core/providers$"), async () => {
await import("@goauthentik/admin/providers/ProviderListPage"); await import("@goauthentik/admin/providers/ProviderListPage");
return html`<ak-provider-list></ak-provider-list>`; return html`<ak-provider-list></ak-provider-list>`;
}), }),
new Route<IDParameters>( new Route(new RegExp(`^/core/providers/(?<id>${ID_REGEX})$`), async (args) => {
new URLPattern({ await import("@goauthentik/admin/providers/ProviderViewPage");
pathname: `/core/providers/:id(${ID_PATTERN})`, return html`<ak-provider-view .providerID=${parseInt(args.id, 10)}></ak-provider-view>`;
}), }),
async (params) => { new Route(new RegExp("^/core/applications$"), async () => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view
.providerID=${parseInt(params.id, 10)}
></ak-provider-view>`;
},
),
new Route("/core/applications", async () => {
await import("@goauthentik/admin/applications/ApplicationListPage"); await import("@goauthentik/admin/applications/ApplicationListPage");
return html`<ak-application-list></ak-application-list>`; return html`<ak-application-list></ak-application-list>`;
}), }),
new Route(`/core/applications/:slug(${SLUG_PATTERN})`, async ({ slug }) => { new Route(new RegExp(`^/core/applications/(?<slug>${SLUG_REGEX})$`), async (args) => {
await import("@goauthentik/admin/applications/ApplicationViewPage"); await import("@goauthentik/admin/applications/ApplicationViewPage");
return html`<ak-application-view .applicationSlug=${args.slug}></ak-application-view>`;
return html`<ak-application-view .applicationSlug=${slug}></ak-application-view>`;
}), }),
new Route("/core/sources", async () => { new Route(new RegExp("^/core/sources$"), async () => {
await import("@goauthentik/admin/sources/SourceListPage"); await import("@goauthentik/admin/sources/SourceListPage");
return html`<ak-source-list></ak-source-list>`; return html`<ak-source-list></ak-source-list>`;
}), }),
new Route(`/core/sources/:slug(${SLUG_PATTERN})`, async ({ slug }) => { new Route(new RegExp(`^/core/sources/(?<slug>${SLUG_REGEX})$`), async (args) => {
await import("@goauthentik/admin/sources/SourceViewPage"); await import("@goauthentik/admin/sources/SourceViewPage");
return html`<ak-source-view .sourceSlug=${args.slug}></ak-source-view>`;
return html`<ak-source-view .sourceSlug=${slug}></ak-source-view>`;
}), }),
new Route("/core/property-mappings", async () => { new Route(new RegExp("^/core/property-mappings$"), async () => {
await import("@goauthentik/admin/property-mappings/PropertyMappingListPage"); await import("@goauthentik/admin/property-mappings/PropertyMappingListPage");
return html`<ak-property-mapping-list></ak-property-mapping-list>`; return html`<ak-property-mapping-list></ak-property-mapping-list>`;
}), }),
new Route("/core/tokens", async () => { new Route(new RegExp("^/core/tokens$"), async () => {
await import("@goauthentik/admin/tokens/TokenListPage"); await import("@goauthentik/admin/tokens/TokenListPage");
return html`<ak-token-list></ak-token-list>`; return html`<ak-token-list></ak-token-list>`;
}), }),
new Route("/core/brands", async () => { new Route(new RegExp("^/core/brands"), async () => {
await import("@goauthentik/admin/brands/BrandListPage"); await import("@goauthentik/admin/brands/BrandListPage");
return html`<ak-brand-list></ak-brand-list>`; return html`<ak-brand-list></ak-brand-list>`;
}), }),
new Route("/policy/policies", async () => { new Route(new RegExp("^/policy/policies$"), async () => {
await import("@goauthentik/admin/policies/PolicyListPage"); await import("@goauthentik/admin/policies/PolicyListPage");
return html`<ak-policy-list></ak-policy-list>`; return html`<ak-policy-list></ak-policy-list>`;
}), }),
new Route("/policy/reputation", async () => { new Route(new RegExp("^/policy/reputation$"), async () => {
await import("@goauthentik/admin/policies/reputation/ReputationListPage"); await import("@goauthentik/admin/policies/reputation/ReputationListPage");
return html`<ak-policy-reputation-list></ak-policy-reputation-list>`; return html`<ak-policy-reputation-list></ak-policy-reputation-list>`;
}), }),
new Route("/identity/groups", async () => { new Route(new RegExp("^/identity/groups$"), async () => {
await import("@goauthentik/admin/groups/GroupListPage"); await import("@goauthentik/admin/groups/GroupListPage");
return html`<ak-group-list></ak-group-list>`; return html`<ak-group-list></ak-group-list>`;
}), }),
new Route<UUIDParameters>(`/identity/groups/:uuid(${UUID_PATTERN})`, async ({ uuid }) => { new Route(new RegExp(`^/identity/groups/(?<uuid>${UUID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/groups/GroupViewPage"); await import("@goauthentik/admin/groups/GroupViewPage");
return html`<ak-group-view .groupId=${args.uuid}></ak-group-view>`;
return html`<ak-group-view .groupId=${uuid}></ak-group-view>`;
}), }),
new Route("/identity/users", async () => { new Route(new RegExp("^/identity/users$"), async () => {
await import("@goauthentik/admin/users/UserListPage"); await import("@goauthentik/admin/users/UserListPage");
return html`<ak-user-list></ak-user-list>`; return html`<ak-user-list></ak-user-list>`;
}), }),
new Route<IDParameters>(`/identity/users/:id(${ID_PATTERN})`, async ({ id }) => { new Route(new RegExp(`^/identity/users/(?<id>${ID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/users/UserViewPage"); await import("@goauthentik/admin/users/UserViewPage");
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
return html`<ak-user-view .userId=${parseInt(id, 10)}></ak-user-view>`;
}), }),
new Route("/identity/roles", async () => { new Route(new RegExp("^/identity/roles$"), async () => {
await import("@goauthentik/admin/roles/RoleListPage"); await import("@goauthentik/admin/roles/RoleListPage");
return html`<ak-role-list></ak-role-list>`; return html`<ak-role-list></ak-role-list>`;
}), }),
new Route<IDParameters>(`/identity/roles/:id(${UUID_PATTERN})`, async ({ id }) => { new Route(new RegExp(`^/identity/roles/(?<id>${UUID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/roles/RoleViewPage"); await import("@goauthentik/admin/roles/RoleViewPage");
return html`<ak-role-view roleId=${args.id}></ak-role-view>`;
return html`<ak-role-view roleId=${id}></ak-role-view>`;
}), }),
new Route("/flow/stages/invitations", async () => { new Route(new RegExp("^/flow/stages/invitations$"), async () => {
await import("@goauthentik/admin/stages/invitation/InvitationListPage"); await import("@goauthentik/admin/stages/invitation/InvitationListPage");
return html`<ak-stage-invitation-list></ak-stage-invitation-list>`; return html`<ak-stage-invitation-list></ak-stage-invitation-list>`;
}), }),
new Route("/flow/stages/prompts", async () => { new Route(new RegExp("^/flow/stages/prompts$"), async () => {
await import("@goauthentik/admin/stages/prompt/PromptListPage"); await import("@goauthentik/admin/stages/prompt/PromptListPage");
return html`<ak-stage-prompt-list></ak-stage-prompt-list>`; return html`<ak-stage-prompt-list></ak-stage-prompt-list>`;
}), }),
new Route("/flow/stages", async () => { new Route(new RegExp("^/flow/stages$"), async () => {
await import("@goauthentik/admin/stages/StageListPage"); await import("@goauthentik/admin/stages/StageListPage");
return html`<ak-stage-list></ak-stage-list>`; return html`<ak-stage-list></ak-stage-list>`;
}), }),
new Route("/flow/flows", async () => { new Route(new RegExp("^/flow/flows$"), async () => {
await import("@goauthentik/admin/flows/FlowListPage"); await import("@goauthentik/admin/flows/FlowListPage");
return html`<ak-flow-list></ak-flow-list>`; return html`<ak-flow-list></ak-flow-list>`;
}), }),
new Route<SlugParameters>(`/flow/flows/:slug(${SLUG_PATTERN})`, async ({ slug }) => { new Route(new RegExp(`^/flow/flows/(?<slug>${SLUG_REGEX})$`), async (args) => {
await import("@goauthentik/admin/flows/FlowViewPage"); await import("@goauthentik/admin/flows/FlowViewPage");
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
return html`<ak-flow-view .flowSlug=${slug}></ak-flow-view>`;
}), }),
new Route("/events/log", async () => { new Route(new RegExp("^/events/log$"), async () => {
await import("@goauthentik/admin/events/EventListPage"); await import("@goauthentik/admin/events/EventListPage");
return html`<ak-event-list></ak-event-list>`; return html`<ak-event-list></ak-event-list>`;
}), }),
new Route<IDParameters>(`/events/log/:id(${UUID_PATTERN})`, async ({ id }) => { new Route(new RegExp(`^/events/log/(?<id>${UUID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/events/EventViewPage"); await import("@goauthentik/admin/events/EventViewPage");
return html`<ak-event-view .eventID=${args.id}></ak-event-view>`;
return html`<ak-event-view .eventID=${id}></ak-event-view>`;
}), }),
new Route("/events/transports", async () => { new Route(new RegExp("^/events/transports$"), async () => {
await import("@goauthentik/admin/events/TransportListPage"); await import("@goauthentik/admin/events/TransportListPage");
return html`<ak-event-transport-list></ak-event-transport-list>`; return html`<ak-event-transport-list></ak-event-transport-list>`;
}), }),
new Route("/events/rules", async () => { new Route(new RegExp("^/events/rules$"), async () => {
await import("@goauthentik/admin/events/RuleListPage"); await import("@goauthentik/admin/events/RuleListPage");
return html`<ak-event-rule-list></ak-event-rule-list>`; return html`<ak-event-rule-list></ak-event-rule-list>`;
}), }),
new Route("/outpost/outposts", async () => { new Route(new RegExp("^/outpost/outposts$"), async () => {
await import("@goauthentik/admin/outposts/OutpostListPage"); await import("@goauthentik/admin/outposts/OutpostListPage");
return html`<ak-outpost-list></ak-outpost-list>`; return html`<ak-outpost-list></ak-outpost-list>`;
}), }),
new Route("/outpost/integrations", async () => { new Route(new RegExp("^/outpost/integrations$"), async () => {
await import("@goauthentik/admin/outposts/ServiceConnectionListPage"); await import("@goauthentik/admin/outposts/ServiceConnectionListPage");
return html`<ak-outpost-service-connection-list></ak-outpost-service-connection-list>`; return html`<ak-outpost-service-connection-list></ak-outpost-service-connection-list>`;
}), }),
new Route("/crypto/certificates", async () => { new Route(new RegExp("^/crypto/certificates$"), async () => {
await import("@goauthentik/admin/crypto/CertificateKeyPairListPage"); await import("@goauthentik/admin/crypto/CertificateKeyPairListPage");
return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`; return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`;
}), }),
new Route("/admin/settings", async () => { new Route(new RegExp("^/admin/settings$"), async () => {
await import("@goauthentik/admin/admin-settings/AdminSettingsPage"); await import("@goauthentik/admin/admin-settings/AdminSettingsPage");
return html`<ak-admin-settings></ak-admin-settings>`; return html`<ak-admin-settings></ak-admin-settings>`;
}), }),
new Route("/blueprints/instances", async () => { new Route(new RegExp("^/blueprints/instances$"), async () => {
await import("@goauthentik/admin/blueprints/BlueprintListPage"); await import("@goauthentik/admin/blueprints/BlueprintListPage");
return html`<ak-blueprint-list></ak-blueprint-list>`; return html`<ak-blueprint-list></ak-blueprint-list>`;
}), }),
new Route("/debug", async () => { new Route(new RegExp("^/debug$"), async () => {
await import("@goauthentik/admin/DebugPage"); await import("@goauthentik/admin/DebugPage");
return html`<ak-admin-debug-page></ak-admin-debug-page>`; return html`<ak-admin-debug-page></ak-admin-debug-page>`;
}), }),
new Route("/enterprise/licenses", async () => { new Route(new RegExp("^/enterprise/licenses$"), async () => {
await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage"); await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage");
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`; return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
}), }),
] satisfies Route<never>[]; ];

View File

@ -16,7 +16,7 @@ import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/cards/AggregatePromiseCard"; import "@goauthentik/elements/cards/AggregatePromiseCard";
import "@goauthentik/elements/cards/QuickActionsCard.js"; import "@goauthentik/elements/cards/QuickActionsCard.js";
import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js"; import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js";
import { formatRouteHash } from "@goauthentik/elements/router"; import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, TemplateResult, css, html, nothing } from "lit";
@ -79,13 +79,10 @@ export class AdminOverviewPage extends AdminOverviewBase {
} }
quickActions: QuickAction[] = [ quickActions: QuickAction[] = [
[ [msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
msg("Create a new application"), [msg("Check the logs"), paramURL("/events/log")],
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true], [msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), formatRouteHash("/identity/users")], [msg("Manage users"), paramURL("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true], [msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true],
]; ];
@ -198,13 +195,10 @@ export class AdminOverviewPage extends AdminOverviewBase {
const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`; const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`;
const quickActions: [string, string][] = [ const quickActions: [string, string][] = [
[ [msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
msg("Create a new application"), [msg("Check the logs"), paramURL("/events/log")],
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/"], [msg("Explore integrations"), "https://goauthentik.io/integrations/"],
[msg("Manage users"), formatRouteHash("/identity/users")], [msg("Manage users"), paramURL("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`], [msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`],
]; ];

View File

@ -7,7 +7,7 @@ import "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { getRouteParameter } from "@goauthentik/elements/router/utils"; import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -156,7 +156,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
} }
renderObjectCreate(): TemplateResult { renderObjectCreate(): TemplateResult {
return html` <ak-application-wizard .open=${getRouteParameter("createWizard", false)}> return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}>
<button <button
slot="trigger" slot="trigger"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
@ -165,7 +165,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
${msg("Create with Provider")} ${msg("Create with Provider")}
</button> </button>
</ak-application-wizard> </ak-application-wizard>
<ak-forms-modal .open=${getRouteParameter("createForm", false)}> <ak-forms-modal .open=${getURLParam("createForm", false)}>
<span slot="submit"> ${msg("Create")} </span> <span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Application")} </span> <span slot="header"> ${msg("Create Application")} </span>
<ak-application-form slot="form"> </ak-application-form> <ak-application-form slot="form"> </ak-application-form>

View File

@ -8,7 +8,7 @@ import "@goauthentik/components/ak-hint/ak-hint-body";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Label"; import "@goauthentik/elements/Label";
import "@goauthentik/elements/buttons/ActionButton/ak-action-button"; import "@goauthentik/elements/buttons/ActionButton/ak-action-button";
import { getRouteParameter } from "@goauthentik/elements/router/utils"; import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { css, html } from "lit"; import { css, html } from "lit";
@ -110,7 +110,7 @@ export class AkApplicationWizardHint extends AKElement implements ShowHintContro
the same time with our new Application Wizard. the same time with our new Application Wizard.
<!-- <a href="(link to docs)">Learn more about the wizard here.</a> --> <!-- <a href="(link to docs)">Learn more about the wizard here.</a> -->
</p> </p>
<ak-application-wizard .open=${getRouteParameter("createWizard", false)}> <ak-application-wizard .open=${getURLParam("createWizard", false)}>
<button <button
slot="trigger" slot="trigger"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"

View File

@ -1,6 +1,7 @@
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js"; import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js"; import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js"; import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { isSlug } from "@goauthentik/common/utils.js";
import { camelToSnake } from "@goauthentik/common/utils.js"; import { camelToSnake } from "@goauthentik/common/utils.js";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input"; import "@goauthentik/components/ak-slug-input";
@ -10,7 +11,6 @@ import { type NavigableButton, type WizardButton } from "@goauthentik/components
import { type KeyUnknown } from "@goauthentik/elements/forms/Form"; import { type KeyUnknown } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { isSlug } from "@goauthentik/elements/router";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { html } from "lit"; import { html } from "lit";

View File

@ -1,7 +1,7 @@
import "@goauthentik/admin/flows/FlowForm"; import "@goauthentik/admin/flows/FlowForm";
import "@goauthentik/admin/flows/FlowImportForm"; import "@goauthentik/admin/flows/FlowImportForm";
import { DesignationToLabel, formatFlowURL } from "@goauthentik/admin/flows/utils"; import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { groupBy } from "@goauthentik/common/utils"; import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ConfirmationForm"; import "@goauthentik/elements/forms/ConfirmationForm";
@ -107,9 +107,10 @@ export class FlowListPage extends TablePage<Flow> {
<button <button
class="pf-c-button pf-m-plain" class="pf-c-button pf-m-plain"
@click=${() => { @click=${() => {
const url = formatFlowURL(item); const finalURL = `${window.location.origin}/if/flow/${item.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
window.open(url, "_blank"); )}`;
window.open(finalURL, "_blank");
}} }}
> >
<pf-tooltip position="top" content=${msg("Execute")}> <pf-tooltip position="top" content=${msg("Execute")}>

View File

@ -1,10 +1,10 @@
import "@goauthentik/admin/flows/BoundStagesList"; import "@goauthentik/admin/flows/BoundStagesList";
import "@goauthentik/admin/flows/FlowDiagram"; import "@goauthentik/admin/flows/FlowDiagram";
import "@goauthentik/admin/flows/FlowForm"; import "@goauthentik/admin/flows/FlowForm";
import { DesignationToLabel, applyNextParam, formatFlowURL } from "@goauthentik/admin/flows/utils"; import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
import "@goauthentik/admin/policies/BoundPoliciesList"; import "@goauthentik/admin/policies/BoundPoliciesList";
import "@goauthentik/admin/rbac/ObjectPermissionsPage"; import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/PageHeader";
@ -151,9 +151,12 @@ export class FlowViewPage extends AKElement {
<button <button
class="pf-c-button pf-m-block pf-m-primary" class="pf-c-button pf-m-block pf-m-primary"
@click=${() => { @click=${() => {
const url = formatFlowURL(this.flow); const finalURL = `${
window.location.origin
window.open(url, "_blank"); }/if/flow/${this.flow.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
}} }}
> >
${msg("Normal")} ${msg("Normal")}
@ -165,16 +168,12 @@ export class FlowViewPage extends AKElement {
.flowsInstancesExecuteRetrieve({ .flowsInstancesExecuteRetrieve({
slug: this.flow.slug, slug: this.flow.slug,
}) })
.then(({ link }) => { .then((link) => {
const finalURL = URL.canParse(link) const finalURL = `${
? new URL(link) link.link
: new URL( }${AndNext(
link, `${window.location.pathname}#${window.location.hash}`,
window.location.origin, )}`;
);
applyNextParam(finalURL);
window.open(finalURL, "_blank"); window.open(finalURL, "_blank");
}); });
}} }}

View File

@ -43,51 +43,3 @@ export function LayoutToLabel(layout: FlowLayoutEnum): string {
return msg("Unknown layout"); return msg("Unknown layout");
} }
} }
/**
* Applies the next URL as a query parameter to the given URL or URLSearchParams object.
*
* @todo deprecate this once hash routing is removed.
*/
export function applyNextParam(
target: URL | URLSearchParams,
destination: string | URL = window.location.pathname + "#" + window.location.hash,
): void {
const searchParams = target instanceof URL ? target.searchParams : target;
searchParams.set("next", destination.toString());
}
/**
* Creates a URLSearchParams object with the next URL as a query parameter.
*
* @todo deprecate this once hash routing is removed.
*/
export function createNextSearchParams(
destination: string | URL = window.location.pathname + "#" + window.location.hash,
): URLSearchParams {
const searchParams = new URLSearchParams();
applyNextParam(searchParams, destination);
return searchParams;
}
/**
* Creates a URL to a flow, with the next URL as a query parameter.
*
* @param flow The flow to create the URL for.
* @param destination The next URL to redirect to after the flow is completed, `true` to use the current route.
*/
export function formatFlowURL(
flow: Flow,
destination: string | URL | null = window.location.pathname + "#" + window.location.hash,
): URL {
const url = new URL(`/if/flow/${flow.slug}/`, window.location.origin);
if (destination) {
applyNextParam(url, destination);
}
return url;
}

View File

@ -22,7 +22,7 @@ import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table"; import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { UserOption } from "@goauthentik/elements/user/utils"; import { UserOption } from "@goauthentik/elements/user/utils";
@ -127,7 +127,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
order = "last_login"; order = "last_login";
@property({ type: Boolean }) @property({ type: Boolean })
hideServiceAccounts = getRouteParameter<boolean>("hideServiceAccounts", true); hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true);
@state() @state()
me?: SessionUser; me?: SessionUser;
@ -466,7 +466,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
this.hideServiceAccounts = !this.hideServiceAccounts; this.hideServiceAccounts = !this.hideServiceAccounts;
this.page = 1; this.page = 1;
this.fetch(); this.fetch();
patchRouteParams({ updateURLParams({
hideServiceAccounts: this.hideServiceAccounts, hideServiceAccounts: this.hideServiceAccounts,
}); });
}} }}

View File

@ -19,7 +19,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/forms/ProxyForm"; import "@goauthentik/elements/forms/ProxyForm";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -54,7 +54,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
order = "name"; order = "name";
@state() @state()
hideManaged = getRouteParameter<boolean>("hideManaged", true); hideManaged = getURLParam<boolean>("hideManaged", true);
async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> { async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({ return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({
@ -148,7 +148,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
this.hideManaged = !this.hideManaged; this.hideManaged = !this.hideManaged;
this.page = 1; this.page = 1;
this.fetch(); this.fetch();
patchRouteParams({ updateURLParams({
hideManaged: this.hideManaged, hideManaged: this.hideManaged,
}); });
}} }}

View File

@ -1,4 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api"; import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api";
@ -21,7 +22,11 @@ export async function propertyMappingsProvider(page = 1, search = "") {
export function propertyMappingsSelector(instanceMappings?: string[]) { export function propertyMappingsSelector(instanceMappings?: string[]) {
if (!instanceMappings) { if (!instanceMappings) {
return async () => []; return async (mappings: DualSelectPair<ScopeMapping>[]) =>
mappings.filter(
([_0, _1, _2, scope]: DualSelectPair<ScopeMapping>) =>
!(scope?.managed ?? "").startsWith("goauthentik.io/providers"),
);
} }
return async () => { return async () => {

View File

@ -3,6 +3,7 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/rbac/ObjectPermissionsPage"; import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { convertToSlug } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import MDCaddyStandalone from "@goauthentik/docs/add-secure-apps/providers/proxy/_caddy_standalone.md"; import MDCaddyStandalone from "@goauthentik/docs/add-secure-apps/providers/proxy/_caddy_standalone.md";
@ -20,8 +21,7 @@ import "@goauthentik/elements/ak-mdx";
import type { Replacer } from "@goauthentik/elements/ak-mdx"; import type { Replacer } from "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import { formatAsSlug } from "@goauthentik/elements/router"; import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit"; import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
@ -156,7 +156,7 @@ export class ProxyProviderViewPage extends AKElement {
(input: string): string => { (input: string): string => {
// The generated config is pretty unreliable currently so // The generated config is pretty unreliable currently so
// put it behind a flag // put it behind a flag
if (!getRouteParameter("generatedConfig", false)) { if (!getURLParam("generatedConfig", false)) {
return input; return input;
} }
if (!this.provider) { if (!this.provider) {
@ -183,7 +183,7 @@ export class ProxyProviderViewPage extends AKElement {
return html`<ak-tabs pageIdentifier="proxy-setup"> return html`<ak-tabs pageIdentifier="proxy-setup">
${servers.map((server) => { ${servers.map((server) => {
return html`<section return html`<section
slot="page-${formatAsSlug(server.label)}" slot="page-${convertToSlug(server.label)}"
data-tab-title="${server.label}" data-tab-title="${server.label}"
class="pf-c-page__main-section pf-m-no-padding-mobile ak-markdown-section" class="pf-c-page__main-section pf-m-no-padding-mobile ak-markdown-section"
> >

View File

@ -9,7 +9,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js"; import { PFSize } from "@goauthentik/common/enums.js";
import { userTypeToLabel } from "@goauthentik/common/labels"; import { userTypeToLabel } from "@goauthentik/common/labels";
import { MessageLevel } from "@goauthentik/common/messages"; import { MessageLevel } from "@goauthentik/common/messages";
import { createUIConfig, uiConfig } from "@goauthentik/common/ui/config"; import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils"; import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
@ -24,7 +24,7 @@ import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -117,7 +117,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
activePath; activePath;
@state() @state()
hideDeactivated = getRouteParameter<boolean>("hideDeactivated", false); hideDeactivated = getURLParam<boolean>("hideDeactivated", false);
@state() @state()
userPaths?: UserPath; userPaths?: UserPath;
@ -131,10 +131,8 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
constructor() { constructor() {
super(); super();
const defaultPath = new DefaultUIConfig().defaults.userPath;
const defaultPath = createUIConfig().defaults.userPath; this.activePath = getURLParam<string>("path", defaultPath);
this.activePath = getRouteParameter("path", defaultPath);
uiConfig().then((c) => { uiConfig().then((c) => {
if (c.defaults.userPath !== defaultPath) { if (c.defaults.userPath !== defaultPath) {
this.activePath = c.defaults.userPath; this.activePath = c.defaults.userPath;
@ -145,7 +143,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
async apiEndpoint(): Promise<PaginatedResponse<User>> { async apiEndpoint(): Promise<PaginatedResponse<User>> {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({ const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
...(await this.defaultEndpointConfig()), ...(await this.defaultEndpointConfig()),
pathStartswith: getRouteParameter("path", ""), pathStartswith: getURLParam("path", ""),
isActive: this.hideDeactivated ? true : undefined, isActive: this.hideDeactivated ? true : undefined,
includeGroups: false, includeGroups: false,
}); });
@ -227,7 +225,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
this.hideDeactivated = !this.hideDeactivated; this.hideDeactivated = !this.hideDeactivated;
this.page = 1; this.page = 1;
this.fetch(); this.fetch();
patchRouteParams({ updateURLParams({
hideDeactivated: this.hideDeactivated, hideDeactivated: this.hideDeactivated,
}); });
}} }}

View File

@ -79,4 +79,11 @@ export const DEFAULT_CONFIG = new Configuration({
], ],
}); });
// This is just a function so eslint doesn't complain about
// missing-whitespace-between-attributes or
// unexpected-character-in-attribute-name
export function AndNext(url: string): string {
return `?next=${encodeURIComponent(url)}`;
}
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`); console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`);

View File

@ -1,5 +1,5 @@
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants"; import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
import { getCookie } from "@goauthentik/common/http"; import { getCookie } from "@goauthentik/common/utils";
import { import {
CurrentBrand, CurrentBrand,

View File

@ -1,60 +1,170 @@
/** /**
* @file Client-side utilities. * @file
* Client-side observer for ESBuild events.
*/ */
import { TITLE_DEFAULT } from "@goauthentik/common/constants"; import type { Message as ESBuildMessage } from "esbuild";
import { isAdminRoute } from "@goauthentik/elements/router";
import { msg } from "@lit/localize"; const logPrefix = "👷 [ESBuild]";
const log = console.debug.bind(console, logPrefix);
import type { CurrentBrand } from "@goauthentik/api"; type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
type BrandTitleLike = Partial<Pick<CurrentBrand, "brandingTitle">>;
/** /**
* Create a title for the page. * A client-side watcher for ESBuild.
* *
* @param brand - The brand object to append to the title. * Note that this should be conditionally imported in your code, so that
* @param segments - The segments to prepend to the title. * ESBuild may tree-shake it out of production builds.
*/
export function formatPageTitle(
brand: BrandTitleLike | undefined,
...segments: Array<string | undefined>
): string;
/**
* Create a title for the page.
* *
* @param segments - The segments to prepend to the title. * ```ts
*/ * if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
export function formatPageTitle(...segments: Array<string | undefined>): string; * const { ESBuildObserver } = await import("@goauthentik/common/client");
/**
* Create a title for the page.
* *
* @param args - The segments to prepend to the title. * new ESBuildObserver(process.env.WATCHER_URL);
* @param args - The brand object to append to the title. * }
*/ * ```
export function formatPageTitle( }
...args: [BrandTitleLike | string | undefined, ...Array<string | undefined>]
): string { */
const segments: string[] = []; export class ESBuildObserver extends EventSource {
/**
if (isAdminRoute()) { * Whether the watcher has a recent connection to the server.
segments.push(msg("Admin")); */
} alive = true;
const [arg1, ...rest] = args; /**
* The number of errors that have occurred since the watcher started.
if (typeof arg1 === "object") { */
const { brandingTitle = TITLE_DEFAULT } = arg1; errorCount = 0;
segments.push(brandingTitle);
} else { /**
segments.push(TITLE_DEFAULT); * Whether a reload has been requested while offline.
} */
deferredReload = false;
for (const segment of rest) {
if (segment) { /**
segments.push(segment); * The last time a message was received from the server.
} */
} lastUpdatedAt = Date.now();
return segments.join(" - "); /**
* Whether the browser considers itself online.
*/
online = true;
/**
* The ID of the animation frame for the reload.
*/
#reloadFrameID = -1;
/**
* The interval for the keep-alive check.
*/
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
#trackActivity = () => {
this.lastUpdatedAt = Date.now();
this.alive = true;
};
#startListener: BuildEventListener = () => {
this.#trackActivity();
log("⏰ Build started...");
};
#internalErrorListener = () => {
this.errorCount += 1;
if (this.errorCount > 100) {
clearTimeout(this.#keepAliveInterval);
this.close();
log("⛔️ Closing connection");
}
};
#errorListener: BuildEventListener<string> = (event) => {
this.#trackActivity();
// eslint-disable-next-line no-console
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
for (const error of esbuildErrorMessages) {
console.warn(error.text);
if (error.location) {
console.debug(
`file://${error.location.file}:${error.location.line}:${error.location.column}`,
);
console.debug(error.location.lineText);
}
}
// eslint-disable-next-line no-console
console.groupEnd();
};
#endListener: BuildEventListener = () => {
cancelAnimationFrame(this.#reloadFrameID);
this.#trackActivity();
if (!this.online) {
log("🚫 Build finished while offline.");
this.deferredReload = true;
return;
}
log("🛎️ Build completed! Reloading...");
// We use an animation frame to keep the reload from happening before the
// event loop has a chance to process the message.
this.#reloadFrameID = requestAnimationFrame(() => {
window.location.reload();
});
};
#keepAliveListener: BuildEventListener = () => {
this.#trackActivity();
log("🏓 Keep-alive");
};
constructor(url: string | URL) {
super(url);
this.addEventListener("esbuild:start", this.#startListener);
this.addEventListener("esbuild:end", this.#endListener);
this.addEventListener("esbuild:error", this.#errorListener);
this.addEventListener("esbuild:keep-alive", this.#keepAliveListener);
this.addEventListener("error", this.#internalErrorListener);
window.addEventListener("offline", () => {
this.online = false;
});
window.addEventListener("online", () => {
this.online = true;
if (!this.deferredReload) return;
log("🛎️ Reloading after offline build...");
this.deferredReload = false;
window.location.reload();
});
log("🛎️ Listening for build changes...");
this.#keepAliveInterval = setInterval(() => {
const now = Date.now();
if (now - this.lastUpdatedAt < 10_000) return;
this.alive = false;
log("👋 Waiting for build to start...");
}, 15_000);
}
} }

View File

@ -3,8 +3,9 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger"; export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress"; export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current"; export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2025.2.3"; export const VERSION = "2025.2.2";
export const TITLE_DEFAULT = "authentik"; export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";";
export const EVENT_REFRESH = "ak-refresh"; export const EVENT_REFRESH = "ak-refresh";
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle"; export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";

View File

@ -1,28 +0,0 @@
/**
* @file HTTP utilities.
*/
/**
* Get the value of a cookie by its name.
*
* @param cookieName - The name of the cookie to retrieve.
* @returns The value of the cookie, or an empty string if the cookie is not found.
*/
export function getCookie(cookieName: string): string {
if (!cookieName) return "";
if (typeof document === "undefined") return "";
if (typeof document.cookie !== "string") return "";
if (!document.cookie) return "";
const search = cookieName + "=";
// Split the cookie string into individual name=value pairs...
const keyValPairs = document.cookie.split(";").map((cookie) => cookie.trim());
for (const pair of keyValPairs) {
if (!pair.startsWith(search)) continue;
return decodeURIComponent(pair.substring(search.length));
}
return "";
}

View File

@ -2,7 +2,6 @@ import { config } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants"; import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/errors"; import { SentryIgnoredError } from "@goauthentik/common/errors";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
import { import {
ErrorEvent, ErrorEvent,
EventHint, EventHint,
@ -65,7 +64,7 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
}); });
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
if (window.location.pathname.includes("if/")) { if (window.location.pathname.includes("if/")) {
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`); setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`);
} }
if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) {
const Spotlight = await import("@spotlightjs/spotlight"); const Spotlight = await import("@spotlightjs/spotlight");
@ -83,3 +82,13 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
} }
return cfg; return cfg;
} }
// Get the interface name from URL
export function currentInterface(): string {
const pathMatches = window.location.pathname.match(/.+if\/(\w+)\//);
let currentInterface = "unknown";
if (pathMatches && pathMatches.length >= 2) {
currentInterface = pathMatches[1];
}
return currentInterface.toLowerCase();
}

View File

@ -1,7 +1,7 @@
import { currentInterface } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { isUserRoute } from "@goauthentik/elements/router";
import { UiThemeEnum } from "@goauthentik/api"; import { UiThemeEnum, UserSelf } from "@goauthentik/api";
export enum UserDisplay { export enum UserDisplay {
username = "username", username = "username",
@ -18,27 +18,15 @@ export enum LayoutType {
export interface UIConfig { export interface UIConfig {
enabledFeatures: { enabledFeatures: {
/** // API Request drawer in navbar
* Whether to show the API request drawer in the navbar.
*/
apiDrawer: boolean; apiDrawer: boolean;
/** // Notification drawer in navbar
* Whether to show the notification drawer in the navbar.
*/
notificationDrawer: boolean; notificationDrawer: boolean;
/** // Settings in user dropdown
* Whether to show the settings in the user dropdown.
*/
settings: boolean; settings: boolean;
/** // Application edit in library (only shown when user is superuser)
* Whether to show the application edit button in the library.
*
* This is only shown when the user is a superuser.
*/
applicationEdit: boolean; applicationEdit: boolean;
/** // Search bar
* Whether to show the search bar.
*/
search: boolean; search: boolean;
}; };
navbar: { navbar: {
@ -50,77 +38,68 @@ export interface UIConfig {
cardBackground: string; cardBackground: string;
}; };
pagination: { pagination: {
/**
* Number of items to show per page in paginated lists.
*/
perPage: number; perPage: number;
}; };
layout: { layout: {
/**
* Layout type to use for the application.
*/
type: LayoutType; type: LayoutType;
}; };
/**
* Locale to use for the application.
*/
locale: string; locale: string;
/**
* Default values.
*/
defaults: { defaults: {
/**
* Default path to use for user API calls.
*/
userPath: string; userPath: string;
}; };
} }
export function createUIConfig(overrides: Partial<UIConfig> = {}): UIConfig { export class DefaultUIConfig implements UIConfig {
const uiConfig: UIConfig = { enabledFeatures = {
enabledFeatures: { apiDrawer: true,
// TODO: Is the intent that only user routes should have the API drawer disabled, notificationDrawer: true,
// or only admin routes? settings: true,
apiDrawer: !isUserRoute(), applicationEdit: true,
notificationDrawer: true, search: true,
settings: true, };
applicationEdit: true, layout = {
search: true, type: LayoutType.row,
}, };
layout: { navbar = {
type: LayoutType.row, userDisplay: UserDisplay.username,
}, };
navbar: { theme = {
userDisplay: UserDisplay.username, base: UiThemeEnum.Automatic,
}, background: "",
theme: { cardBackground: "",
base: UiThemeEnum.Automatic, };
background: "", pagination = {
cardBackground: "", perPage: 20,
}, };
pagination: { locale = "";
perPage: 20, defaults = {
}, userPath: "users",
locale: "",
defaults: {
userPath: "users",
},
}; };
// TODO: Should we deep merge the overrides instead of shallow? constructor() {
Object.assign(uiConfig, overrides); if (currentInterface() === "user") {
this.enabledFeatures.apiDrawer = false;
return uiConfig; }
}
} }
let cachedUIConfig: UIConfig | null = null; let globalUiConfig: Promise<UIConfig>;
export function getConfigForUser(user: UserSelf): UIConfig {
const settings = user.settings;
let config = new DefaultUIConfig();
if (!settings) {
return config;
}
config = Object.assign(new DefaultUIConfig(), settings);
return config;
}
export function uiConfig(): Promise<UIConfig> { export function uiConfig(): Promise<UIConfig> {
if (cachedUIConfig) return Promise.resolve(cachedUIConfig); if (!globalUiConfig) {
globalUiConfig = me().then((user) => {
return me().then((session) => { return getConfigForUser(user.user);
cachedUIConfig = createUIConfig(session.user.settings); });
}
return cachedUIConfig; return globalUiConfig;
});
} }

View File

@ -2,6 +2,35 @@ import { SentryIgnoredError } from "@goauthentik/common/errors";
import { CSSResult, css } from "lit"; import { CSSResult, css } from "lit";
export function getCookie(name: string): string {
let cookieValue = "";
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
export function convertToSlug(text: string): string {
return text
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "");
}
export function isSlug(text: string): boolean {
const lowered = text.toLowerCase();
const forbidden = /([^\w-]|\s)/.test(lowered);
return lowered === text && !forbidden;
}
/** /**
* Truncate a string based on maximum word count * Truncate a string based on maximum word count
*/ */
@ -34,29 +63,17 @@ export function snakeToCamel(key: string) {
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> { export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
const m = new Map<string, T[]>(); const m = new Map<string, T[]>();
objects.forEach((obj) => { objects.forEach((obj) => {
const group = callback(obj); const group = callback(obj);
if (!m.has(group)) { if (!m.has(group)) {
m.set(group, []); m.set(group, []);
} }
const tProviders = m.get(group) || []; const tProviders = m.get(group) || [];
tProviders.push(obj); tProviders.push(obj);
}); });
return Array.from(m).sort(); return Array.from(m).sort();
} }
/**
* Returns the first non-null and non-undefined argument.
*
* @deprecated Use nullish coalescing operator (??) instead.
* @remarks
*
* This needs a deeper look. Some instances of this function use `new Date()`
* which may cause issues during rendering.
*/
export function first<T>(...args: Array<T | undefined | null>): T { export function first<T>(...args: Array<T | undefined | null>): T {
for (let index = 0; index < args.length; index++) { for (let index = 0; index < args.length; index++) {
const element = args[index]; const element = args[index];
@ -140,26 +157,23 @@ export function adaptCSS(sheet: AdaptableStylesheet | AdaptableStylesheet[]): Ad
return Array.isArray(sheet) ? sheet.map(_adaptCSS) : _adaptCSS(sheet); return Array.isArray(sheet) ? sheet.map(_adaptCSS) : _adaptCSS(sheet);
} }
const _timeUnits = new Map<Intl.RelativeTimeFormatUnit, number>([
["year", 24 * 60 * 60 * 1000 * 365],
["month", (24 * 60 * 60 * 1000 * 365) / 12],
["day", 24 * 60 * 60 * 1000],
["hour", 60 * 60 * 1000],
["minute", 60 * 1000],
["second", 1000],
]);
export function getRelativeTime(d1: Date, d2: Date = new Date()): string { export function getRelativeTime(d1: Date, d2: Date = new Date()): string {
const elapsed = d1.getTime() - d2.getTime();
const rtf = new Intl.RelativeTimeFormat("default", { numeric: "auto" }); const rtf = new Intl.RelativeTimeFormat("default", { numeric: "auto" });
const elapsed = d1.getTime() - d2.getTime();
const _timeUnits: [Intl.RelativeTimeFormatUnit, number][] = [ // "Math.abs" accounts for both "past" & "future" scenarios
["year", 1000 * 60 * 60 * 24 * 365],
["month", (24 * 60 * 60 * 1000 * 365) / 12],
["day", 1000 * 60 * 60 * 24],
["hour", 1000 * 60 * 60],
["minute", 1000 * 60],
["second", 1000],
];
for (const [key, value] of _timeUnits) { for (const [key, value] of _timeUnits) {
if (Math.abs(elapsed) > value || key === "second") { if (Math.abs(elapsed) > value || key == "second") {
let rounded = Math.round(elapsed / value); return rtf.format(Math.round(elapsed / value), key);
if (!isFinite(rounded)) {
rounded = 0;
}
return rtf.format(rounded, key);
} }
} }
return rtf.format(Math.round(elapsed / 1000), "second"); return rtf.format(Math.round(elapsed / 1000), "second");

View File

@ -1,4 +1,4 @@
import { formatAsSlug } from "@goauthentik/elements/router"; import { convertToSlug } from "@goauthentik/common/utils";
import { html } from "lit"; import { html } from "lit";
import { customElement, property, query } from "lit/decorators.js"; import { customElement, property, query } from "lit/decorators.js";
@ -34,7 +34,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 // 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. // component, such as a custom forms manager, may receive it.
handleTouch(ev: Event) { handleTouch(ev: Event) {
this.input.value = formatAsSlug(this.input.value); this.input.value = convertToSlug(this.input.value);
this.value = this.input.value; this.value = this.input.value;
if (this.origin && this.origin.value === "" && this.input.value === "") { if (this.origin && this.origin.value === "" && this.input.value === "") {
@ -67,7 +67,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// "any event which adds or removes a character but leaves the rest of the slug looking like // "any event which adds or removes a character but leaves the rest of the slug looking like
// the previous iteration, set it to the current iteration." // the previous iteration, set it to the current iteration."
const newSlug = formatAsSlug(ev.target.value); const newSlug = convertToSlug(ev.target.value);
const oldSlug = this.input.value; const oldSlug = this.input.value;
const [shorter, longer] = const [shorter, longer] =
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug]; newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];

View File

@ -1,169 +0,0 @@
/**
* @file
* Client-side observer for ESBuild events.
*/
import type { Message as ESBuildMessage } from "esbuild";
const logPrefix = "👷 [ESBuild]";
const log = console.debug.bind(console, logPrefix);
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
/**
* A client-side watcher for ESBuild.
*
* Note that this should be conditionally imported in your code, so that
* ESBuild may tree-shake it out of production builds.
*
* ```ts
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
* const { ESBuildObserver } = await import("@goauthentik/common/development/build-observer");
*
* new ESBuildObserver(process.env.WATCHER_URL);
* }
* ```
*
*/
export class ESBuildObserver extends EventSource {
/**
* Whether the watcher has a recent connection to the server.
*/
alive = true;
/**
* The number of errors that have occurred since the watcher started.
*/
errorCount = 0;
/**
* Whether a reload has been requested while offline.
*/
deferredReload = false;
/**
* The last time a message was received from the server.
*/
lastUpdatedAt = Date.now();
/**
* Whether the browser considers itself online.
*/
online = true;
/**
* The ID of the animation frame for the reload.
*/
#reloadFrameID = -1;
/**
* The interval for the keep-alive check.
*/
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
#trackActivity = () => {
this.lastUpdatedAt = Date.now();
this.alive = true;
};
#startListener: BuildEventListener = () => {
this.#trackActivity();
log("⏰ Build started...");
};
#internalErrorListener = () => {
this.errorCount += 1;
if (this.errorCount > 100) {
clearTimeout(this.#keepAliveInterval);
this.close();
log("⛔️ Closing connection");
}
};
#errorListener: BuildEventListener<string> = (event) => {
this.#trackActivity();
// eslint-disable-next-line no-console
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
for (const error of esbuildErrorMessages) {
console.warn(error.text);
if (error.location) {
console.debug(
`file://${error.location.file}:${error.location.line}:${error.location.column}`,
);
console.debug(error.location.lineText);
}
}
// eslint-disable-next-line no-console
console.groupEnd();
};
#endListener: BuildEventListener = () => {
cancelAnimationFrame(this.#reloadFrameID);
this.#trackActivity();
if (!this.online) {
log("🚫 Build finished while offline.");
this.deferredReload = true;
return;
}
log("🛎️ Build completed! Reloading...");
// We use an animation frame to keep the reload from happening before the
// event loop has a chance to process the message.
this.#reloadFrameID = requestAnimationFrame(() => {
window.location.reload();
});
};
#keepAliveListener: BuildEventListener = () => {
this.#trackActivity();
log("🏓 Keep-alive");
};
constructor(url: string | URL) {
super(url);
this.addEventListener("esbuild:start", this.#startListener);
this.addEventListener("esbuild:end", this.#endListener);
this.addEventListener("esbuild:error", this.#errorListener);
this.addEventListener("esbuild:keep-alive", this.#keepAliveListener);
this.addEventListener("error", this.#internalErrorListener);
window.addEventListener("offline", () => {
this.online = false;
});
window.addEventListener("online", () => {
this.online = true;
if (!this.deferredReload) return;
log("🛎️ Reloading after offline build...");
this.deferredReload = false;
window.location.reload();
});
log("🛎️ Listening for build changes...");
this.#keepAliveInterval = setInterval(() => {
const now = Date.now();
if (now - this.lastUpdatedAt < 10_000) return;
this.alive = false;
log("👋 Waiting for build to start...");
}, 15_000);
}
}

View File

@ -1,6 +1,10 @@
import { formatPageTitle } from "@goauthentik/common/client"; import {
import { EVENT_SIDEBAR_TOGGLE, EVENT_WS_MESSAGE } from "@goauthentik/common/constants"; EVENT_SIDEBAR_TOGGLE,
EVENT_WS_MESSAGE,
TITLE_DEFAULT,
} from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global"; import { globalAK } from "@goauthentik/common/global";
import { currentInterface } from "@goauthentik/common/sentry";
import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config"; import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import "@goauthentik/components/ak-nav-buttons"; import "@goauthentik/components/ak-nav-buttons";
@ -121,8 +125,17 @@ export class PageHeader extends WithBrandConfig(AKElement) {
this.uiConfig.navbar.userDisplay = UserDisplay.none; this.uiConfig.navbar.userDisplay = UserDisplay.none;
} }
setTitle(pageTitle?: string) { setTitle(header?: string) {
document.title = formatPageTitle(this.brand, pageTitle); const currentIf = currentInterface();
let title = this.brand?.brandingTitle || TITLE_DEFAULT;
if (currentIf === "admin") {
title = `${msg("Admin")} - ${title}`;
}
// Prepend the header to the title
if (header !== undefined && header !== "") {
title = `${header} - ${title}`;
}
document.title = title;
} }
willUpdate() { willUpdate() {

View File

@ -1,7 +1,6 @@
import { CURRENT_CLASS, EVENT_REFRESH } from "@goauthentik/common/constants"; import { CURRENT_CLASS, EVENT_REFRESH, ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { ROUTE_SEPARATOR } from "@goauthentik/elements/router"; import { getURLParams, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { getRouteParams, patchRouteParams } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
@ -11,8 +10,6 @@ import { ifDefined } from "lit/directives/if-defined.js";
import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css"; import PFTabs from "@patternfly/patternfly/components/Tabs/tabs.css";
import PFGlobal from "@patternfly/patternfly/patternfly-base.css"; import PFGlobal from "@patternfly/patternfly/patternfly-base.css";
const SLOT_PREFIX = "page-";
@customElement("ak-tabs") @customElement("ak-tabs")
export class Tabs extends AKElement { export class Tabs extends AKElement {
@property() @property()
@ -21,14 +18,6 @@ export class Tabs extends AKElement {
@property() @property()
currentPage?: string; currentPage?: string;
get currentPageParamName(): string | null {
if (!this.currentPage) return null;
return this.currentPage.startsWith(SLOT_PREFIX)
? this.currentPage.slice(SLOT_PREFIX.length)
: this.currentPage;
}
@property({ type: Boolean }) @property({ type: Boolean })
vertical = false; vertical = false;
@ -79,30 +68,13 @@ export class Tabs extends AKElement {
super.disconnectedCallback(); super.disconnectedCallback();
} }
/** onClick(slot?: string): void {
* Sync route params with the current page. this.currentPage = slot;
* const params: { [key: string]: string | undefined } = {};
* @todo This should be moved to a router component. params[this.pageIdentifier] = slot;
*/ updateURLParams(params);
#syncRouteParams(): void {
const { currentPageParamName } = this;
if (!currentPageParamName) return;
patchRouteParams({
[this.pageIdentifier]: currentPageParamName,
});
}
activatePage(nextPage?: string): void {
this.currentPage = nextPage;
this.#syncRouteParams();
const page = this.querySelector(`[slot='${this.currentPage}']`); const page = this.querySelector(`[slot='${this.currentPage}']`);
if (!page) return; if (!page) return;
page.dispatchEvent(new CustomEvent(EVENT_REFRESH)); page.dispatchEvent(new CustomEvent(EVENT_REFRESH));
page.dispatchEvent(new CustomEvent("activate")); page.dispatchEvent(new CustomEvent("activate"));
} }
@ -110,7 +82,7 @@ export class Tabs extends AKElement {
renderTab(page: Element): TemplateResult { renderTab(page: Element): TemplateResult {
const slot = page.attributes.getNamedItem("slot")?.value; const slot = page.attributes.getNamedItem("slot")?.value;
return html` <li class="pf-c-tabs__item ${slot === this.currentPage ? CURRENT_CLASS : ""}"> return html` <li class="pf-c-tabs__item ${slot === this.currentPage ? CURRENT_CLASS : ""}">
<button class="pf-c-tabs__link" @click=${() => this.activatePage(slot)}> <button class="pf-c-tabs__link" @click=${() => this.onClick(slot)}>
<span class="pf-c-tabs__item-text"> ${page.getAttribute("data-tab-title")} </span> <span class="pf-c-tabs__item-text"> ${page.getAttribute("data-tab-title")} </span>
</button> </button>
</li>`; </li>`;
@ -118,41 +90,24 @@ export class Tabs extends AKElement {
render(): TemplateResult { render(): TemplateResult {
const pages = Array.from(this.querySelectorAll(":scope > [slot^='page-']")); const pages = Array.from(this.querySelectorAll(":scope > [slot^='page-']"));
if (window.location.hash.includes(ROUTE_SEPARATOR)) { if (window.location.hash.includes(ROUTE_SEPARATOR)) {
const params = getRouteParams(); const params = getURLParams();
const slotName = params[this.pageIdentifier];
if ( if (
slotName && this.pageIdentifier in params &&
typeof slotName === "string" &&
!this.currentPage && !this.currentPage &&
this.querySelector(`[slot='${slotName}']`) !== null this.querySelector(`[slot='${params[this.pageIdentifier]}']`) !== null
) { ) {
console.debug( // To update the URL to match with the current slot
`authentik/tabs (${this.pageIdentifier}): setting current page to`, this.onClick(params[this.pageIdentifier] as string);
slotName,
);
this.activatePage(slotName);
} }
} }
if (!this.currentPage) { if (!this.currentPage) {
if (pages.length < 1) { if (pages.length < 1) {
return html`<h1>${msg("no tabs defined")}</h1>`; return html`<h1>${msg("no tabs defined")}</h1>`;
} }
const wantedPage = pages[0].attributes.getNamedItem("slot")?.value; const wantedPage = pages[0].attributes.getNamedItem("slot")?.value;
this.onClick(wantedPage);
console.debug(
`authentik/tabs (${this.pageIdentifier}): setting current page to`,
wantedPage,
);
this.activatePage(wantedPage);
} }
return html`<div class="pf-c-tabs ${this.vertical ? "pf-m-vertical pf-m-box" : ""}"> return html`<div class="pf-c-tabs ${this.vertical ? "pf-m-vertical pf-m-box" : ""}">
<ul class="pf-c-tabs__list"> <ul class="pf-c-tabs__list">
${pages.map((page) => this.renderTab(page))} ${pages.map((page) => this.renderTab(page))}

View File

@ -1,6 +1,6 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { setRouteParams } from "@goauthentik/elements/router/utils"; import { setURLParams } from "@goauthentik/elements/router/RouteMatch";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, html } from "lit";
@ -84,7 +84,7 @@ export class TreeViewNode extends AKElement {
if (this.host) { if (this.host) {
this.host.activeNode = this; this.host.activeNode = this;
} }
setRouteParams({ path: this.fullPath }); setURLParams({ path: this.fullPath });
this.dispatchEvent( this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, { new CustomEvent(EVENT_REFRESH, {
bubbles: true, bubbles: true,

View File

@ -1,13 +1,11 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIError } from "@goauthentik/common/errors"; import { parseAPIError } from "@goauthentik/common/errors";
import { MessageLevel } from "@goauthentik/common/messages"; import { MessageLevel } from "@goauthentik/common/messages";
import { dateToUTC } from "@goauthentik/common/utils"; import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
import { camelToSnake } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement"; import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers"; import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { formatAsSlug } from "@goauthentik/elements/router/slugs";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
@ -225,11 +223,11 @@ export abstract class Form<T> extends AKElement {
// Only attach handler if the slug is already equal to the name // Only attach handler if the slug is already equal to the name
// if not, they are probably completely different and shouldn't update // if not, they are probably completely different and shouldn't update
// each other // each other
if (formatAsSlug(input.value) !== slugField.value) { if (convertToSlug(input.value) !== slugField.value) {
return; return;
} }
nameInput.addEventListener("input", () => { nameInput.addEventListener("input", () => {
slugField.value = formatAsSlug(input.value); slugField.value = convertToSlug(input.value);
}); });
}); });
} }

View File

@ -1,6 +1,6 @@
import { convertToSlug } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { FormGroup } from "@goauthentik/elements/forms/FormGroup"; import { FormGroup } from "@goauthentik/elements/forms/FormGroup";
import { formatAsSlug } from "@goauthentik/elements/router";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, css } from "lit"; import { CSSResult, css } from "lit";
@ -123,7 +123,7 @@ export class HorizontalFormElement extends AKElement {
if (this.name === "slug" || this.slugMode) { if (this.name === "slug" || this.slugMode) {
this.querySelectorAll<HTMLInputElement>("input[type='text']").forEach((input) => { this.querySelectorAll<HTMLInputElement>("input[type='text']").forEach((input) => {
input.addEventListener("keyup", () => { input.addEventListener("keyup", () => {
input.value = formatAsSlug(input.value); input.value = convertToSlug(input.value);
}); });
}); });
} }

View File

@ -1,60 +1,66 @@
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { TemplateResult, html, nothing } from "lit"; import { TemplateResult, html } from "lit";
import { until } from "lit/directives/until.js"; import { until } from "lit/directives/until.js";
export type PrimitiveRouteParameter = string | number | boolean | null | undefined; export const SLUG_REGEX = "[-a-zA-Z0-9_]+";
export type RouteParameterRecord = { [key: string]: PrimitiveRouteParameter }; export const ID_REGEX = "\\d+";
export const UUID_REGEX = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
export type RouteCallback<P = unknown> = ( export interface RouteArgs {
params: P, [key: string]: string;
) => SlottedTemplateResult | Promise<SlottedTemplateResult>; }
export type RouteInitTuple = [string | RegExp, RouteCallback | undefined]; export class Route {
url: RegExp;
export class Route<P = unknown> { private element?: TemplateResult;
public readonly pattern: URLPattern; private callback?: (args: RouteArgs) => Promise<TemplateResult>;
#callback: RouteCallback<P>; constructor(url: RegExp, callback?: (args: RouteArgs) => Promise<TemplateResult>) {
this.url = url;
constructor(patternInit: URLPatternInit | string, callback: RouteCallback<P>) { this.callback = callback;
this.pattern = new URLPattern(
typeof patternInit === "string"
? {
pathname: patternInit,
}
: patternInit,
);
this.#callback = callback;
} }
/** redirect(to: string, raw = false): Route {
* Create a new redirect route. this.callback = async () => {
*
* @param patternInit The pattern to match.
* @param to The URL to redirect to.
* @param raw Whether to use the raw URL or not.
*/
static redirect(patternInit: URLPatternInit | string, to: string, raw = false): Route<unknown> {
return new Route(patternInit, () => {
console.debug(`authentik/router: redirecting ${to}`); console.debug(`authentik/router: redirecting ${to}`);
if (!raw) { if (!raw) {
window.location.hash = `#${to}`; window.location.hash = `#${to}`;
} else { } else {
window.location.hash = to; window.location.hash = to;
} }
return html``;
return nothing; };
}); return this;
} }
render(params: P): TemplateResult { then(render: (args: RouteArgs) => TemplateResult): Route {
return html`${until( this.callback = async (args) => {
this.#callback(params), return render(args);
html`<ak-empty-state ?loading=${true}></ak-empty-state>`, };
)}`; return this;
}
thenAsync(render: (args: RouteArgs) => Promise<TemplateResult>): Route {
this.callback = render;
return this;
}
render(args: RouteArgs): TemplateResult {
if (this.callback) {
return html`${until(
this.callback(args),
html`<ak-empty-state ?loading=${true}></ak-empty-state>`,
)}`;
}
if (this.element) {
return this.element;
}
throw new Error("Route does not have callback or element");
}
toString(): string {
return `<Route url=${this.url} callback=${this.callback ? "true" : "false"}>`;
} }
} }

View File

@ -0,0 +1,66 @@
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { Route } from "@goauthentik/elements/router/Route";
import { TemplateResult } from "lit";
export class RouteMatch {
route: Route;
arguments: { [key: string]: string };
fullUrl?: string;
constructor(route: Route) {
this.route = route;
this.arguments = {};
}
render(): TemplateResult {
return this.route.render(this.arguments);
}
toString(): string {
return `<RouteMatch url=${this.fullUrl} route=${this.route} arguments=${JSON.stringify(
this.arguments,
)}>`;
}
}
export function getURLParam<T>(key: string, fallback: T): T {
const params = getURLParams();
if (key in params) {
return params[key] as T;
}
return fallback;
}
export function getURLParams(): { [key: string]: unknown } {
const params = {};
if (window.location.hash.includes(ROUTE_SEPARATOR)) {
const urlParts = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR, 2);
const rawParams = decodeURIComponent(urlParts[1]);
try {
return JSON.parse(rawParams);
} catch {
return params;
}
}
return params;
}
export function setURLParams(params: { [key: string]: unknown }, replace = true): void {
const paramsString = JSON.stringify(params);
const currentUrl = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
const newUrl = `#${currentUrl};${encodeURIComponent(paramsString)}`;
if (replace) {
history.replaceState(undefined, "", newUrl);
} else {
history.pushState(undefined, "", newUrl);
}
}
export function updateURLParams(params: { [key: string]: unknown }, replace = true): void {
const currentParams = getURLParams();
for (const key in params) {
currentParams[key] = params[key] as string;
}
setURLParams(currentParams, replace);
}

View File

@ -11,7 +11,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-router-404") @customElement("ak-router-404")
export class Router404 extends AKElement { export class Router404 extends AKElement {
@property() @property()
pathname = ""; url = "";
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, PFEmptyState, PFTitle]; return [PFBase, PFEmptyState, PFTitle];
@ -23,7 +23,7 @@ export class Router404 extends AKElement {
<i class="fas fa-question-circle pf-c-empty-state__icon" aria-hidden="true"></i> <i class="fas fa-question-circle pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 class="pf-c-title pf-m-lg">${msg("Not found")}</h1> <h1 class="pf-c-title pf-m-lg">${msg("Not found")}</h1>
<div class="pf-c-empty-state__body"> <div class="pf-c-empty-state__body">
${msg(str`The URL "${this.pathname}" was not found.`)} ${msg(str`The URL "${this.url}" was not found.`)}
</div> </div>
<a href="#/" class="pf-c-button pf-m-primary" type="button" <a href="#/" class="pf-c-button pf-m-primary" type="button"
>${msg("Return home")}</a >${msg("Return home")}</a

View File

@ -1,47 +1,57 @@
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { Route } from "@goauthentik/elements/router/Route"; import { Route } from "@goauthentik/elements/router/Route";
import { RouteMatch } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/Router404"; import "@goauthentik/elements/router/Router404";
import { matchRoute, pluckRoute } from "@goauthentik/elements/router/utils";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
// Poliyfill for hashchange.newURL, // Poliyfill for hashchange.newURL,
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onhashchange // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onhashchange
window.addEventListener("load", () => { window.addEventListener("load", () => {
if (window.HashChangeEvent) return; if (!window.HashChangeEvent)
(function () {
console.debug("authentik/router: polyfilling hashchange event"); let lastURL = document.URL;
window.addEventListener("hashchange", function (event) {
let lastURL = document.URL; Object.defineProperty(event, "oldURL", {
enumerable: true,
window.addEventListener("hashchange", function (event) { configurable: true,
Object.defineProperty(event, "oldURL", { value: lastURL,
enumerable: true, });
configurable: true, Object.defineProperty(event, "newURL", {
value: lastURL, enumerable: true,
}); configurable: true,
value: document.URL,
Object.defineProperty(event, "newURL", { });
enumerable: true, lastURL = document.URL;
configurable: true, });
value: document.URL, })();
});
lastURL = document.URL;
});
}); });
export function paramURL(url: string, params?: { [key: string]: unknown }): string {
let finalUrl = "#";
finalUrl += url;
if (params) {
finalUrl += ";";
finalUrl += encodeURIComponent(JSON.stringify(params));
}
return finalUrl;
}
export function navigate(url: string, params?: { [key: string]: unknown }): void {
window.location.assign(paramURL(url, params));
}
@customElement("ak-router-outlet") @customElement("ak-router-outlet")
export class RouterOutlet extends AKElement { export class RouterOutlet extends AKElement {
@state() @property({ attribute: false })
private currentPathname: string | null = null; current?: RouteMatch;
@property() @property()
public defaultURL?: string; defaultUrl?: string;
@property({ attribute: false }) @property({ attribute: false })
public routes: Route[] = []; routes: Route[] = [];
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
@ -49,7 +59,6 @@ export class RouterOutlet extends AKElement {
:host { :host {
background-color: transparent !important; background-color: transparent !important;
} }
*:first-child { *:first-child {
flex-direction: column; flex-direction: column;
} }
@ -57,78 +66,56 @@ export class RouterOutlet extends AKElement {
]; ];
} }
connectedCallback(): void { constructor() {
super.connectedCallback(); super();
window.addEventListener("hashchange", (ev: HashChangeEvent) => this.navigate(ev));
window.addEventListener("hashchange", this.#refreshLocation);
} }
disconnectedCallback(): void { firstUpdated(): void {
super.disconnectedCallback(); this.navigate();
window.removeEventListener("hashchange", this.#refreshLocation);
} }
protected firstUpdated(): void { navigate(ev?: HashChangeEvent): void {
const currentPathname = pluckRoute(window.location).pathname; let activeUrl = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
if (ev) {
if (currentPathname) return; // Check if we've actually changed paths
const oldPath = new URL(ev.oldURL).hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
console.debug("authentik/router: defaulted route to empty pathname"); if (oldPath === activeUrl) return;
}
this.#redirectToDefault(); if (activeUrl === "") {
} activeUrl = this.defaultUrl || "/";
window.location.hash = `#${activeUrl}`;
#redirectToDefault(): void { console.debug(`authentik/router: defaulted URL to ${window.location.hash}`);
const nextPathname = this.defaultURL || "/"; return;
}
window.location.hash = "#" + nextPathname; let matchedRoute: RouteMatch | null = null;
} this.routes.some((route) => {
const match = route.url.exec(activeUrl);
#refreshLocation = (event: HashChangeEvent): void => { if (match !== null) {
console.debug("authentik/router: hashchange event", event); matchedRoute = new RouteMatch(route);
const nextPathname = pluckRoute(event.newURL).pathname; matchedRoute.arguments = match.groups || {};
const previousPathname = pluckRoute(event.oldURL).pathname; matchedRoute.fullUrl = activeUrl;
console.debug("authentik/router: found match ", matchedRoute);
if (previousPathname === nextPathname) { return true;
console.debug("authentik/router: hashchange event, but no change in path", event, { }
currentPathname: nextPathname, return false;
previousPathname, });
if (!matchedRoute) {
console.debug(`authentik/router: route "${activeUrl}" not defined`);
const route = new Route(RegExp(""), async () => {
return html`<div class="pf-c-page__main">
<ak-router-404 url=${activeUrl}></ak-router-404>
</div>`;
}); });
matchedRoute = new RouteMatch(route);
return; matchedRoute.arguments = route.url.exec(activeUrl)?.groups || {};
matchedRoute.fullUrl = activeUrl;
} }
this.current = matchedRoute;
if (!nextPathname) { }
console.debug(`authentik/router: defaulted route to ${nextPathname}`);
this.#redirectToDefault();
return;
}
this.currentPathname = nextPathname;
};
render(): TemplateResult | undefined { render(): TemplateResult | undefined {
let currentPathname = this.currentPathname; return this.current?.render();
if (!currentPathname) {
currentPathname = pluckRoute(window.location).pathname;
}
const match = matchRoute(currentPathname, this.routes);
if (!match) {
return html`<div class="pf-c-page__main">
<ak-router-404 pathname=${currentPathname}></ak-router-404>
</div>`;
}
console.debug("authentik/router: found match", match);
const { parameters, route } = match;
return route.render(parameters);
} }
} }

View File

@ -1,26 +0,0 @@
/**
* @file Router constants.
*/
/**
* Route separator, used to separate the path from the mock query string.
*/
export const ROUTE_SEPARATOR = "?";
/**
* Slug pattern, matching alphanumeric characters, underscores, and hyphens.
*/
export const SLUG_PATTERN = "[a-zA-Z0-9_\\-]+";
/**
* Numeric ID pattern, typically used for database IDs.
*/
export const ID_PATTERN = "\\d+";
/**
* UUID v4 pattern
*
* @todo Enforcing this format on the front-end may be a bit too strict.
* We may want to allow other UUID formats, or move this to a validation step.
*/
export const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";

View File

@ -1,6 +0,0 @@
export * from "./Route.js";
export * from "./constants.js";
export * from "./Router404.js";
export * from "./RouterOutlet.js";
export * from "./utils.js";
export * from "./slugs.js";

View File

@ -1,23 +0,0 @@
/**
* Given a string, return a URL-friendly slug.
*/
export function formatAsSlug(text: string): string {
return text
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "");
}
/**
* Type guard to check if a given string is a valid URL slug, i.e.
* only containing alphanumeric characters, dashes, and underscores.
*/
export function isSlug(input: unknown): input is string {
if (typeof input !== "string") return false;
if (!input) return false;
const lowered = input.toLowerCase();
if (input !== lowered) return false;
return /([^\w-]|\s)/.test(lowered);
}

View File

@ -1,254 +0,0 @@
import { ROUTE_SEPARATOR } from "@goauthentik/elements/router";
import type { Route, RouteParameterRecord } from "@goauthentik/elements/router/Route";
export interface RouteMatch<P extends RouteParameterRecord = RouteParameterRecord> {
readonly route: Route<P>;
readonly parameters: P;
readonly pathname: string;
}
/**
* Match a route against a pathname.
*/
export function matchRoute<P extends RouteParameterRecord>(
pathname: string,
routes: Route<P>[],
): RouteMatch<P> | null {
if (!pathname) return null;
for (const route of routes) {
const match = route.pattern.exec({ pathname });
if (!match) continue;
console.debug(
`authentik/router: matched route ${route.pattern} to ${pathname} with params`,
match.pathname.groups,
);
return {
route: route as Route<P>,
parameters: match.pathname.groups as P,
pathname,
};
}
console.debug(`authentik/router: no route matched ${pathname}`);
return null;
}
/**
* Navigate to a route.
*
* @param {string} pathname The pathname of the route.
* @param {RouteParameterRecord} params The parameters to serialize.
*/
export function navigate(pathname: string, params?: RouteParameterRecord): void {
window.location.assign(formatRouteHash(pathname, params));
}
/**
* Create a route hash from a pathname and parameters.
*
* @param {string} pathname The pathname of the route.
* @param {RouteParameterRecord} params The parameters to serialize.
* @returns {string} The formatted route hash, starting with `#`.
* @see {@linkcode navigate} to navigate to a route.
*/
export function formatRouteHash(pathname: string, params?: RouteParameterRecord): string {
const routePrefix = "#" + pathname;
if (!params) return routePrefix;
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === "boolean" && value) {
searchParams.set(key, "true");
continue;
}
if (typeof value === "undefined" || value === null) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
searchParams.append(key, item.toString());
}
continue;
}
searchParams.set(key, String(value));
}
return [routePrefix, searchParams.toString()].join(ROUTE_SEPARATOR);
}
/**
* Create a route to an interface by name, optionally with parameters.
*/
export function formatInterfaceRoute(
interfaceName: RouteInterfaceName,
pathname?: string,
params?: RouteParameterRecord,
): string {
const prefix = `/if/${interfaceName}/`;
if (!pathname) return prefix;
return prefix + formatRouteHash(pathname, params);
}
export interface SerializedRoute {
pathname: string;
serializedParameters?: string;
}
export function pluckRoute(source: Pick<URL, "hash"> | string = window.location): SerializedRoute {
source = typeof source === "string" ? new URL(source) : source;
const [pathname, serializedParameters] = source.hash.slice(1).split(ROUTE_SEPARATOR, 2);
return {
pathname,
serializedParameters,
};
}
/**
* Get a parameter from the current route.
*
* @template T - The type of the parameter.
* @param {string} paramName - The name of the parameter to retrieve.
* @param {T} fallback - The fallback value to return if the parameter is not found.
*/
export function getRouteParameter<T>(paramName: string, fallback: T): T {
const params = getRouteParams();
if (Object.hasOwn(params, paramName)) {
return params[paramName] as T;
}
return fallback;
}
/**
* Get the route parameters from the URL.
*
* @template T - The type of the route parameters.
*/
export function getRouteParams<T = RouteParameterRecord>(): T {
const { serializedParameters } = pluckRoute();
if (!serializedParameters) return {} as T;
let searchParams: URLSearchParams;
try {
searchParams = new URLSearchParams(serializedParameters);
} catch (_error) {
console.warn("Failed to parse URL parameters", serializedParameters);
return {} as T;
}
const decodedParameters: Record<string, unknown> = {};
for (const [key, value] of searchParams.entries()) {
if (value === "true" || value === "") {
decodedParameters[key] = true;
continue;
}
if (value === "false") {
decodedParameters[key] = false;
continue;
}
decodedParameters[key] = value;
}
return decodedParameters as T;
}
/**
* Set the route parameters in the URL.
*
* @param nextParams - The JSON-serializable parameters to set in the URL.
* @param replace - Whether to replace the current history entry or create a new one.
*/
export function setRouteParams(nextParams: RouteParameterRecord, replace = true): void {
const { pathname } = pluckRoute();
const nextHash = formatRouteHash(pathname, nextParams);
if (replace) {
history.replaceState(undefined, "", nextHash);
} else {
history.pushState(undefined, "", nextHash);
}
}
/**
* Patch the route parameters in the URL, retaining existing parameters not specified in the input.
*
* @param patchedParams - The parameters to patch in the URL.
* @param replace - Whether to replace the current history entry or create a new one.
*
* @todo Most instances of this should be URL search params, not hash params.
*/
export function patchRouteParams(patchedParams: RouteParameterRecord, replace = true): void {
const currentParams = getRouteParams();
const nextParams = { ...currentParams, ...patchedParams };
setRouteParams(nextParams, replace);
}
/**
* Type guard to check if a given input is parsable as a URL.
*
* ```js
* isURLInput("https://example.com") // true
* isURLInput("invalid-url") // false
* isURLInput(new URL("https://example.com")) // true
* ```
*/
export function isURLInput(input: unknown): input is string | URL {
if (typeof input !== "string" && !(input instanceof URL)) return false;
if (!input) return false;
return URL.canParse(input);
}
/**
* The name identifier for the current interface.
*/
export type RouteInterfaceName = "user" | "admin" | "flow" | "unknown";
/**
* Read the current interface route parameter from the URL.
*
* @param location - The location object to read the pathname from. Defaults to `window.location`.
* * @returns The name of the current interface, or "unknown" if not found.
*/
export function readInterfaceRouteParam(
location: Pick<URL, "pathname"> = window.location,
): RouteInterfaceName {
const [, currentInterface = "unknown"] = location.pathname.match(/.+if\/(\w+)\//) || [];
return currentInterface.toLowerCase() as RouteInterfaceName;
}
/**
* Predicate to determine if the current route is for the admin interface.
*/
export function isAdminRoute(location: Pick<URL, "pathname"> = window.location): boolean {
return readInterfaceRouteParam(location) === "admin";
}
/**
* Predicate to determine if the current route is for the user interface.
*/
export function isUserRoute(location: Pick<URL, "pathname"> = window.location): boolean {
return readInterfaceRouteParam(location) === "user";
}

View File

@ -1,5 +1,5 @@
import { ROUTE_SEPARATOR } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { pluckRoute } from "@goauthentik/elements/router";
import { CSSResult, css } from "lit"; import { CSSResult, css } from "lit";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
@ -69,9 +69,9 @@ export class SidebarItem extends AKElement {
} }
@property() @property()
pathname?: string; path?: string;
#activeMatchers: URLPattern[] = []; activeMatchers: RegExp[] = [];
@property({ type: Boolean }) @property({ type: Boolean })
expanded = false; expanded = false;
@ -94,57 +94,41 @@ export class SidebarItem extends AKElement {
} }
@property({ attribute: false }) @property({ attribute: false })
set activeWhen(nextPathnamePatterns: string[]) { set activeWhen(regexp: string[]) {
for (const pathname of nextPathnamePatterns) { regexp.forEach((r) => {
this.#activeMatchers.push(new URLPattern({ pathname })); this.activeMatchers.push(new RegExp(r));
} });
} }
firstUpdated(): void { firstUpdated(): void {
this.#hashListener(); this.onHashChange();
window.addEventListener("hashchange", this.#hashListener); window.addEventListener("hashchange", () => this.onHashChange());
} }
#hashListener = (): void => { onHashChange(): void {
const currentPathname = pluckRoute(window.location).pathname; const activePath = window.location.hash.slice(1, Infinity).split(ROUTE_SEPARATOR)[0];
this.childItems.forEach((item) => { this.childItems.forEach((item) => {
this.expandParentRecursive(currentPathname, item); this.expandParentRecursive(activePath, item);
}); });
this.isActive = this.matchesPath(activePath);
}
this.isActive = this.matchesPath(currentPathname); private matchesPath(path: string): boolean {
}; if (!this.path) {
return false;
}
private matchesPath(targetPathname: string): boolean { const ourPath = this.path.split(";")[0];
if (!this.pathname) return false; const pathIsWholePath = new RegExp(`^${ourPath}$`).test(path);
const pathIsAnActivePath = this.activeMatchers.some((v) => v.test(path));
const criteria = { return pathIsWholePath || pathIsAnActivePath;
pathname: targetPathname,
};
const matchesWholePath = new URLPattern({
pathname: this.pathname,
}).test(criteria);
const activePath = this.#activeMatchers.some((v) => v.test(criteria));
return matchesWholePath || activePath;
} }
expandParentRecursive(activePath: string, item: SidebarItem): void { expandParentRecursive(activePath: string, item: SidebarItem): void {
if (item.matchesPath(activePath) && item.parent) { if (item.matchesPath(activePath) && item.parent) {
item.parent.expanded = true; item.parent.expanded = true;
this.requestUpdate(); this.requestUpdate();
if (!item.childItems.length) {
requestAnimationFrame(() => {
this.scrollIntoView({
block: "nearest",
});
});
}
} }
item.childItems.forEach((i) => this.expandParentRecursive(activePath, i)); item.childItems.forEach((i) => this.expandParentRecursive(activePath, i));
} }
@ -207,7 +191,7 @@ export class SidebarItem extends AKElement {
renderWithPath() { renderWithPath() {
return html` return html`
<a <a
href="${this.isAbsoluteLink ? "" : "#"}${this.pathname}" href="${this.isAbsoluteLink ? "" : "#"}${this.path}"
class="pf-c-nav__link ${this.isActive ? "pf-m-current" : ""}" class="pf-c-nav__link ${this.isActive ? "pf-m-current" : ""}"
> >
<slot name="label"></slot> <slot name="label"></slot>
@ -225,11 +209,11 @@ export class SidebarItem extends AKElement {
renderInner() { renderInner() {
if (this.childItems.length > 0) { if (this.childItems.length > 0) {
return this.pathname ? this.renderWithPathAndChildren() : this.renderWithChildren(); return this.path ? this.renderWithPathAndChildren() : this.renderWithChildren();
} }
return html`<li class="pf-c-nav__item"> return html`<li class="pf-c-nav__item">
${this.pathname ? this.renderWithPath() : this.renderWithLabel()} ${this.path ? this.renderWithPath() : this.renderWithLabel()}
</li>`; </li>`;
} }
} }

View File

@ -7,7 +7,7 @@ import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/chips/Chip"; import "@goauthentik/elements/chips/Chip";
import "@goauthentik/elements/chips/ChipGroup"; import "@goauthentik/elements/chips/ChipGroup";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/table/TablePagination"; import "@goauthentik/elements/table/TablePagination";
import "@goauthentik/elements/table/TableSearch"; import "@goauthentik/elements/table/TableSearch";
@ -118,7 +118,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
data?: PaginatedResponse<T>; data?: PaginatedResponse<T>;
@property({ type: Number }) @property({ type: Number })
page = getRouteParameter("tablePage", 1); page = getURLParam("tablePage", 1);
/** @prop /** @prop
* *
@ -200,7 +200,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
} }
}); });
if (this.searchEnabled()) { if (this.searchEnabled()) {
this.search = getRouteParameter("search", ""); this.search = getURLParam("search", "");
} }
} }
@ -441,7 +441,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
renderSearch(): TemplateResult { renderSearch(): TemplateResult {
const runSearch = (value: string) => { const runSearch = (value: string) => {
this.search = value; this.search = value;
patchRouteParams({ updateURLParams({
search: value, search: value,
}); });
this.fetch(); this.fetch();
@ -524,7 +524,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
/* A simple pagination display, shown at both the top and bottom of the page. */ /* A simple pagination display, shown at both the top and bottom of the page. */
renderTablePagination(): TemplateResult { renderTablePagination(): TemplateResult {
const handler = (page: number) => { const handler = (page: number) => {
patchRouteParams({ tablePage: page }); updateURLParams({ tablePage: page });
this.page = page; this.page = page;
this.fetch(); this.fetch();
}; };

View File

@ -1,5 +1,5 @@
import "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/PageHeader";
import { patchRouteParams } from "@goauthentik/elements/router/utils"; import { updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { Table } from "@goauthentik/elements/table/Table"; import { Table } from "@goauthentik/elements/table/Table";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@ -60,7 +60,7 @@ export abstract class TablePage<T> extends Table<T> {
this.search = ""; this.search = "";
this.requestUpdate(); this.requestUpdate();
this.fetch(); this.fetch();
patchRouteParams({ updateURLParams({
search: "", search: "",
}); });
}} }}

View File

@ -1,10 +1,8 @@
import { applyNextParam } from "@goauthentik/admin/flows/utils"; import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { MessageLevel } from "@goauthentik/common/messages"; import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/Spinner"; import "@goauthentik/elements/Spinner";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { formatInterfaceRoute } from "@goauthentik/elements/router/utils";
import { BaseUserSettings } from "@goauthentik/elements/user/sources/BaseUserSettings"; import { BaseUserSettings } from "@goauthentik/elements/user/sources/BaseUserSettings";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
@ -59,13 +57,12 @@ export class SourceSettingsOAuth extends BaseUserSettings {
</button>`; </button>`;
} }
if (this.configureUrl) { if (this.configureUrl) {
const target = new URL(this.configureUrl); return html`<a
class="pf-c-button pf-m-primary"
const destination = formatInterfaceRoute("user", "settings", { page: "sources" }); href="${this.configureUrl}${AndNext(
`/if/user/#/settings;${JSON.stringify({ page: "page-sources" })}`,
applyNextParam(target, destination); )}"
>
return html`<a class="pf-c-button pf-m-primary" href="${target}">
${msg("Connect")} ${msg("Connect")}
</a>`; </a>`;
} }

View File

@ -1,10 +1,8 @@
import { applyNextParam } from "@goauthentik/admin/flows/utils"; import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { MessageLevel } from "@goauthentik/common/messages"; import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/Spinner"; import "@goauthentik/elements/Spinner";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { formatInterfaceRoute } from "@goauthentik/elements/router/utils";
import { BaseUserSettings } from "@goauthentik/elements/user/sources/BaseUserSettings"; import { BaseUserSettings } from "@goauthentik/elements/user/sources/BaseUserSettings";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
@ -59,13 +57,12 @@ export class SourceSettingsSAML extends BaseUserSettings {
</button>`; </button>`;
} }
if (this.configureUrl) { if (this.configureUrl) {
const target = new URL(this.configureUrl); return html`<a
class="pf-c-button pf-m-primary"
const destination = formatInterfaceRoute("user", "settings", { page: "sources" }); href="${this.configureUrl}${AndNext(
`/if/user/#/settings;${JSON.stringify({ page: "page-sources" })}`,
applyNextParam(target, destination); )}"
>
return html`<a class="pf-c-button pf-m-primary" href="${target}">
${msg("Connect")} ${msg("Connect")}
</a>`; </a>`;
} }

View File

@ -15,7 +15,7 @@ import "@goauthentik/flow/stages/password/PasswordStage";
// end of stage import // end of stage import
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) { if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("src/development/build-observer"); const { ESBuildObserver } = await import("@goauthentik/common/client");
new ESBuildObserver(process.env.WATCHER_URL); new ESBuildObserver(process.env.WATCHER_URL);
} }

Some files were not shown because too many files have changed in this diff Show More