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]
current_version = 2025.2.3
current_version = 2025.2.2
tag = 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*))?
@ -17,8 +17,6 @@ optional_value = final
[bumpversion:file:pyproject.toml]
[bumpversion:file:uv.lock]
[bumpversion:file:package.json]
[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
# 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 TARGETARCH
@ -76,7 +76,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
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 \
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
# 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"
# 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
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" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2025.2.3"
__version__ = "2025.2.2"
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:
"""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:
key += f"/{page_number}"
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.filterset import FilterSet
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 authentik.core.api.object_types import TypesMixin
@ -18,10 +18,10 @@ from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer"""
assigned_application_slug = ReadOnlyField(source="application.slug")
assigned_application_name = ReadOnlyField(source="application.name")
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
assigned_application_slug = SerializerMethodField()
assigned_application_name = SerializerMethodField()
assigned_backchannel_application_slug = SerializerMethodField()
assigned_backchannel_application_name = SerializerMethodField()
component = SerializerMethodField()
@ -31,6 +31,38 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
return ""
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:
model = Provider
fields = [

View File

@ -1,14 +1,13 @@
"""User API Views"""
from datetime import timedelta
from importlib import import_module
from json import loads
from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash
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.transaction import atomic
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
LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
class UserGroupSerializer(ModelSerializer):
@ -375,7 +373,7 @@ class UsersFilter(FilterSet):
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")
path = CharFilter(field_name="path")
@ -393,11 +391,6 @@ class UsersFilter(FilterSet):
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):
"""Filter attributes by query args"""
try:
@ -776,8 +769,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if not instance.is_active:
sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True)
for session in session_ids:
SessionStore(session).delete()
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username)
return response

View File

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

View File

@ -1,10 +1,7 @@
"""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.sessions.backends.base import SessionBase
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models import Model
@ -28,7 +25,6 @@ password_changed = Signal()
login_failed = Signal()
LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
@receiver(post_save, sender=Application)
@ -64,7 +60,8 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
@receiver(pre_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""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)

View File

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

View File

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

View File

@ -3,7 +3,8 @@
from django.urls import reverse
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
@ -24,3 +25,51 @@ class TestProvidersAPI(APITestCase):
reverse("authentik_api:provider-types"),
)
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"""
from datetime import datetime
from json import loads
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
@ -16,12 +15,7 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.core.tests.utils import (
create_test_admin_user,
create_test_brand,
create_test_flow,
create_test_user,
)
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage
@ -32,7 +26,7 @@ class TestUsersAPI(APITestCase):
def setUp(self) -> None:
self.admin = create_test_admin_user()
self.user = create_test_user()
self.user = User.objects.create(username="test-user")
def test_filter_type(self):
"""Test API filtering by type"""
@ -47,35 +41,6 @@ class TestUsersAPI(APITestCase):
)
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):
"""Test listing with groups"""
self.client.force_login(self.admin)
@ -134,8 +99,6 @@ class TestUsersAPI(APITestCase):
def test_recovery_email_no_flow(self):
"""Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin)
self.user.email = ""
self.user.save()
response = self.client.post(
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_POST = "authentik/flows/post"
SESSION_KEY_HISTORY = "authentik/flows/history"
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
QS_KEY_TOKEN = "flow_token" # nosec
QS_QUERY = "query"
@ -454,7 +453,6 @@ class FlowExecutorView(APIView):
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_PLAN,
SESSION_KEY_GET,
SESSION_KEY_AUTH_STARTED,
# We might need the initial POST payloads for later requests
# SESSION_KEY_POST,
# 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 authentik.core.views.interface import InterfaceView
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
from authentik.flows.models import Flow
class FlowInterfaceView(InterfaceView):
"""Flow interface"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
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["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs)

View File

@ -18,15 +18,6 @@ class SerializerModel(models.Model):
@property
def serializer(self) -> type[BaseSerializer]:
"""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

View File

@ -35,4 +35,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies"
verbose_name = "authentik Policies"
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"""
from django.urls import path
from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.views import BufferView
urlpatterns = [
path("buffer", BufferView.as_view(), name="buffer"),
]
api_urlpatterns = [
("policies/all", PolicyViewSet),

View File

@ -1,37 +1,23 @@
"""authentik access helper classes"""
from typing import Any
from uuid import uuid4
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse, QueryDict
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.http import HttpRequest, HttpResponse
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 authentik.core.models import Application, Provider, User
from authentik.flows.models import Flow, FlowDesignation
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.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyRequest, PolicyResult
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):
@ -139,65 +125,3 @@ class PolicyAccessView(AccessMixin, View):
for message in result.messages:
messages.error(self.request, _(message))
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.views import bad_request_message
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 (
PKCE_METHOD_PLAIN,
PKCE_METHOD_S256,
@ -328,7 +328,7 @@ class OAuthAuthorizationParams:
return code
class AuthorizationFlowInitView(BufferedPolicyAccessView):
class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams

View File

@ -74,6 +74,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"verbose_name": "RAC Provider",
"verbose_name_plural": "RAC Providers",
"meta_model_name": "authentik_providers_rac.racprovider",
@ -124,6 +126,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"connection_expiry": "hours=8",
"delete_token_on_disconnect": False,
"verbose_name": "RAC Provider",
@ -153,6 +157,8 @@ class TestEndpointsAPI(APITestCase):
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
"assigned_application_name": self.app.name,
"assigned_backchannel_application_slug": "",
"assigned_backchannel_application_name": "",
"connection_expiry": "hours=8",
"delete_token_on_disconnect": False,
"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.lib.utils.time import timedelta_from_string
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
class RACStartView(BufferedPolicyAccessView):
class RACStartView(PolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token"""
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.models import PropertyMapping, Provider
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.processors.constants import (
DSA_SHA1,
@ -41,9 +40,7 @@ class SAMLBindings(models.TextChoices):
class SAMLProvider(Provider):
"""SAML 2.0 Endpoint for applications which support SAML."""
acs_url = models.TextField(
validators=[DomainlessURLValidator(schemes=("http", "https"))], verbose_name=_("ACS URL")
)
acs_url = models.URLField(verbose_name=_("ACS URL"))
audience = models.TextField(
default="",
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.views.executor import SESSION_KEY_POST
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.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
LOGGER = get_logger()
class SAMLSSOView(BufferedPolicyAccessView):
class SAMLSSOView(PolicyAccessView):
"""SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler."""
@ -83,7 +83,7 @@ class SAMLSSOView(BufferedPolicyAccessView):
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""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)

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

View File

@ -33,7 +33,6 @@ from authentik.flows.planner import (
)
from authentik.flows.stage import ChallengeStageView
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.providers.saml.utils.encoding import nice64
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(
NEXT_ARG_NAME, "authentik_core:if-user"
)
if not is_url_absolute(final_redirect):
final_redirect = "authentik_core:if-user"
kwargs.update(
{
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.")
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
if captcha_stage := current_stage.captcha_stage:
captcha_token = attrs.get("captcha_token", None)
if not captcha_token:
self.stage.logger.warning("Token not set for captcha attempt")
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

View File

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

View File

@ -31,7 +31,7 @@ services:
volumes:
- redis:/data
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
command: server
environment:
@ -54,7 +54,7 @@ services:
redis:
condition: service_healthy
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
command: worker
environment:

7
go.mod
View File

@ -1,6 +1,9 @@
module goauthentik.io
go 1.24.0
go 1.23.0
toolchain go1.24.0
require (
beryju.io/ldap v0.1.0
github.com/coreos/go-oidc/v3 v3.13.0
@ -26,7 +29,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025023.2
goauthentik.io/api/v3 v3.2025022.6
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.28.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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025023.2 h1:4XHlnykN5jQH78liQ4cp2Jf8eigvQImIJp+A+bsq1nA=
goauthentik.io/api/v3 v3.2025023.2/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2025022.6 h1:M5M8Cd/1N7E8KLkvYYh7VdcdKz5nfzjKPFLK+YOtOVg=
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-20190510104115-cbcb75029529/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())
}
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 (
"context"
"crypto/fips140"
"fmt"
"math/rand"
"net/http"
@ -204,7 +203,7 @@ func (a *APIController) getWebsocketPingArgs() map[string]interface{} {
"golangVersion": runtime.Version(),
"opensslEnabled": cryptobackend.OpensslEnabled,
"opensslVersion": cryptobackend.OpensslVersion(),
"fipsEnabled": fips140.Enabled(),
"fipsEnabled": cryptobackend.FipsEnabled,
}
hostname, err := os.Hostname()
if err == nil {

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# 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 TARGETARCH
@ -27,7 +27,7 @@ COPY . .
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 \
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
# Stage 2: Run

View File

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

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -1220,20 +1220,6 @@ msgstr ""
msgid "Reputation Scores"
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
msgid "Permission denied"
msgstr ""

View File

@ -10,8 +10,8 @@
# Manuel Viens, 2023
# Mordecai, 2023
# nerdinator <florian.dupret@gmail.com>, 2024
# Tina, 2024
# Charles Leclerc, 2025
# Tina, 2025
# Marc Schmitt, 2025
#
#, fuzzy
@ -19,7 +19,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: Marc Schmitt, 2025\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"
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
msgid "Permission denied"
msgstr "Permission refusée"

View File

@ -14,7 +14,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: deluxghost, 2025\n"
"Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n"
@ -1234,20 +1234,6 @@ msgstr "信誉分数"
msgid "Reputation Scores"
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
msgid "Permission denied"
msgstr "权限被拒绝"

View File

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

View File

@ -17,7 +17,7 @@ COPY web .
RUN npm run build-proxy
# 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 TARGETARCH
@ -43,7 +43,7 @@ COPY . .
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 \
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
# Stage 3: Run

View File

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

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# 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 TARGETARCH
@ -27,7 +27,7 @@ COPY . .
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 \
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
# Stage 2: Run

View File

@ -1,7 +1,7 @@
# syntax=docker/dockerfile:1
# 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 TARGETARCH
@ -27,7 +27,7 @@ COPY . .
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 \
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
# Stage 2: Run

View File

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

View File

@ -410,77 +410,3 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"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):
"""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"""
metadata_url = (
self.url(
@ -40,7 +40,6 @@ class TestProviderSAML(SeleniumTestCase):
"SP_ENTITY_ID": provider.issuer,
"SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"SP_METADATA_URL": metadata_url,
**kwargs,
},
)
@ -112,74 +111,6 @@ class TestProviderSAML(SeleniumTestCase):
[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()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
@ -519,81 +450,3 @@ class TestProviderSAML(SeleniumTestCase):
lambda driver: driver.current_url.startswith(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]]
name = "authentik"
version = "2025.2.3"
version = "2025.2.2"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },

114
web/package-lock.json generated
View File

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

View File

@ -12,7 +12,7 @@
"@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7",
"@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/context": "^1.1.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/notifications/APIDrawer";
import "@goauthentik/elements/notifications/NotificationDrawer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/RouterOutlet";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem";
@ -37,10 +37,10 @@ import "./AdminSidebar";
@customElement("ak-interface-admin")
export class AdminInterface extends AuthenticatedInterface {
@property({ type: Boolean })
notificationDrawerOpen = getRouteParameter("notificationDrawerOpen", false);
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@property({ type: Boolean })
apiDrawerOpen = getRouteParameter("apiDrawerOpen", false);
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
ws: WebsocketClient;
@ -93,14 +93,14 @@ export class AdminInterface extends AuthenticatedInterface {
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
patchRouteParams({
updateURLParams({
notificationDrawerOpen: this.notificationDrawerOpen,
});
});
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
this.apiDrawerOpen = !this.apiDrawerOpen;
patchRouteParams({
updateURLParams({
apiDrawerOpen: this.apiDrawerOpen,
});
});
@ -123,7 +123,7 @@ export class AdminInterface extends AuthenticatedInterface {
super.connectedCallback();
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);
}
@ -158,7 +158,7 @@ export class AdminInterface extends AuthenticatedInterface {
class="pf-c-page__main"
tabindex="-1"
id="main-content"
defaultURL="/administration/overview"
defaultUrl="/administration/overview"
.routes=${ROUTES}
>
</ak-router-outlet>

View File

@ -6,7 +6,7 @@ import {
WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider";
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 { spread } from "@open-wc/lit-helpers";
@ -95,127 +95,62 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
}
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 = [
/**
* The pathname to match against. If null, this is a parent item.
*/
pathname: string | null,
/**
* The label to display in the sidebar.
*/
path: string | null,
label: string,
/**
* 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.
*/
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
children?: SidebarEntry[],
];
// prettier-ignore
const sidebarContent: SidebarEntry[] = [
// ---
[
null,
msg("Dashboards"),
{ "?expanded": true },
[
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")],
],
],
[
null,
msg("Applications"),
null,
[
[
"/core/applications",
msg("Applications"),
[`/core/applications/:slug(${SLUG_PATTERN})`],
],
["/core/providers", msg("Providers"), [`/core/providers/:id(${ID_PATTERN})`]],
["/outpost/outposts", msg("Outposts")],
],
],
[
null,
msg("Events"),
null,
[
["/events/log", msg("Logs"), [`/events/log/:id(${UUID_PATTERN})`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")],
],
],
[
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")],
],
],
[null, msg("Dashboards"), { "?expanded": true }, [
["/administration/overview", msg("Overview")],
["/administration/dashboard/users", msg("User Statistics")],
["/administration/system-tasks", msg("System Tasks")]]],
[null, msg("Applications"), null, [
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
["/outpost/outposts", msg("Outposts")]]],
[null, msg("Events"), null, [
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
["/events/rules", msg("Notification Rules")],
["/events/transports", msg("Notification Transports")]]],
[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_REGEX})$`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")]]],
[null, msg("Directory"), null, [
["/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})$`]],
["/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
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
const renderOneSidebarItem: SidebarRenderer = ([pathname, label, attributes, children]) => {
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
const properties = Array.isArray(attributes)
? { ".activeWhen": attributes }
: (attributes ?? {});
if (pathname) {
properties.pathname = pathname;
if (path) {
properties.path = path;
}
return html`<ak-sidebar-item ${spread(properties)}>
${label ? html`<span slot="label">${label}</span>` : nothing}
${map(children, renderOneSidebarItem)}

View File

@ -1,210 +1,155 @@
import "@goauthentik/admin/admin-overview/AdminOverviewPage";
import { Route } from "@goauthentik/elements/router/Route";
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router/constants";
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { html } from "lit";
interface IDParameters {
id: string;
}
interface SlugParameters {
slug: string;
}
interface UUIDParameters {
uuid: string;
}
export const ROUTES = [
export const ROUTES: Route[] = [
// Prevent infinite Shell loops
Route.redirect("^/$", "/administration/overview"),
Route.redirect("^#.*", "/administration/overview"),
Route.redirect("^/library$", "/if/user/", true),
new Route(new RegExp("^/$")).redirect("/administration/overview"),
new Route(new RegExp("^#.*")).redirect("/administration/overview"),
new Route(new RegExp("^/library$")).redirect("/if/user/", true),
// 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>`;
}),
new Route("/administration/dashboard/users", async () => {
new Route(new RegExp("^/administration/dashboard/users$"), async () => {
await import("@goauthentik/admin/admin-overview/DashboardUserPage");
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");
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");
return html`<ak-provider-list></ak-provider-list>`;
}),
new Route<IDParameters>(
new URLPattern({
pathname: `/core/providers/:id(${ID_PATTERN})`,
}),
async (params) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view
.providerID=${parseInt(params.id, 10)}
></ak-provider-view>`;
},
),
new Route("/core/applications", async () => {
new Route(new RegExp(`^/core/providers/(?<id>${ID_REGEX})$`), async (args) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view .providerID=${parseInt(args.id, 10)}></ak-provider-view>`;
}),
new Route(new RegExp("^/core/applications$"), async () => {
await import("@goauthentik/admin/applications/ApplicationListPage");
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");
return html`<ak-application-view .applicationSlug=${slug}></ak-application-view>`;
return html`<ak-application-view .applicationSlug=${args.slug}></ak-application-view>`;
}),
new Route("/core/sources", async () => {
new Route(new RegExp("^/core/sources$"), async () => {
await import("@goauthentik/admin/sources/SourceListPage");
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");
return html`<ak-source-view .sourceSlug=${slug}></ak-source-view>`;
return html`<ak-source-view .sourceSlug=${args.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");
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");
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");
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");
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");
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");
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");
return html`<ak-group-view .groupId=${uuid}></ak-group-view>`;
return html`<ak-group-view .groupId=${args.uuid}></ak-group-view>`;
}),
new Route("/identity/users", async () => {
new Route(new RegExp("^/identity/users$"), async () => {
await import("@goauthentik/admin/users/UserListPage");
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");
return html`<ak-user-view .userId=${parseInt(id, 10)}></ak-user-view>`;
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
}),
new Route("/identity/roles", async () => {
new Route(new RegExp("^/identity/roles$"), async () => {
await import("@goauthentik/admin/roles/RoleListPage");
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");
return html`<ak-role-view roleId=${id}></ak-role-view>`;
return html`<ak-role-view roleId=${args.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");
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");
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");
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");
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");
return html`<ak-flow-view .flowSlug=${slug}></ak-flow-view>`;
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
}),
new Route("/events/log", async () => {
new Route(new RegExp("^/events/log$"), async () => {
await import("@goauthentik/admin/events/EventListPage");
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");
return html`<ak-event-view .eventID=${id}></ak-event-view>`;
return html`<ak-event-view .eventID=${args.id}></ak-event-view>`;
}),
new Route("/events/transports", async () => {
new Route(new RegExp("^/events/transports$"), async () => {
await import("@goauthentik/admin/events/TransportListPage");
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");
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");
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");
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");
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");
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");
return html`<ak-blueprint-list></ak-blueprint-list>`;
}),
new Route("/debug", async () => {
new Route(new RegExp("^/debug$"), async () => {
await import("@goauthentik/admin/DebugPage");
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");
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/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 { CSSResult, TemplateResult, css, html, nothing } from "lit";
@ -79,13 +79,10 @@ export class AdminOverviewPage extends AdminOverviewBase {
}
quickActions: QuickAction[] = [
[
msg("Create a new application"),
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[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],
];
@ -198,13 +195,10 @@ export class AdminOverviewPage extends AdminOverviewBase {
const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`;
const quickActions: [string, string][] = [
[
msg("Create a new application"),
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Check the logs"), paramURL("/events/log")],
[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}`],
];

View File

@ -7,7 +7,7 @@ import "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
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 { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -156,7 +156,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
}
renderObjectCreate(): TemplateResult {
return html` <ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}>
<button
slot="trigger"
class="pf-c-button pf-m-primary"
@ -165,7 +165,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
${msg("Create with Provider")}
</button>
</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="header"> ${msg("Create Application")} </span>
<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 "@goauthentik/elements/Label";
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 { css, html } from "lit";
@ -110,7 +110,7 @@ export class AkApplicationWizardHint extends AKElement implements ShowHintContro
the same time with our new Application Wizard.
<!-- <a href="(link to docs)">Learn more about the wizard here.</a> -->
</p>
<ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
<ak-application-wizard .open=${getURLParam("createWizard", false)}>
<button
slot="trigger"
class="pf-c-button pf-m-primary"

View File

@ -1,6 +1,7 @@
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.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 "@goauthentik/components/ak-radio-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 "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { isSlug } from "@goauthentik/elements/router";
import { msg } from "@lit/localize";
import { html } from "lit";

View File

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

View File

@ -1,10 +1,10 @@
import "@goauthentik/admin/flows/BoundStagesList";
import "@goauthentik/admin/flows/FlowDiagram";
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/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 { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader";
@ -151,9 +151,12 @@ export class FlowViewPage extends AKElement {
<button
class="pf-c-button pf-m-block pf-m-primary"
@click=${() => {
const url = formatFlowURL(this.flow);
window.open(url, "_blank");
const finalURL = `${
window.location.origin
}/if/flow/${this.flow.slug}/${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
}}
>
${msg("Normal")}
@ -165,16 +168,12 @@ export class FlowViewPage extends AKElement {
.flowsInstancesExecuteRetrieve({
slug: this.flow.slug,
})
.then(({ link }) => {
const finalURL = URL.canParse(link)
? new URL(link)
: new URL(
link,
window.location.origin,
);
applyNextParam(finalURL);
.then((link) => {
const finalURL = `${
link.link
}${AndNext(
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
});
}}

View File

@ -43,51 +43,3 @@ export function LayoutToLabel(layout: FlowLayoutEnum): string {
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/ModalForm";
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 { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { UserOption } from "@goauthentik/elements/user/utils";
@ -127,7 +127,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
order = "last_login";
@property({ type: Boolean })
hideServiceAccounts = getRouteParameter<boolean>("hideServiceAccounts", true);
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true);
@state()
me?: SessionUser;
@ -466,7 +466,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
this.hideServiceAccounts = !this.hideServiceAccounts;
this.page = 1;
this.fetch();
patchRouteParams({
updateURLParams({
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/ModalForm";
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 { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -54,7 +54,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
order = "name";
@state()
hideManaged = getRouteParameter<boolean>("hideManaged", true);
hideManaged = getURLParam<boolean>("hideManaged", true);
async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({
@ -148,7 +148,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
this.hideManaged = !this.hideManaged;
this.page = 1;
this.fetch();
patchRouteParams({
updateURLParams({
hideManaged: this.hideManaged,
});
}}

View File

@ -1,4 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api";
@ -21,7 +22,11 @@ export async function propertyMappingsProvider(page = 1, search = "") {
export function propertyMappingsSelector(instanceMappings?: string[]) {
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 () => {

View File

@ -3,6 +3,7 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { convertToSlug } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog";
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 "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton";
import { formatAsSlug } from "@goauthentik/elements/router";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
@ -156,7 +156,7 @@ export class ProxyProviderViewPage extends AKElement {
(input: string): string => {
// The generated config is pretty unreliable currently so
// put it behind a flag
if (!getRouteParameter("generatedConfig", false)) {
if (!getURLParam("generatedConfig", false)) {
return input;
}
if (!this.provider) {
@ -183,7 +183,7 @@ export class ProxyProviderViewPage extends AKElement {
return html`<ak-tabs pageIdentifier="proxy-setup">
${servers.map((server) => {
return html`<section
slot="page-${formatAsSlug(server.label)}"
slot="page-${convertToSlug(server.label)}"
data-tab-title="${server.label}"
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 { userTypeToLabel } from "@goauthentik/common/labels";
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 { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label";
@ -24,7 +24,7 @@ import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
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 { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -117,7 +117,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
activePath;
@state()
hideDeactivated = getRouteParameter<boolean>("hideDeactivated", false);
hideDeactivated = getURLParam<boolean>("hideDeactivated", false);
@state()
userPaths?: UserPath;
@ -131,10 +131,8 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
constructor() {
super();
const defaultPath = createUIConfig().defaults.userPath;
this.activePath = getRouteParameter("path", defaultPath);
const defaultPath = new DefaultUIConfig().defaults.userPath;
this.activePath = getURLParam<string>("path", defaultPath);
uiConfig().then((c) => {
if (c.defaults.userPath !== defaultPath) {
this.activePath = c.defaults.userPath;
@ -145,7 +143,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
async apiEndpoint(): Promise<PaginatedResponse<User>> {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
...(await this.defaultEndpointConfig()),
pathStartswith: getRouteParameter("path", ""),
pathStartswith: getURLParam("path", ""),
isActive: this.hideDeactivated ? true : undefined,
includeGroups: false,
});
@ -227,7 +225,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
this.hideDeactivated = !this.hideDeactivated;
this.page = 1;
this.fetch();
patchRouteParams({
updateURLParams({
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}`);

View File

@ -1,5 +1,5 @@
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
import { getCookie } from "@goauthentik/common/http";
import { getCookie } from "@goauthentik/common/utils";
import {
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 { isAdminRoute } from "@goauthentik/elements/router";
import type { Message as ESBuildMessage } from "esbuild";
import { msg } from "@lit/localize";
const logPrefix = "👷 [ESBuild]";
const log = console.debug.bind(console, logPrefix);
import type { CurrentBrand } from "@goauthentik/api";
type BrandTitleLike = Partial<Pick<CurrentBrand, "brandingTitle">>;
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
/**
* Create a title for the page.
* A client-side watcher for ESBuild.
*
* @param brand - The brand object to append to the title.
* @param segments - The segments to prepend to the title.
*/
export function formatPageTitle(
brand: BrandTitleLike | undefined,
...segments: Array<string | undefined>
): string;
/**
* Create a title for the page.
* Note that this should be conditionally imported in your code, so that
* ESBuild may tree-shake it out of production builds.
*
* @param segments - The segments to prepend to the title.
*/
export function formatPageTitle(...segments: Array<string | undefined>): string;
/**
* Create a title for the page.
* ```ts
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
* const { ESBuildObserver } = await import("@goauthentik/common/client");
*
* @param args - The segments to prepend to the title.
* @param args - The brand object to append to the title.
*/
export function formatPageTitle(
...args: [BrandTitleLike | string | undefined, ...Array<string | undefined>]
): string {
const segments: string[] = [];
if (isAdminRoute()) {
segments.push(msg("Admin"));
}
const [arg1, ...rest] = args;
if (typeof arg1 === "object") {
const { brandingTitle = TITLE_DEFAULT } = arg1;
segments.push(brandingTitle);
} else {
segments.push(TITLE_DEFAULT);
}
for (const segment of rest) {
if (segment) {
segments.push(segment);
}
}
return segments.join(" - ");
* 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

@ -3,8 +3,9 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2025.2.3";
export const VERSION = "2025.2.2";
export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";";
export const EVENT_REFRESH = "ak-refresh";
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 { SentryIgnoredError } from "@goauthentik/common/errors";
import { me } from "@goauthentik/common/users";
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
import {
ErrorEvent,
EventHint,
@ -65,7 +64,7 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
});
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
if (window.location.pathname.includes("if/")) {
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`);
}
if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) {
const Spotlight = await import("@spotlightjs/spotlight");
@ -83,3 +82,13 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
}
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 { isUserRoute } from "@goauthentik/elements/router";
import { UiThemeEnum } from "@goauthentik/api";
import { UiThemeEnum, UserSelf } from "@goauthentik/api";
export enum UserDisplay {
username = "username",
@ -18,27 +18,15 @@ export enum LayoutType {
export interface UIConfig {
enabledFeatures: {
/**
* Whether to show the API request drawer in the navbar.
*/
// API Request drawer in navbar
apiDrawer: boolean;
/**
* Whether to show the notification drawer in the navbar.
*/
// Notification drawer in navbar
notificationDrawer: boolean;
/**
* Whether to show the settings in the user dropdown.
*/
// Settings in user dropdown
settings: boolean;
/**
* Whether to show the application edit button in the library.
*
* This is only shown when the user is a superuser.
*/
// Application edit in library (only shown when user is superuser)
applicationEdit: boolean;
/**
* Whether to show the search bar.
*/
// Search bar
search: boolean;
};
navbar: {
@ -50,77 +38,68 @@ export interface UIConfig {
cardBackground: string;
};
pagination: {
/**
* Number of items to show per page in paginated lists.
*/
perPage: number;
};
layout: {
/**
* Layout type to use for the application.
*/
type: LayoutType;
};
/**
* Locale to use for the application.
*/
locale: string;
/**
* Default values.
*/
defaults: {
/**
* Default path to use for user API calls.
*/
userPath: string;
};
}
export function createUIConfig(overrides: Partial<UIConfig> = {}): UIConfig {
const uiConfig: UIConfig = {
enabledFeatures: {
// TODO: Is the intent that only user routes should have the API drawer disabled,
// or only admin routes?
apiDrawer: !isUserRoute(),
notificationDrawer: true,
settings: true,
applicationEdit: true,
search: true,
},
layout: {
type: LayoutType.row,
},
navbar: {
userDisplay: UserDisplay.username,
},
theme: {
base: UiThemeEnum.Automatic,
background: "",
cardBackground: "",
},
pagination: {
perPage: 20,
},
locale: "",
defaults: {
userPath: "users",
},
export class DefaultUIConfig implements UIConfig {
enabledFeatures = {
apiDrawer: true,
notificationDrawer: true,
settings: true,
applicationEdit: true,
search: true,
};
layout = {
type: LayoutType.row,
};
navbar = {
userDisplay: UserDisplay.username,
};
theme = {
base: UiThemeEnum.Automatic,
background: "",
cardBackground: "",
};
pagination = {
perPage: 20,
};
locale = "";
defaults = {
userPath: "users",
};
// TODO: Should we deep merge the overrides instead of shallow?
Object.assign(uiConfig, overrides);
return uiConfig;
constructor() {
if (currentInterface() === "user") {
this.enabledFeatures.apiDrawer = false;
}
}
}
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> {
if (cachedUIConfig) return Promise.resolve(cachedUIConfig);
return me().then((session) => {
cachedUIConfig = createUIConfig(session.user.settings);
return cachedUIConfig;
});
if (!globalUiConfig) {
globalUiConfig = me().then((user) => {
return getConfigForUser(user.user);
});
}
return globalUiConfig;
}

View File

@ -2,6 +2,35 @@ import { SentryIgnoredError } from "@goauthentik/common/errors";
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
*/
@ -34,29 +63,17 @@ export function snakeToCamel(key: string) {
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
const m = new Map<string, T[]>();
objects.forEach((obj) => {
const group = callback(obj);
if (!m.has(group)) {
m.set(group, []);
}
const tProviders = m.get(group) || [];
tProviders.push(obj);
});
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 {
for (let index = 0; index < args.length; 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);
}
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 {
const elapsed = d1.getTime() - d2.getTime();
const rtf = new Intl.RelativeTimeFormat("default", { numeric: "auto" });
const elapsed = d1.getTime() - d2.getTime();
const _timeUnits: [Intl.RelativeTimeFormatUnit, number][] = [
["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],
];
// "Math.abs" accounts for both "past" & "future" scenarios
for (const [key, value] of _timeUnits) {
if (Math.abs(elapsed) > value || key === "second") {
let rounded = Math.round(elapsed / value);
if (!isFinite(rounded)) {
rounded = 0;
}
return rtf.format(rounded, key);
if (Math.abs(elapsed) > value || key == "second") {
return rtf.format(Math.round(elapsed / value), key);
}
}
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 { 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
// component, such as a custom forms manager, may receive it.
handleTouch(ev: Event) {
this.input.value = formatAsSlug(this.input.value);
this.input.value = convertToSlug(this.input.value);
this.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
// 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 [shorter, longer] =
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 { EVENT_SIDEBAR_TOGGLE, EVENT_WS_MESSAGE } from "@goauthentik/common/constants";
import {
EVENT_SIDEBAR_TOGGLE,
EVENT_WS_MESSAGE,
TITLE_DEFAULT,
} from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { currentInterface } from "@goauthentik/common/sentry";
import { UIConfig, UserDisplay, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users";
import "@goauthentik/components/ak-nav-buttons";
@ -121,8 +125,17 @@ export class PageHeader extends WithBrandConfig(AKElement) {
this.uiConfig.navbar.userDisplay = UserDisplay.none;
}
setTitle(pageTitle?: string) {
document.title = formatPageTitle(this.brand, pageTitle);
setTitle(header?: string) {
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() {

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

View File

@ -1,6 +1,6 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
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 { CSSResult, TemplateResult, html } from "lit";
@ -84,7 +84,7 @@ export class TreeViewNode extends AKElement {
if (this.host) {
this.host.activeNode = this;
}
setRouteParams({ path: this.fullPath });
setURLParams({ path: this.fullPath });
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,

View File

@ -1,13 +1,11 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIError } from "@goauthentik/common/errors";
import { MessageLevel } from "@goauthentik/common/messages";
import { dateToUTC } from "@goauthentik/common/utils";
import { camelToSnake } from "@goauthentik/common/utils";
import { camelToSnake, convertToSlug, dateToUTC } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { formatAsSlug } from "@goauthentik/elements/router/slugs";
import { msg } from "@lit/localize";
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
// if not, they are probably completely different and shouldn't update
// each other
if (formatAsSlug(input.value) !== slugField.value) {
if (convertToSlug(input.value) !== slugField.value) {
return;
}
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 { FormGroup } from "@goauthentik/elements/forms/FormGroup";
import { formatAsSlug } from "@goauthentik/elements/router";
import { msg, str } from "@lit/localize";
import { CSSResult, css } from "lit";
@ -123,7 +123,7 @@ export class HorizontalFormElement extends AKElement {
if (this.name === "slug" || this.slugMode) {
this.querySelectorAll<HTMLInputElement>("input[type='text']").forEach((input) => {
input.addEventListener("keyup", () => {
input.value = formatAsSlug(input.value);
input.value = convertToSlug(input.value);
});
});
}

View File

@ -1,60 +1,66 @@
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";
export type PrimitiveRouteParameter = string | number | boolean | null | undefined;
export type RouteParameterRecord = { [key: string]: PrimitiveRouteParameter };
export const SLUG_REGEX = "[-a-zA-Z0-9_]+";
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> = (
params: P,
) => SlottedTemplateResult | Promise<SlottedTemplateResult>;
export interface RouteArgs {
[key: string]: string;
}
export type RouteInitTuple = [string | RegExp, RouteCallback | undefined];
export class Route {
url: RegExp;
export class Route<P = unknown> {
public readonly pattern: URLPattern;
private element?: TemplateResult;
private callback?: (args: RouteArgs) => Promise<TemplateResult>;
#callback: RouteCallback<P>;
constructor(patternInit: URLPatternInit | string, callback: RouteCallback<P>) {
this.pattern = new URLPattern(
typeof patternInit === "string"
? {
pathname: patternInit,
}
: patternInit,
);
this.#callback = callback;
constructor(url: RegExp, callback?: (args: RouteArgs) => Promise<TemplateResult>) {
this.url = url;
this.callback = callback;
}
/**
* Create a new redirect route.
*
* @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, () => {
redirect(to: string, raw = false): Route {
this.callback = async () => {
console.debug(`authentik/router: redirecting ${to}`);
if (!raw) {
window.location.hash = `#${to}`;
} else {
window.location.hash = to;
}
return nothing;
});
return html``;
};
return this;
}
render(params: P): TemplateResult {
return html`${until(
this.#callback(params),
html`<ak-empty-state ?loading=${true}></ak-empty-state>`,
)}`;
then(render: (args: RouteArgs) => TemplateResult): Route {
this.callback = async (args) => {
return render(args);
};
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")
export class Router404 extends AKElement {
@property()
pathname = "";
url = "";
static get styles(): CSSResult[] {
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>
<h1 class="pf-c-title pf-m-lg">${msg("Not found")}</h1>
<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>
<a href="#/" class="pf-c-button pf-m-primary" type="button"
>${msg("Return home")}</a

View File

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

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

View File

@ -7,7 +7,7 @@ import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/chips/Chip";
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/TableSearch";
@ -118,7 +118,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
data?: PaginatedResponse<T>;
@property({ type: Number })
page = getRouteParameter("tablePage", 1);
page = getURLParam("tablePage", 1);
/** @prop
*
@ -200,7 +200,7 @@ export abstract class Table<T> extends AKElement implements TableLike {
}
});
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 {
const runSearch = (value: string) => {
this.search = value;
patchRouteParams({
updateURLParams({
search: value,
});
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. */
renderTablePagination(): TemplateResult {
const handler = (page: number) => {
patchRouteParams({ tablePage: page });
updateURLParams({ tablePage: page });
this.page = page;
this.fetch();
};

View File

@ -1,5 +1,5 @@
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 { msg } from "@lit/localize";
@ -60,7 +60,7 @@ export abstract class TablePage<T> extends Table<T> {
this.search = "";
this.requestUpdate();
this.fetch();
patchRouteParams({
updateURLParams({
search: "",
});
}}

View File

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

View File

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

View File

@ -15,7 +15,7 @@ import "@goauthentik/flow/stages/password/PasswordStage";
// end of stage import
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);
}

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