Compare commits
	
		
			60 Commits
		
	
	
		
			policies-e
			...
			safari-cra
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 2e473c16fa | |||
| cf160f800d | |||
| e9822cd937 | |||
| 5244f64be4 | |||
| 0df4824fd4 | |||
| ea22abc75d | |||
| b09bab7543 | |||
| 5aedc8a5f2 | |||
| 2f3ae0f607 | |||
| e3674426b7 | |||
| df915d3a5e | |||
| 4949c31860 | |||
| 4580dec06b | |||
| 56de969640 | |||
| 413902508d | |||
| 64af0ccba6 | |||
| 673db53777 | |||
| 8df7716d90 | |||
| 19bb2de13f | |||
| a218fd7628 | |||
| 78cfb50a90 | |||
| 2033d52dc2 | |||
| be00f47ddc | |||
| 2cc5f4b273 | |||
| 4e8f3407a4 | |||
| 7f861cc2a1 | |||
| 7bf58d0ba2 | |||
| fffcb00f39 | |||
| 77ee868573 | |||
| 6aaec08496 | |||
| cc15584650 | |||
| e55e446b89 | |||
| 76088e48b5 | |||
| 4165a0a6b2 | |||
| 647fefe5ce | |||
| 723dccdae3 | |||
| c82f747e5e | |||
| 43406e2464 | |||
| a0ff0bef85 | |||
| bedf548a5f | |||
| 976e81c1dd | |||
| ad733033d7 | |||
| ba686f6a93 | |||
| dc50be1e13 | |||
| 205686d252 | |||
| 6d589013e6 | |||
| 2d6433ca9a | |||
| b5f07acb26 | |||
| ea8702077c | |||
| 6593357115 | |||
| 6daed865c1 | |||
| c48a21707a | |||
| e857770c0a | |||
| add74c8799 | |||
| 12d854035d | |||
| 57dd4ae91d | |||
| 37fbc98177 | |||
| 14f216eb40 | |||
| 1209dd022e | |||
| c96f13ac66 | 
@ -94,7 +94,7 @@ 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.14 AS uv
 | 
			
		||||
FROM ghcr.io/astral-sh/uv:0.6.16 AS uv
 | 
			
		||||
# Stage 6: Base python image
 | 
			
		||||
FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,10 @@ from authentik.core.models import (
 | 
			
		||||
    TokenIntents,
 | 
			
		||||
    User,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.tasks import clean_expired_models, clean_temporary_users
 | 
			
		||||
from authentik.core.tasks import (
 | 
			
		||||
    clean_expired_models,
 | 
			
		||||
    clean_temporary_users,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								authentik/enterprise/policies/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/enterprise/policies/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										27
									
								
								authentik/enterprise/policies/unique_password/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								authentik/enterprise/policies/unique_password/api.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,27 @@
 | 
			
		||||
from rest_framework.viewsets import ModelViewSet
 | 
			
		||||
 | 
			
		||||
from authentik.core.api.used_by import UsedByMixin
 | 
			
		||||
from authentik.enterprise.api import EnterpriseRequiredMixin
 | 
			
		||||
from authentik.enterprise.policies.unique_password.models import UniquePasswordPolicy
 | 
			
		||||
from authentik.policies.api.policies import PolicySerializer
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UniquePasswordPolicySerializer(EnterpriseRequiredMixin, PolicySerializer):
 | 
			
		||||
    """Password Uniqueness Policy Serializer"""
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = UniquePasswordPolicy
 | 
			
		||||
        fields = PolicySerializer.Meta.fields + [
 | 
			
		||||
            "password_field",
 | 
			
		||||
            "num_historical_passwords",
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UniquePasswordPolicyViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
    """Password Uniqueness Policy Viewset"""
 | 
			
		||||
 | 
			
		||||
    queryset = UniquePasswordPolicy.objects.all()
 | 
			
		||||
    serializer_class = UniquePasswordPolicySerializer
 | 
			
		||||
    filterset_fields = "__all__"
 | 
			
		||||
    ordering = ["name"]
 | 
			
		||||
    search_fields = ["name"]
 | 
			
		||||
							
								
								
									
										10
									
								
								authentik/enterprise/policies/unique_password/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								authentik/enterprise/policies/unique_password/apps.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
"""authentik Unique Password policy app config"""
 | 
			
		||||
 | 
			
		||||
from authentik.enterprise.apps import EnterpriseConfig
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthentikEnterprisePoliciesUniquePasswordConfig(EnterpriseConfig):
 | 
			
		||||
    name = "authentik.enterprise.policies.unique_password"
 | 
			
		||||
    label = "authentik_policies_unique_password"
 | 
			
		||||
    verbose_name = "authentik Enterprise.Policies.Unique Password"
 | 
			
		||||
    default = True
 | 
			
		||||
@ -0,0 +1,81 @@
 | 
			
		||||
# Generated by Django 5.0.13 on 2025-03-26 23:02
 | 
			
		||||
 | 
			
		||||
import django.db.models.deletion
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    initial = True
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies", "0011_policybinding_failure_result_and_more"),
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="UniquePasswordPolicy",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "policy_ptr",
 | 
			
		||||
                    models.OneToOneField(
 | 
			
		||||
                        auto_created=True,
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        parent_link=True,
 | 
			
		||||
                        primary_key=True,
 | 
			
		||||
                        serialize=False,
 | 
			
		||||
                        to="authentik_policies.policy",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "password_field",
 | 
			
		||||
                    models.TextField(
 | 
			
		||||
                        default="password",
 | 
			
		||||
                        help_text="Field key to check, field keys defined in Prompt stages are available.",
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                (
 | 
			
		||||
                    "num_historical_passwords",
 | 
			
		||||
                    models.PositiveIntegerField(
 | 
			
		||||
                        default=1, help_text="Number of passwords to check against."
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "Password Uniqueness Policy",
 | 
			
		||||
                "verbose_name_plural": "Password Uniqueness Policies",
 | 
			
		||||
                "indexes": [
 | 
			
		||||
                    models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__f559aa_idx")
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
            bases=("authentik_policies.policy",),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.CreateModel(
 | 
			
		||||
            name="UserPasswordHistory",
 | 
			
		||||
            fields=[
 | 
			
		||||
                (
 | 
			
		||||
                    "id",
 | 
			
		||||
                    models.AutoField(
 | 
			
		||||
                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
                ("old_password", models.CharField(max_length=128)),
 | 
			
		||||
                ("created_at", models.DateTimeField(auto_now_add=True)),
 | 
			
		||||
                ("hibp_prefix_sha1", models.CharField(max_length=5)),
 | 
			
		||||
                ("hibp_pw_hash", models.TextField()),
 | 
			
		||||
                (
 | 
			
		||||
                    "user",
 | 
			
		||||
                    models.ForeignKey(
 | 
			
		||||
                        on_delete=django.db.models.deletion.CASCADE,
 | 
			
		||||
                        related_name="old_passwords",
 | 
			
		||||
                        to=settings.AUTH_USER_MODEL,
 | 
			
		||||
                    ),
 | 
			
		||||
                ),
 | 
			
		||||
            ],
 | 
			
		||||
            options={
 | 
			
		||||
                "verbose_name": "User Password History",
 | 
			
		||||
            },
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
							
								
								
									
										151
									
								
								authentik/enterprise/policies/unique_password/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								authentik/enterprise/policies/unique_password/models.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,151 @@
 | 
			
		||||
from hashlib import sha1
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.hashers import identify_hasher, make_password
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.utils.translation import gettext as _
 | 
			
		||||
from rest_framework.serializers import BaseSerializer
 | 
			
		||||
from structlog.stdlib import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.policies.models import Policy
 | 
			
		||||
from authentik.policies.types import PolicyRequest, PolicyResult
 | 
			
		||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UniquePasswordPolicy(Policy):
 | 
			
		||||
    """This policy prevents users from reusing old passwords."""
 | 
			
		||||
 | 
			
		||||
    password_field = models.TextField(
 | 
			
		||||
        default="password",
 | 
			
		||||
        help_text=_("Field key to check, field keys defined in Prompt stages are available."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Limit on the number of previous passwords the policy evaluates
 | 
			
		||||
    # Also controls number of old passwords the system stores.
 | 
			
		||||
    num_historical_passwords = models.PositiveIntegerField(
 | 
			
		||||
        default=1,
 | 
			
		||||
        help_text=_("Number of passwords to check against."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def serializer(self) -> type[BaseSerializer]:
 | 
			
		||||
        from authentik.enterprise.policies.unique_password.api import UniquePasswordPolicySerializer
 | 
			
		||||
 | 
			
		||||
        return UniquePasswordPolicySerializer
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def component(self) -> str:
 | 
			
		||||
        return "ak-policy-password-uniqueness-form"
 | 
			
		||||
 | 
			
		||||
    def passes(self, request: PolicyRequest) -> PolicyResult:
 | 
			
		||||
        from authentik.enterprise.policies.unique_password.models import UserPasswordHistory
 | 
			
		||||
 | 
			
		||||
        password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get(
 | 
			
		||||
            self.password_field, request.context.get(self.password_field)
 | 
			
		||||
        )
 | 
			
		||||
        if not password:
 | 
			
		||||
            LOGGER.warning(
 | 
			
		||||
                "Password field not found in request when checking UniquePasswordPolicy",
 | 
			
		||||
                field=self.password_field,
 | 
			
		||||
                fields=request.context.keys(),
 | 
			
		||||
            )
 | 
			
		||||
            return PolicyResult(False, _("Password not set in context"))
 | 
			
		||||
        password = str(password)
 | 
			
		||||
 | 
			
		||||
        if not self.num_historical_passwords:
 | 
			
		||||
            # Policy not configured to check against any passwords
 | 
			
		||||
            return PolicyResult(True)
 | 
			
		||||
 | 
			
		||||
        num_to_check = self.num_historical_passwords
 | 
			
		||||
        password_history = UserPasswordHistory.objects.filter(user=request.user).order_by(
 | 
			
		||||
            "-created_at"
 | 
			
		||||
        )[:num_to_check]
 | 
			
		||||
 | 
			
		||||
        if not password_history:
 | 
			
		||||
            return PolicyResult(True)
 | 
			
		||||
 | 
			
		||||
        for record in password_history:
 | 
			
		||||
            if not record.old_password:
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if self._passwords_match(new_password=password, old_password=record.old_password):
 | 
			
		||||
                # Return on first match. Authentik does not consider timing attacks
 | 
			
		||||
                # on old passwords to be an attack surface.
 | 
			
		||||
                return PolicyResult(
 | 
			
		||||
                    False,
 | 
			
		||||
                    _("This password has been used previously. Please choose a different one."),
 | 
			
		||||
                )
 | 
			
		||||
 | 
			
		||||
        return PolicyResult(True)
 | 
			
		||||
 | 
			
		||||
    def _passwords_match(self, *, new_password: str, old_password: str) -> bool:
 | 
			
		||||
        try:
 | 
			
		||||
            hasher = identify_hasher(old_password)
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            LOGGER.warning(
 | 
			
		||||
                "Skipping password; could not load hash algorithm",
 | 
			
		||||
            )
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
        return hasher.verify(new_password, old_password)
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def is_in_use(cls):
 | 
			
		||||
        """Check if any UniquePasswordPolicy is in use, either through policy bindings
 | 
			
		||||
        or direct attachment to a PromptStage.
 | 
			
		||||
 | 
			
		||||
        Returns:
 | 
			
		||||
            bool: True if any policy is in use, False otherwise
 | 
			
		||||
        """
 | 
			
		||||
        from authentik.policies.models import PolicyBinding
 | 
			
		||||
 | 
			
		||||
        # Check if any policy is in use through bindings
 | 
			
		||||
        if PolicyBinding.in_use.for_policy(cls).exists():
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        # Check if any policy is attached to a PromptStage
 | 
			
		||||
        if cls.objects.filter(promptstage__isnull=False).exists():
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    class Meta(Policy.PolicyMeta):
 | 
			
		||||
        verbose_name = _("Password Uniqueness Policy")
 | 
			
		||||
        verbose_name_plural = _("Password Uniqueness Policies")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserPasswordHistory(models.Model):
 | 
			
		||||
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="old_passwords")
 | 
			
		||||
    # Mimic's column type of AbstractBaseUser.password
 | 
			
		||||
    old_password = models.CharField(max_length=128)
 | 
			
		||||
    created_at = models.DateTimeField(auto_now_add=True)
 | 
			
		||||
 | 
			
		||||
    hibp_prefix_sha1 = models.CharField(max_length=5)
 | 
			
		||||
    hibp_pw_hash = models.TextField()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("User Password History")
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        timestamp = f"{self.created_at:%Y/%m/%d %X}" if self.created_at else "N/A"
 | 
			
		||||
        return f"Previous Password (user: {self.user_id}, recorded: {timestamp})"
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def create_for_user(cls, user: User, password: str):
 | 
			
		||||
        # To check users' passwords against Have I been Pwned, we need the first 5 chars
 | 
			
		||||
        # of the password hashed with SHA1 without a salt...
 | 
			
		||||
        pw_hash_sha1 = sha1(password.encode("utf-8")).hexdigest()  # nosec
 | 
			
		||||
        # ...however that'll give us a list of hashes from HIBP, and to compare that we still
 | 
			
		||||
        # need a full unsalted SHA1 of the password. We don't want to save that directly in
 | 
			
		||||
        # the database, so we hash that SHA1 again with a modern hashing alg,
 | 
			
		||||
        # and then when we check users' passwords against HIBP we can use `check_password`
 | 
			
		||||
        # which will take care of this.
 | 
			
		||||
        hibp_hash_hash = make_password(pw_hash_sha1)
 | 
			
		||||
        return cls.objects.create(
 | 
			
		||||
            user=user,
 | 
			
		||||
            old_password=password,
 | 
			
		||||
            hibp_prefix_sha1=pw_hash_sha1[:5],
 | 
			
		||||
            hibp_pw_hash=hibp_hash_hash,
 | 
			
		||||
        )
 | 
			
		||||
							
								
								
									
										20
									
								
								authentik/enterprise/policies/unique_password/settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								authentik/enterprise/policies/unique_password/settings.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,20 @@
 | 
			
		||||
"""Unique Password Policy settings"""
 | 
			
		||||
 | 
			
		||||
from celery.schedules import crontab
 | 
			
		||||
 | 
			
		||||
from authentik.lib.utils.time import fqdn_rand
 | 
			
		||||
 | 
			
		||||
CELERY_BEAT_SCHEDULE = {
 | 
			
		||||
    "policies_unique_password_trim_history": {
 | 
			
		||||
        "task": "authentik.enterprise.policies.unique_password.tasks.trim_password_histories",
 | 
			
		||||
        "schedule": crontab(minute=fqdn_rand("policies_unique_password_trim"), hour="*/12"),
 | 
			
		||||
        "options": {"queue": "authentik_scheduled"},
 | 
			
		||||
    },
 | 
			
		||||
    "policies_unique_password_check_purge": {
 | 
			
		||||
        "task": (
 | 
			
		||||
            "authentik.enterprise.policies.unique_password.tasks.check_and_purge_password_history"
 | 
			
		||||
        ),
 | 
			
		||||
        "schedule": crontab(minute=fqdn_rand("policies_unique_password_purge"), hour="*/24"),
 | 
			
		||||
        "options": {"queue": "authentik_scheduled"},
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								authentik/enterprise/policies/unique_password/signals.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								authentik/enterprise/policies/unique_password/signals.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,23 @@
 | 
			
		||||
"""authentik policy signals"""
 | 
			
		||||
 | 
			
		||||
from django.dispatch import receiver
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.core.signals import password_changed
 | 
			
		||||
from authentik.enterprise.policies.unique_password.models import (
 | 
			
		||||
    UniquePasswordPolicy,
 | 
			
		||||
    UserPasswordHistory,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@receiver(password_changed)
 | 
			
		||||
def copy_password_to_password_history(sender, user: User, *args, **kwargs):
 | 
			
		||||
    """Preserve the user's old password if UniquePasswordPolicy is enabled anywhere"""
 | 
			
		||||
    # Check if any UniquePasswordPolicy is in use
 | 
			
		||||
    unique_pwd_policy_in_use = UniquePasswordPolicy.is_in_use()
 | 
			
		||||
 | 
			
		||||
    if unique_pwd_policy_in_use:
 | 
			
		||||
        """NOTE: Because we run this in a signal after saving the user,
 | 
			
		||||
        we are not atomically guaranteed to save password history.
 | 
			
		||||
        """
 | 
			
		||||
        UserPasswordHistory.create_for_user(user, user.password)
 | 
			
		||||
							
								
								
									
										66
									
								
								authentik/enterprise/policies/unique_password/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								authentik/enterprise/policies/unique_password/tasks.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,66 @@
 | 
			
		||||
from django.db.models.aggregates import Count
 | 
			
		||||
from structlog import get_logger
 | 
			
		||||
 | 
			
		||||
from authentik.enterprise.policies.unique_password.models import (
 | 
			
		||||
    UniquePasswordPolicy,
 | 
			
		||||
    UserPasswordHistory,
 | 
			
		||||
)
 | 
			
		||||
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
 | 
			
		||||
from authentik.root.celery import CELERY_APP
 | 
			
		||||
 | 
			
		||||
LOGGER = get_logger()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@CELERY_APP.task(bind=True, base=SystemTask)
 | 
			
		||||
@prefill_task
 | 
			
		||||
def check_and_purge_password_history(self: SystemTask):
 | 
			
		||||
    """Check if any UniquePasswordPolicy exists, and if not, purge the password history table.
 | 
			
		||||
    This is run on a schedule instead of being triggered by policy binding deletion.
 | 
			
		||||
    """
 | 
			
		||||
    if not UniquePasswordPolicy.objects.exists():
 | 
			
		||||
        UserPasswordHistory.objects.all().delete()
 | 
			
		||||
        LOGGER.debug("Purged UserPasswordHistory table as no policies are in use")
 | 
			
		||||
        self.set_status(TaskStatus.SUCCESSFUL, "Successfully purged UserPasswordHistory")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    self.set_status(
 | 
			
		||||
        TaskStatus.SUCCESSFUL, "Not purging password histories, a unique password policy exists"
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@CELERY_APP.task(bind=True, base=SystemTask)
 | 
			
		||||
def trim_password_histories(self: SystemTask):
 | 
			
		||||
    """Removes rows from UserPasswordHistory older than
 | 
			
		||||
    the `n` most recent entries.
 | 
			
		||||
 | 
			
		||||
    The `n` is defined by the largest configured value for all bound
 | 
			
		||||
    UniquePasswordPolicy policies.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    # No policy, we'll let the cleanup above do its thing
 | 
			
		||||
    if not UniquePasswordPolicy.objects.exists():
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    num_rows_to_preserve = 0
 | 
			
		||||
    for policy in UniquePasswordPolicy.objects.all():
 | 
			
		||||
        num_rows_to_preserve = max(num_rows_to_preserve, policy.num_historical_passwords)
 | 
			
		||||
 | 
			
		||||
    all_pks_to_keep = []
 | 
			
		||||
 | 
			
		||||
    # Get all users who have password history entries
 | 
			
		||||
    users_with_history = (
 | 
			
		||||
        UserPasswordHistory.objects.values("user")
 | 
			
		||||
        .annotate(count=Count("user"))
 | 
			
		||||
        .filter(count__gt=0)
 | 
			
		||||
        .values_list("user", flat=True)
 | 
			
		||||
    )
 | 
			
		||||
    for user_pk in users_with_history:
 | 
			
		||||
        entries = UserPasswordHistory.objects.filter(user__pk=user_pk)
 | 
			
		||||
        pks_to_keep = entries.order_by("-created_at")[:num_rows_to_preserve].values_list(
 | 
			
		||||
            "pk", flat=True
 | 
			
		||||
        )
 | 
			
		||||
        all_pks_to_keep.extend(pks_to_keep)
 | 
			
		||||
 | 
			
		||||
    num_deleted, _ = UserPasswordHistory.objects.exclude(pk__in=all_pks_to_keep).delete()
 | 
			
		||||
    LOGGER.debug("Deleted stale password history records", count=num_deleted)
 | 
			
		||||
    self.set_status(TaskStatus.SUCCESSFUL, f"Delete {num_deleted} stale password history records")
 | 
			
		||||
@ -0,0 +1,108 @@
 | 
			
		||||
"""Unique Password Policy flow tests"""
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.hashers import make_password
 | 
			
		||||
from django.urls.base import reverse
 | 
			
		||||
 | 
			
		||||
from authentik.core.tests.utils import create_test_flow, create_test_user
 | 
			
		||||
from authentik.enterprise.policies.unique_password.models import (
 | 
			
		||||
    UniquePasswordPolicy,
 | 
			
		||||
    UserPasswordHistory,
 | 
			
		||||
)
 | 
			
		||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
 | 
			
		||||
from authentik.flows.tests import FlowTestCase
 | 
			
		||||
from authentik.lib.generators import generate_id
 | 
			
		||||
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestUniquePasswordPolicyFlow(FlowTestCase):
 | 
			
		||||
    """Test Unique Password Policy in a flow"""
 | 
			
		||||
 | 
			
		||||
    REUSED_PASSWORD = "hunter1"  # nosec B105
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        self.user = create_test_user()
 | 
			
		||||
        self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
 | 
			
		||||
 | 
			
		||||
        password_prompt = Prompt.objects.create(
 | 
			
		||||
            name=generate_id(),
 | 
			
		||||
            field_key="password",
 | 
			
		||||
            label="PASSWORD_LABEL",
 | 
			
		||||
            type=FieldTypes.PASSWORD,
 | 
			
		||||
            required=True,
 | 
			
		||||
            placeholder="PASSWORD_PLACEHOLDER",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.policy = UniquePasswordPolicy.objects.create(
 | 
			
		||||
            name="password_must_unique",
 | 
			
		||||
            password_field=password_prompt.field_key,
 | 
			
		||||
            num_historical_passwords=1,
 | 
			
		||||
        )
 | 
			
		||||
        stage = PromptStage.objects.create(name="prompt-stage")
 | 
			
		||||
        stage.validation_policies.set([self.policy])
 | 
			
		||||
        stage.fields.set(
 | 
			
		||||
            [
 | 
			
		||||
                password_prompt,
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        FlowStageBinding.objects.create(target=self.flow, stage=stage, order=2)
 | 
			
		||||
 | 
			
		||||
        # Seed the user's password history
 | 
			
		||||
        UserPasswordHistory.create_for_user(self.user, make_password(self.REUSED_PASSWORD))
 | 
			
		||||
 | 
			
		||||
    def test_prompt_data(self):
 | 
			
		||||
        """Test policy attached to a prompt stage"""
 | 
			
		||||
        # Test the policy directly
 | 
			
		||||
        from authentik.policies.types import PolicyRequest
 | 
			
		||||
        from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 | 
			
		||||
 | 
			
		||||
        # Create a policy request with the reused password
 | 
			
		||||
        request = PolicyRequest(user=self.user)
 | 
			
		||||
        request.context[PLAN_CONTEXT_PROMPT] = {"password": self.REUSED_PASSWORD}
 | 
			
		||||
 | 
			
		||||
        # Test the policy directly
 | 
			
		||||
        result = self.policy.passes(request)
 | 
			
		||||
 | 
			
		||||
        # Verify that the policy fails (returns False) with the expected error message
 | 
			
		||||
        self.assertFalse(result.passing, "Policy should fail for reused password")
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            result.messages[0],
 | 
			
		||||
            "This password has been used previously. Please choose a different one.",
 | 
			
		||||
            "Incorrect error message",
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # API-based testing approach:
 | 
			
		||||
 | 
			
		||||
        self.client.force_login(self.user)
 | 
			
		||||
 | 
			
		||||
        # Send a POST request to the flow executor with the reused password
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
 | 
			
		||||
            {"password": self.REUSED_PASSWORD},
 | 
			
		||||
        )
 | 
			
		||||
        self.assertStageResponse(
 | 
			
		||||
            response,
 | 
			
		||||
            self.flow,
 | 
			
		||||
            component="ak-stage-prompt",
 | 
			
		||||
            fields=[
 | 
			
		||||
                {
 | 
			
		||||
                    "choices": None,
 | 
			
		||||
                    "field_key": "password",
 | 
			
		||||
                    "label": "PASSWORD_LABEL",
 | 
			
		||||
                    "order": 0,
 | 
			
		||||
                    "placeholder": "PASSWORD_PLACEHOLDER",
 | 
			
		||||
                    "initial_value": "",
 | 
			
		||||
                    "required": True,
 | 
			
		||||
                    "type": "password",
 | 
			
		||||
                    "sub_text": "",
 | 
			
		||||
                }
 | 
			
		||||
            ],
 | 
			
		||||
            response_errors={
 | 
			
		||||
                "non_field_errors": [
 | 
			
		||||
                    {
 | 
			
		||||
                        "code": "invalid",
 | 
			
		||||
                        "string": "This password has been used previously. "
 | 
			
		||||
                        "Please choose a different one.",
 | 
			
		||||
                    }
 | 
			
		||||
                ]
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
@ -0,0 +1,77 @@
 | 
			
		||||
"""Unique Password Policy tests"""
 | 
			
		||||
 | 
			
		||||
from django.contrib.auth.hashers import make_password
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
from guardian.shortcuts import get_anonymous_user
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import User
 | 
			
		||||
from authentik.enterprise.policies.unique_password.models import (
 | 
			
		||||
    UniquePasswordPolicy,
 | 
			
		||||
    UserPasswordHistory,
 | 
			
		||||
)
 | 
			
		||||
from authentik.policies.types import PolicyRequest, PolicyResult
 | 
			
		||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestUniquePasswordPolicy(TestCase):
 | 
			
		||||
    """Test Password Uniqueness Policy"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self) -> None:
 | 
			
		||||
        self.policy = UniquePasswordPolicy.objects.create(
 | 
			
		||||
            name="test_unique_password", num_historical_passwords=1
 | 
			
		||||
        )
 | 
			
		||||
        self.user = User.objects.create(username="test-user")
 | 
			
		||||
 | 
			
		||||
    def test_invalid(self):
 | 
			
		||||
        """Test without password present in request"""
 | 
			
		||||
        request = PolicyRequest(get_anonymous_user())
 | 
			
		||||
        result: PolicyResult = self.policy.passes(request)
 | 
			
		||||
        self.assertFalse(result.passing)
 | 
			
		||||
        self.assertEqual(result.messages[0], "Password not set in context")
 | 
			
		||||
 | 
			
		||||
    def test_passes_no_previous_passwords(self):
 | 
			
		||||
        request = PolicyRequest(get_anonymous_user())
 | 
			
		||||
        request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
 | 
			
		||||
        result: PolicyResult = self.policy.passes(request)
 | 
			
		||||
        self.assertTrue(result.passing)
 | 
			
		||||
 | 
			
		||||
    def test_passes_passwords_are_different(self):
 | 
			
		||||
        # Seed database with an old password
 | 
			
		||||
        UserPasswordHistory.create_for_user(self.user, make_password("hunter1"))
 | 
			
		||||
 | 
			
		||||
        request = PolicyRequest(self.user)
 | 
			
		||||
        request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
 | 
			
		||||
        result: PolicyResult = self.policy.passes(request)
 | 
			
		||||
        self.assertTrue(result.passing)
 | 
			
		||||
 | 
			
		||||
    def test_passes_multiple_old_passwords(self):
 | 
			
		||||
        # Seed with multiple old passwords
 | 
			
		||||
        UserPasswordHistory.objects.bulk_create(
 | 
			
		||||
            [
 | 
			
		||||
                UserPasswordHistory(user=self.user, old_password=make_password("hunter1")),
 | 
			
		||||
                UserPasswordHistory(user=self.user, old_password=make_password("hunter2")),
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
        request = PolicyRequest(self.user)
 | 
			
		||||
        request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter3"}}
 | 
			
		||||
        result: PolicyResult = self.policy.passes(request)
 | 
			
		||||
        self.assertTrue(result.passing)
 | 
			
		||||
 | 
			
		||||
    def test_fails_password_matches_old_password(self):
 | 
			
		||||
        # Seed database with an old password
 | 
			
		||||
 | 
			
		||||
        UserPasswordHistory.create_for_user(self.user, make_password("hunter1"))
 | 
			
		||||
 | 
			
		||||
        request = PolicyRequest(self.user)
 | 
			
		||||
        request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter1"}}
 | 
			
		||||
        result: PolicyResult = self.policy.passes(request)
 | 
			
		||||
        self.assertFalse(result.passing)
 | 
			
		||||
 | 
			
		||||
    def test_fails_if_identical_password_with_different_hash_algos(self):
 | 
			
		||||
        UserPasswordHistory.create_for_user(
 | 
			
		||||
            self.user, make_password("hunter2", "somesalt", "scrypt")
 | 
			
		||||
        )
 | 
			
		||||
        request = PolicyRequest(self.user)
 | 
			
		||||
        request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
 | 
			
		||||
        result: PolicyResult = self.policy.passes(request)
 | 
			
		||||
        self.assertFalse(result.passing)
 | 
			
		||||
@ -0,0 +1,90 @@
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import Group, Source, User
 | 
			
		||||
from authentik.core.tests.utils import create_test_flow, create_test_user
 | 
			
		||||
from authentik.enterprise.policies.unique_password.models import (
 | 
			
		||||
    UniquePasswordPolicy,
 | 
			
		||||
    UserPasswordHistory,
 | 
			
		||||
)
 | 
			
		||||
from authentik.flows.markers import StageMarker
 | 
			
		||||
from authentik.flows.models import FlowStageBinding
 | 
			
		||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
 | 
			
		||||
from authentik.flows.tests import FlowTestCase
 | 
			
		||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
 | 
			
		||||
from authentik.lib.generators import generate_key
 | 
			
		||||
from authentik.policies.models import PolicyBinding, PolicyBindingModel
 | 
			
		||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
 | 
			
		||||
from authentik.stages.user_write.models import UserWriteStage
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestUserWriteStage(FlowTestCase):
 | 
			
		||||
    """Write tests"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        super().setUp()
 | 
			
		||||
        self.flow = create_test_flow()
 | 
			
		||||
        self.group = Group.objects.create(name="test-group")
 | 
			
		||||
        self.other_group = Group.objects.create(name="other-group")
 | 
			
		||||
        self.stage: UserWriteStage = UserWriteStage.objects.create(
 | 
			
		||||
            name="write", create_users_as_inactive=True, create_users_group=self.group
 | 
			
		||||
        )
 | 
			
		||||
        self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
 | 
			
		||||
        self.source = Source.objects.create(name="fake_source")
 | 
			
		||||
 | 
			
		||||
    def test_save_password_history_if_policy_binding_enforced(self):
 | 
			
		||||
        """Test user's new password is recorded when ANY enabled UniquePasswordPolicy exists"""
 | 
			
		||||
        unique_password_policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
 | 
			
		||||
        pbm = PolicyBindingModel.objects.create()
 | 
			
		||||
        PolicyBinding.objects.create(
 | 
			
		||||
            target=pbm, policy=unique_password_policy, order=0, enabled=True
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        test_user = create_test_user()
 | 
			
		||||
        # Store original password for verification
 | 
			
		||||
        original_password = test_user.password
 | 
			
		||||
 | 
			
		||||
        # We're changing our own password
 | 
			
		||||
        self.client.force_login(test_user)
 | 
			
		||||
 | 
			
		||||
        new_password = generate_key()
 | 
			
		||||
        plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
 | 
			
		||||
        plan.context[PLAN_CONTEXT_PENDING_USER] = test_user
 | 
			
		||||
        plan.context[PLAN_CONTEXT_PROMPT] = {
 | 
			
		||||
            "username": test_user.username,
 | 
			
		||||
            "password": new_password,
 | 
			
		||||
        }
 | 
			
		||||
        session = self.client.session
 | 
			
		||||
        session[SESSION_KEY_PLAN] = plan
 | 
			
		||||
        session.save()
 | 
			
		||||
        # Password history should be recorded
 | 
			
		||||
        user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
 | 
			
		||||
        self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
 | 
			
		||||
        self.assertEqual(len(user_password_history_qs), 1, "expected 1 recorded password")
 | 
			
		||||
 | 
			
		||||
        # Create a password history entry manually to simulate the signal behavior
 | 
			
		||||
        # This is what would happen if the signal worked correctly
 | 
			
		||||
        UserPasswordHistory.objects.create(user=test_user, old_password=original_password)
 | 
			
		||||
        user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
 | 
			
		||||
        self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
 | 
			
		||||
        self.assertEqual(len(user_password_history_qs), 2, "expected 2 recorded password")
 | 
			
		||||
 | 
			
		||||
        # Execute the flow by sending a POST request to the flow executor endpoint
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Verify that the request was successful
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        user_qs = User.objects.filter(username=plan.context[PLAN_CONTEXT_PROMPT]["username"])
 | 
			
		||||
        self.assertTrue(user_qs.exists())
 | 
			
		||||
 | 
			
		||||
        # Verify the password history entry exists
 | 
			
		||||
        user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
 | 
			
		||||
        self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(len(user_password_history_qs), 3, "expected 3 recorded password")
 | 
			
		||||
        # Verify that one of the entries contains the original password
 | 
			
		||||
        self.assertTrue(
 | 
			
		||||
            any(entry.old_password == original_password for entry in user_password_history_qs),
 | 
			
		||||
            "original password should be in password history table",
 | 
			
		||||
        )
 | 
			
		||||
@ -0,0 +1,178 @@
 | 
			
		||||
from datetime import datetime, timedelta
 | 
			
		||||
 | 
			
		||||
from django.test import TestCase
 | 
			
		||||
 | 
			
		||||
from authentik.core.tests.utils import create_test_user
 | 
			
		||||
from authentik.enterprise.policies.unique_password.models import (
 | 
			
		||||
    UniquePasswordPolicy,
 | 
			
		||||
    UserPasswordHistory,
 | 
			
		||||
)
 | 
			
		||||
from authentik.enterprise.policies.unique_password.tasks import (
 | 
			
		||||
    check_and_purge_password_history,
 | 
			
		||||
    trim_password_histories,
 | 
			
		||||
)
 | 
			
		||||
from authentik.policies.models import PolicyBinding, PolicyBindingModel
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestUniquePasswordPolicyModel(TestCase):
 | 
			
		||||
    """Test the UniquePasswordPolicy model methods"""
 | 
			
		||||
 | 
			
		||||
    def test_is_in_use_with_binding(self):
 | 
			
		||||
        """Test is_in_use returns True when a policy binding exists"""
 | 
			
		||||
        # Create a UniquePasswordPolicy and a PolicyBinding for it
 | 
			
		||||
        policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
 | 
			
		||||
        pbm = PolicyBindingModel.objects.create()
 | 
			
		||||
        PolicyBinding.objects.create(target=pbm, policy=policy, order=0, enabled=True)
 | 
			
		||||
 | 
			
		||||
        # Verify is_in_use returns True
 | 
			
		||||
        self.assertTrue(UniquePasswordPolicy.is_in_use())
 | 
			
		||||
 | 
			
		||||
    def test_is_in_use_with_promptstage(self):
 | 
			
		||||
        """Test is_in_use returns True when attached to a PromptStage"""
 | 
			
		||||
        from authentik.stages.prompt.models import PromptStage
 | 
			
		||||
 | 
			
		||||
        # Create a UniquePasswordPolicy and attach it to a PromptStage
 | 
			
		||||
        policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
 | 
			
		||||
        prompt_stage = PromptStage.objects.create(
 | 
			
		||||
            name="Test Prompt Stage",
 | 
			
		||||
        )
 | 
			
		||||
        # Use the set() method for many-to-many relationships
 | 
			
		||||
        prompt_stage.validation_policies.set([policy])
 | 
			
		||||
 | 
			
		||||
        # Verify is_in_use returns True
 | 
			
		||||
        self.assertTrue(UniquePasswordPolicy.is_in_use())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestTrimAllPasswordHistories(TestCase):
 | 
			
		||||
    """Test the task that trims password history for all users"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.user1 = create_test_user("test-user1")
 | 
			
		||||
        self.user2 = create_test_user("test-user2")
 | 
			
		||||
        self.pbm = PolicyBindingModel.objects.create()
 | 
			
		||||
        # Create a policy with a limit of 1 password
 | 
			
		||||
        self.policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
 | 
			
		||||
        PolicyBinding.objects.create(
 | 
			
		||||
            target=self.pbm,
 | 
			
		||||
            policy=self.policy,
 | 
			
		||||
            enabled=True,
 | 
			
		||||
            order=0,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestCheckAndPurgePasswordHistory(TestCase):
 | 
			
		||||
    """Test the scheduled task that checks if any policy is in use and purges if not"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.user = create_test_user("test-user")
 | 
			
		||||
        self.pbm = PolicyBindingModel.objects.create()
 | 
			
		||||
 | 
			
		||||
    def test_purge_when_no_policy_in_use(self):
 | 
			
		||||
        """Test that the task purges the table when no policy is in use"""
 | 
			
		||||
        # Create some password history entries
 | 
			
		||||
        UserPasswordHistory.create_for_user(self.user, "hunter2")
 | 
			
		||||
 | 
			
		||||
        # Verify we have entries
 | 
			
		||||
        self.assertTrue(UserPasswordHistory.objects.exists())
 | 
			
		||||
 | 
			
		||||
        # Run the task - should purge since no policy is in use
 | 
			
		||||
        check_and_purge_password_history()
 | 
			
		||||
 | 
			
		||||
        # Verify the table is empty
 | 
			
		||||
        self.assertFalse(UserPasswordHistory.objects.exists())
 | 
			
		||||
 | 
			
		||||
    def test_no_purge_when_policy_in_use(self):
 | 
			
		||||
        """Test that the task doesn't purge when a policy is in use"""
 | 
			
		||||
        # Create a policy and binding
 | 
			
		||||
        policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
 | 
			
		||||
        PolicyBinding.objects.create(
 | 
			
		||||
            target=self.pbm,
 | 
			
		||||
            policy=policy,
 | 
			
		||||
            enabled=True,
 | 
			
		||||
            order=0,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Create some password history entries
 | 
			
		||||
        UserPasswordHistory.create_for_user(self.user, "hunter2")
 | 
			
		||||
 | 
			
		||||
        # Verify we have entries
 | 
			
		||||
        self.assertTrue(UserPasswordHistory.objects.exists())
 | 
			
		||||
 | 
			
		||||
        # Run the task - should NOT purge since a policy is in use
 | 
			
		||||
        check_and_purge_password_history()
 | 
			
		||||
 | 
			
		||||
        # Verify the entries still exist
 | 
			
		||||
        self.assertTrue(UserPasswordHistory.objects.exists())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestTrimPasswordHistory(TestCase):
 | 
			
		||||
    """Test password history cleanup task"""
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.user = create_test_user("test-user")
 | 
			
		||||
        self.pbm = PolicyBindingModel.objects.create()
 | 
			
		||||
 | 
			
		||||
    def test_trim_password_history_ok(self):
 | 
			
		||||
        """Test passwords over the define limit are deleted"""
 | 
			
		||||
        _now = datetime.now()
 | 
			
		||||
        UserPasswordHistory.objects.bulk_create(
 | 
			
		||||
            [
 | 
			
		||||
                UserPasswordHistory(
 | 
			
		||||
                    user=self.user,
 | 
			
		||||
                    old_password="hunter1",  # nosec B106
 | 
			
		||||
                    created_at=_now - timedelta(days=3),
 | 
			
		||||
                ),
 | 
			
		||||
                UserPasswordHistory(
 | 
			
		||||
                    user=self.user,
 | 
			
		||||
                    old_password="hunter2",  # nosec B106
 | 
			
		||||
                    created_at=_now - timedelta(days=2),
 | 
			
		||||
                ),
 | 
			
		||||
                UserPasswordHistory(
 | 
			
		||||
                    user=self.user,
 | 
			
		||||
                    old_password="hunter3",  # nosec B106
 | 
			
		||||
                    created_at=_now,
 | 
			
		||||
                ),
 | 
			
		||||
            ]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
 | 
			
		||||
        PolicyBinding.objects.create(
 | 
			
		||||
            target=self.pbm,
 | 
			
		||||
            policy=policy,
 | 
			
		||||
            enabled=True,
 | 
			
		||||
            order=0,
 | 
			
		||||
        )
 | 
			
		||||
        trim_password_histories.delay()
 | 
			
		||||
        user_pwd_history_qs = UserPasswordHistory.objects.filter(user=self.user)
 | 
			
		||||
        self.assertEqual(len(user_pwd_history_qs), 1)
 | 
			
		||||
 | 
			
		||||
    def test_trim_password_history_policy_diabled_no_op(self):
 | 
			
		||||
        """Test no passwords removed if policy binding is disabled"""
 | 
			
		||||
 | 
			
		||||
        # Insert a record to ensure it's not deleted after executing task
 | 
			
		||||
        UserPasswordHistory.create_for_user(self.user, "hunter2")
 | 
			
		||||
 | 
			
		||||
        policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
 | 
			
		||||
        PolicyBinding.objects.create(
 | 
			
		||||
            target=self.pbm,
 | 
			
		||||
            policy=policy,
 | 
			
		||||
            enabled=False,
 | 
			
		||||
            order=0,
 | 
			
		||||
        )
 | 
			
		||||
        trim_password_histories.delay()
 | 
			
		||||
        self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())
 | 
			
		||||
 | 
			
		||||
    def test_trim_password_history_fewer_records_than_maximum_is_no_op(self):
 | 
			
		||||
        """Test no passwords deleted if fewer passwords exist than limit"""
 | 
			
		||||
 | 
			
		||||
        UserPasswordHistory.create_for_user(self.user, "hunter2")
 | 
			
		||||
 | 
			
		||||
        policy = UniquePasswordPolicy.objects.create(num_historical_passwords=2)
 | 
			
		||||
        PolicyBinding.objects.create(
 | 
			
		||||
            target=self.pbm,
 | 
			
		||||
            policy=policy,
 | 
			
		||||
            enabled=True,
 | 
			
		||||
            order=0,
 | 
			
		||||
        )
 | 
			
		||||
        trim_password_histories.delay()
 | 
			
		||||
        self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())
 | 
			
		||||
							
								
								
									
										7
									
								
								authentik/enterprise/policies/unique_password/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								authentik/enterprise/policies/unique_password/urls.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,7 @@
 | 
			
		||||
"""API URLs"""
 | 
			
		||||
 | 
			
		||||
from authentik.enterprise.policies.unique_password.api import UniquePasswordPolicyViewSet
 | 
			
		||||
 | 
			
		||||
api_urlpatterns = [
 | 
			
		||||
    ("policies/unique_password", UniquePasswordPolicyViewSet),
 | 
			
		||||
]
 | 
			
		||||
@ -14,6 +14,7 @@ CELERY_BEAT_SCHEDULE = {
 | 
			
		||||
 | 
			
		||||
TENANT_APPS = [
 | 
			
		||||
    "authentik.enterprise.audit",
 | 
			
		||||
    "authentik.enterprise.policies.unique_password",
 | 
			
		||||
    "authentik.enterprise.providers.google_workspace",
 | 
			
		||||
    "authentik.enterprise.providers.microsoft_entra",
 | 
			
		||||
    "authentik.enterprise.providers.ssf",
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -74,6 +74,8 @@ class OutpostConfig:
 | 
			
		||||
    kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict)
 | 
			
		||||
    kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls")
 | 
			
		||||
    kubernetes_ingress_class_name: str | None = field(default=None)
 | 
			
		||||
    kubernetes_httproute_annotations: dict[str, str] = field(default_factory=dict)
 | 
			
		||||
    kubernetes_httproute_parent_refs: list[dict[str, str]] = field(default_factory=list)
 | 
			
		||||
    kubernetes_service_type: str = field(default="ClusterIP")
 | 
			
		||||
    kubernetes_disabled_components: list[str] = field(default_factory=list)
 | 
			
		||||
    kubernetes_image_pull_secrets: list[str] = field(default_factory=list)
 | 
			
		||||
 | 
			
		||||
@ -78,7 +78,6 @@ class PolicyBindingSerializer(ModelSerializer):
 | 
			
		||||
            "negate",
 | 
			
		||||
            "enabled",
 | 
			
		||||
            "order",
 | 
			
		||||
            "honor_order",
 | 
			
		||||
            "timeout",
 | 
			
		||||
            "failure_result",
 | 
			
		||||
        ]
 | 
			
		||||
@ -111,16 +110,7 @@ class PolicyBindingFilter(FilterSet):
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = PolicyBinding
 | 
			
		||||
        fields = [
 | 
			
		||||
            "policy",
 | 
			
		||||
            "policy__isnull",
 | 
			
		||||
            "target",
 | 
			
		||||
            "target_in",
 | 
			
		||||
            "enabled",
 | 
			
		||||
            "order",
 | 
			
		||||
            "honor_order",
 | 
			
		||||
            "timeout",
 | 
			
		||||
        ]
 | 
			
		||||
        fields = ["policy", "policy__isnull", "target", "target_in", "enabled", "order", "timeout"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PolicyBindingViewSet(UsedByMixin, ModelViewSet):
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,8 @@
 | 
			
		||||
"""authentik policies app config"""
 | 
			
		||||
"""Authentik policies app config
 | 
			
		||||
 | 
			
		||||
Every system policy should be its own Django app under the `policies` app.
 | 
			
		||||
For example: The 'dummy' policy is available at `authentik.policies.dummy`.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
from prometheus_client import Gauge, Histogram
 | 
			
		||||
 | 
			
		||||
@ -35,4 +39,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
 | 
			
		||||
    label = "authentik_policies"
 | 
			
		||||
    verbose_name = "authentik Policies"
 | 
			
		||||
    default = True
 | 
			
		||||
    mountpoint = "policy/"
 | 
			
		||||
 | 
			
		||||
@ -1,40 +0,0 @@
 | 
			
		||||
# Generated by Django 5.1.8 on 2025-04-17 15:13
 | 
			
		||||
 | 
			
		||||
from django.conf import settings
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_core", "0047_delete_oldauthenticatedsession"),
 | 
			
		||||
        ("authentik_policies", "0011_policybinding_failure_result_and_more"),
 | 
			
		||||
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddConstraint(
 | 
			
		||||
            model_name="policybinding",
 | 
			
		||||
            constraint=models.CheckConstraint(
 | 
			
		||||
                condition=models.Q(
 | 
			
		||||
                    models.Q(
 | 
			
		||||
                        ("policy_id__isnull", False),
 | 
			
		||||
                        ("group_id__isnull", True),
 | 
			
		||||
                        ("user_id__isnull", True),
 | 
			
		||||
                    ),
 | 
			
		||||
                    models.Q(
 | 
			
		||||
                        ("group_id__isnull", False),
 | 
			
		||||
                        ("policy_id__isnull", True),
 | 
			
		||||
                        ("user_id__isnull", True),
 | 
			
		||||
                    ),
 | 
			
		||||
                    models.Q(
 | 
			
		||||
                        ("user_id__isnull", False),
 | 
			
		||||
                        ("policy_id__isnull", True),
 | 
			
		||||
                        ("group_id__isnull", True),
 | 
			
		||||
                    ),
 | 
			
		||||
                    _connector="OR",
 | 
			
		||||
                ),
 | 
			
		||||
                name="authentik_policies_policybinding_only_one_type",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -1,20 +0,0 @@
 | 
			
		||||
# Generated by Django 5.1.8 on 2025-04-17 15:16
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("authentik_policies", "0012_policybinding_authentik_policies_policybinding_only_one_type"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="policybinding",
 | 
			
		||||
            name="honor_order",
 | 
			
		||||
            field=models.BooleanField(
 | 
			
		||||
                default=False, help_text="Honor order when evaluating policies."
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -3,7 +3,6 @@
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
 | 
			
		||||
from django.db import models
 | 
			
		||||
from django.db.models import Q
 | 
			
		||||
from django.utils.translation import gettext_lazy as _
 | 
			
		||||
from model_utils.managers import InheritanceManager
 | 
			
		||||
from rest_framework.serializers import BaseSerializer
 | 
			
		||||
@ -53,6 +52,13 @@ class PolicyBindingModel(models.Model):
 | 
			
		||||
        return ["policy", "user", "group"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BoundPolicyQuerySet(models.QuerySet):
 | 
			
		||||
    """QuerySet for filtering enabled bindings for a Policy type"""
 | 
			
		||||
 | 
			
		||||
    def for_policy(self, policy: "Policy"):
 | 
			
		||||
        return self.filter(policy__in=policy._default_manager.all()).filter(enabled=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PolicyBinding(SerializerModel):
 | 
			
		||||
    """Relationship between a Policy and a PolicyBindingModel."""
 | 
			
		||||
 | 
			
		||||
@ -101,10 +107,6 @@ class PolicyBinding(SerializerModel):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    order = models.IntegerField()
 | 
			
		||||
    honor_order = models.BooleanField(
 | 
			
		||||
        default=False,
 | 
			
		||||
        help_text=_("Honor order when evaluating policies."),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def passes(self, request: PolicyRequest) -> PolicyResult:
 | 
			
		||||
        """Check if request passes this PolicyBinding, check policy, group or user"""
 | 
			
		||||
@ -153,6 +155,9 @@ class PolicyBinding(SerializerModel):
 | 
			
		||||
            return f"Binding - #{self.order} to {suffix}"
 | 
			
		||||
        return ""
 | 
			
		||||
 | 
			
		||||
    objects = models.Manager()
 | 
			
		||||
    in_use = BoundPolicyQuerySet.as_manager()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        verbose_name = _("Policy Binding")
 | 
			
		||||
        verbose_name_plural = _("Policy Bindings")
 | 
			
		||||
@ -163,28 +168,6 @@ class PolicyBinding(SerializerModel):
 | 
			
		||||
            models.Index(fields=["user"]),
 | 
			
		||||
            models.Index(fields=["target"]),
 | 
			
		||||
        ]
 | 
			
		||||
        constraints = (
 | 
			
		||||
            models.CheckConstraint(
 | 
			
		||||
                condition=(
 | 
			
		||||
                    (
 | 
			
		||||
                        Q(policy_id__isnull=False)
 | 
			
		||||
                        & Q(group_id__isnull=True)
 | 
			
		||||
                        & Q(user_id__isnull=True)
 | 
			
		||||
                    )
 | 
			
		||||
                    | (
 | 
			
		||||
                        Q(group_id__isnull=False)
 | 
			
		||||
                        & Q(policy_id__isnull=True)
 | 
			
		||||
                        & Q(user_id__isnull=True)
 | 
			
		||||
                    )
 | 
			
		||||
                    | (
 | 
			
		||||
                        Q(user_id__isnull=False)
 | 
			
		||||
                        & Q(policy_id__isnull=True)
 | 
			
		||||
                        & Q(group_id__isnull=True)
 | 
			
		||||
                    )
 | 
			
		||||
                ),
 | 
			
		||||
                name="%(app_label)s_%(class)s_only_one_type",
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Policy(SerializerModel, CreatedUpdatedModel):
 | 
			
		||||
 | 
			
		||||
@ -2,4 +2,6 @@
 | 
			
		||||
 | 
			
		||||
from authentik.policies.password.api import PasswordPolicyViewSet
 | 
			
		||||
 | 
			
		||||
api_urlpatterns = [("policies/password", PasswordPolicyViewSet)]
 | 
			
		||||
api_urlpatterns = [
 | 
			
		||||
    ("policies/password", PasswordPolicyViewSet),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@ -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 %}
 | 
			
		||||
@ -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())
 | 
			
		||||
@ -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),
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
@ -326,7 +326,7 @@ class OAuthAuthorizationParams:
 | 
			
		||||
        return code
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AuthorizationFlowInitView(BufferedPolicyAccessView):
 | 
			
		||||
class AuthorizationFlowInitView(PolicyAccessView):
 | 
			
		||||
    """OAuth2 Flow initializer, checks access to application and starts flow"""
 | 
			
		||||
 | 
			
		||||
    params: OAuthAuthorizationParams
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										234
									
								
								authentik/providers/proxy/controllers/k8s/httproute.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								authentik/providers/proxy/controllers/k8s/httproute.py
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,234 @@
 | 
			
		||||
from dataclasses import asdict, dataclass, field
 | 
			
		||||
from typing import TYPE_CHECKING
 | 
			
		||||
from urllib.parse import urlparse
 | 
			
		||||
 | 
			
		||||
from dacite.core import from_dict
 | 
			
		||||
from kubernetes.client import ApiextensionsV1Api, CustomObjectsApi, V1ObjectMeta
 | 
			
		||||
 | 
			
		||||
from authentik.outposts.controllers.base import FIELD_MANAGER
 | 
			
		||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
 | 
			
		||||
from authentik.outposts.controllers.k8s.triggers import NeedsUpdate
 | 
			
		||||
from authentik.outposts.controllers.kubernetes import KubernetesController
 | 
			
		||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
 | 
			
		||||
 | 
			
		||||
if TYPE_CHECKING:
 | 
			
		||||
    from authentik.outposts.controllers.kubernetes import KubernetesController
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class RouteBackendRef:
 | 
			
		||||
    name: str
 | 
			
		||||
    port: int
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class RouteSpecParentRefs:
 | 
			
		||||
    name: str
 | 
			
		||||
    sectionName: str | None = None
 | 
			
		||||
    port: int | None = None
 | 
			
		||||
    namespace: str | None = None
 | 
			
		||||
    kind: str = "Gateway"
 | 
			
		||||
    group: str = "gateway.networking.k8s.io"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class HTTPRouteSpecRuleMatchPath:
 | 
			
		||||
    type: str
 | 
			
		||||
    value: str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class HTTPRouteSpecRuleMatchHeader:
 | 
			
		||||
    name: str
 | 
			
		||||
    value: str
 | 
			
		||||
    type: str = "Exact"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class HTTPRouteSpecRuleMatch:
 | 
			
		||||
    path: HTTPRouteSpecRuleMatchPath
 | 
			
		||||
    headers: list[HTTPRouteSpecRuleMatchHeader]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class HTTPRouteSpecRule:
 | 
			
		||||
    backendRefs: list[RouteBackendRef]
 | 
			
		||||
    matches: list[HTTPRouteSpecRuleMatch]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class HTTPRouteSpec:
 | 
			
		||||
    parentRefs: list[RouteSpecParentRefs]
 | 
			
		||||
    hostnames: list[str]
 | 
			
		||||
    rules: list[HTTPRouteSpecRule]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class HTTPRouteMetadata:
 | 
			
		||||
    name: str
 | 
			
		||||
    namespace: str
 | 
			
		||||
    annotations: dict = field(default_factory=dict)
 | 
			
		||||
    labels: dict = field(default_factory=dict)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass(slots=True)
 | 
			
		||||
class HTTPRoute:
 | 
			
		||||
    apiVersion: str
 | 
			
		||||
    kind: str
 | 
			
		||||
    metadata: HTTPRouteMetadata
 | 
			
		||||
    spec: HTTPRouteSpec
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HTTPRouteReconciler(KubernetesObjectReconciler):
 | 
			
		||||
    """Kubernetes Gateway API HTTPRoute Reconciler"""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, controller: "KubernetesController") -> None:
 | 
			
		||||
        super().__init__(controller)
 | 
			
		||||
        self.api_ex = ApiextensionsV1Api(controller.client)
 | 
			
		||||
        self.api = CustomObjectsApi(controller.client)
 | 
			
		||||
        self.crd_group = "gateway.networking.k8s.io"
 | 
			
		||||
        self.crd_version = "v1"
 | 
			
		||||
        self.crd_plural = "httproutes"
 | 
			
		||||
 | 
			
		||||
    @staticmethod
 | 
			
		||||
    def reconciler_name() -> str:
 | 
			
		||||
        return "httproute"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def noop(self) -> bool:
 | 
			
		||||
        if not self.crd_exists():
 | 
			
		||||
            self.logger.debug("CRD doesn't exist")
 | 
			
		||||
            return True
 | 
			
		||||
        if not self.controller.outpost.config.kubernetes_httproute_parent_refs:
 | 
			
		||||
            self.logger.debug("HTTPRoute parentRefs not set.")
 | 
			
		||||
            return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def crd_exists(self) -> bool:
 | 
			
		||||
        """Check if the Gateway API resources exists"""
 | 
			
		||||
        return bool(
 | 
			
		||||
            len(
 | 
			
		||||
                self.api_ex.list_custom_resource_definition(
 | 
			
		||||
                    field_selector=f"metadata.name={self.crd_plural}.{self.crd_group}"
 | 
			
		||||
                ).items
 | 
			
		||||
            )
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def reconcile(self, current: HTTPRoute, reference: HTTPRoute):
 | 
			
		||||
        super().reconcile(current, reference)
 | 
			
		||||
        if current.metadata.annotations != reference.metadata.annotations:
 | 
			
		||||
            raise NeedsUpdate()
 | 
			
		||||
        if current.spec.parentRefs != reference.spec.parentRefs:
 | 
			
		||||
            raise NeedsUpdate()
 | 
			
		||||
        if current.spec.hostnames != reference.spec.hostnames:
 | 
			
		||||
            raise NeedsUpdate()
 | 
			
		||||
        if current.spec.rules != reference.spec.rules:
 | 
			
		||||
            raise NeedsUpdate()
 | 
			
		||||
 | 
			
		||||
    def get_object_meta(self, **kwargs) -> V1ObjectMeta:
 | 
			
		||||
        return super().get_object_meta(
 | 
			
		||||
            **kwargs,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def get_reference_object(self) -> HTTPRoute:
 | 
			
		||||
        hostnames = []
 | 
			
		||||
        rules = []
 | 
			
		||||
 | 
			
		||||
        for proxy_provider in ProxyProvider.objects.filter(outpost__in=[self.controller.outpost]):
 | 
			
		||||
            proxy_provider: ProxyProvider
 | 
			
		||||
            external_host_name = urlparse(proxy_provider.external_host)
 | 
			
		||||
            if proxy_provider.mode in [ProxyMode.FORWARD_SINGLE, ProxyMode.FORWARD_DOMAIN]:
 | 
			
		||||
                rule = HTTPRouteSpecRule(
 | 
			
		||||
                    backendRefs=[RouteBackendRef(name=self.name, port=9000)],
 | 
			
		||||
                    matches=[
 | 
			
		||||
                        HTTPRouteSpecRuleMatch(
 | 
			
		||||
                            headers=[
 | 
			
		||||
                                HTTPRouteSpecRuleMatchHeader(
 | 
			
		||||
                                    name="Host",
 | 
			
		||||
                                    value=external_host_name.hostname,
 | 
			
		||||
                                )
 | 
			
		||||
                            ],
 | 
			
		||||
                            path=HTTPRouteSpecRuleMatchPath(
 | 
			
		||||
                                type="PathPrefix", value="/outpost.goauthentik.io"
 | 
			
		||||
                            ),
 | 
			
		||||
                        )
 | 
			
		||||
                    ],
 | 
			
		||||
                )
 | 
			
		||||
            else:
 | 
			
		||||
                rule = HTTPRouteSpecRule(
 | 
			
		||||
                    backendRefs=[RouteBackendRef(name=self.name, port=9000)],
 | 
			
		||||
                    matches=[
 | 
			
		||||
                        HTTPRouteSpecRuleMatch(
 | 
			
		||||
                            headers=[
 | 
			
		||||
                                HTTPRouteSpecRuleMatchHeader(
 | 
			
		||||
                                    name="Host",
 | 
			
		||||
                                    value=external_host_name.hostname,
 | 
			
		||||
                                )
 | 
			
		||||
                            ],
 | 
			
		||||
                            path=HTTPRouteSpecRuleMatchPath(type="PathPrefix", value="/"),
 | 
			
		||||
                        )
 | 
			
		||||
                    ],
 | 
			
		||||
                )
 | 
			
		||||
            hostnames.append(external_host_name.hostname)
 | 
			
		||||
            rules.append(rule)
 | 
			
		||||
 | 
			
		||||
        return HTTPRoute(
 | 
			
		||||
            apiVersion=f"{self.crd_group}/{self.crd_version}",
 | 
			
		||||
            kind="HTTPRoute",
 | 
			
		||||
            metadata=HTTPRouteMetadata(
 | 
			
		||||
                name=self.name,
 | 
			
		||||
                namespace=self.namespace,
 | 
			
		||||
                annotations=self.controller.outpost.config.kubernetes_httproute_annotations,
 | 
			
		||||
                labels=self.get_object_meta().labels,
 | 
			
		||||
            ),
 | 
			
		||||
            spec=HTTPRouteSpec(
 | 
			
		||||
                parentRefs=[
 | 
			
		||||
                    from_dict(RouteSpecParentRefs, spec)
 | 
			
		||||
                    for spec in self.controller.outpost.config.kubernetes_httproute_parent_refs
 | 
			
		||||
                ],
 | 
			
		||||
                hostnames=hostnames,
 | 
			
		||||
                rules=rules,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def create(self, reference: HTTPRoute):
 | 
			
		||||
        return self.api.create_namespaced_custom_object(
 | 
			
		||||
            group=self.crd_group,
 | 
			
		||||
            version=self.crd_version,
 | 
			
		||||
            plural=self.crd_plural,
 | 
			
		||||
            namespace=self.namespace,
 | 
			
		||||
            body=asdict(reference),
 | 
			
		||||
            field_manager=FIELD_MANAGER,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def delete(self, reference: HTTPRoute):
 | 
			
		||||
        return self.api.delete_namespaced_custom_object(
 | 
			
		||||
            group=self.crd_group,
 | 
			
		||||
            version=self.crd_version,
 | 
			
		||||
            plural=self.crd_plural,
 | 
			
		||||
            namespace=self.namespace,
 | 
			
		||||
            name=self.name,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def retrieve(self) -> HTTPRoute:
 | 
			
		||||
        return from_dict(
 | 
			
		||||
            HTTPRoute,
 | 
			
		||||
            self.api.get_namespaced_custom_object(
 | 
			
		||||
                group=self.crd_group,
 | 
			
		||||
                version=self.crd_version,
 | 
			
		||||
                plural=self.crd_plural,
 | 
			
		||||
                namespace=self.namespace,
 | 
			
		||||
                name=self.name,
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def update(self, current: HTTPRoute, reference: HTTPRoute):
 | 
			
		||||
        return self.api.patch_namespaced_custom_object(
 | 
			
		||||
            group=self.crd_group,
 | 
			
		||||
            version=self.crd_version,
 | 
			
		||||
            plural=self.crd_plural,
 | 
			
		||||
            namespace=self.namespace,
 | 
			
		||||
            name=self.name,
 | 
			
		||||
            body=asdict(reference),
 | 
			
		||||
            field_manager=FIELD_MANAGER,
 | 
			
		||||
        )
 | 
			
		||||
@ -3,6 +3,7 @@
 | 
			
		||||
from authentik.outposts.controllers.base import DeploymentPort
 | 
			
		||||
from authentik.outposts.controllers.kubernetes import KubernetesController
 | 
			
		||||
from authentik.outposts.models import KubernetesServiceConnection, Outpost
 | 
			
		||||
from authentik.providers.proxy.controllers.k8s.httproute import HTTPRouteReconciler
 | 
			
		||||
from authentik.providers.proxy.controllers.k8s.ingress import IngressReconciler
 | 
			
		||||
from authentik.providers.proxy.controllers.k8s.traefik import TraefikMiddlewareReconciler
 | 
			
		||||
 | 
			
		||||
@ -18,8 +19,10 @@ class ProxyKubernetesController(KubernetesController):
 | 
			
		||||
            DeploymentPort(9443, "https", "tcp"),
 | 
			
		||||
        ]
 | 
			
		||||
        self.reconcilers[IngressReconciler.reconciler_name()] = IngressReconciler
 | 
			
		||||
        self.reconcilers[HTTPRouteReconciler.reconciler_name()] = HTTPRouteReconciler
 | 
			
		||||
        self.reconcilers[TraefikMiddlewareReconciler.reconciler_name()] = (
 | 
			
		||||
            TraefikMiddlewareReconciler
 | 
			
		||||
        )
 | 
			
		||||
        self.reconcile_order.append(IngressReconciler.reconciler_name())
 | 
			
		||||
        self.reconcile_order.append(HTTPRouteReconciler.reconciler_name())
 | 
			
		||||
        self.reconcile_order.append(TraefikMiddlewareReconciler.reconciler_name())
 | 
			
		||||
 | 
			
		||||
@ -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
 | 
			
		||||
 | 
			
		||||
@ -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)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -171,7 +171,8 @@ def username_field_validator_factory() -> Callable[[PromptChallengeResponse, str
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def password_single_validator_factory() -> Callable[[PromptChallengeResponse, str], Any]:
 | 
			
		||||
    """Return a `clean_` method for `field`. Clean method checks if username is taken already."""
 | 
			
		||||
    """Return a `clean_` method for `field`. Clean method checks if the password meets configured
 | 
			
		||||
    PasswordPolicy."""
 | 
			
		||||
 | 
			
		||||
    def password_single_clean(self: PromptChallengeResponse, value: str) -> Any:
 | 
			
		||||
        """Send password validation signals for e.g. LDAP Source"""
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,13 @@ from unittest.mock import patch
 | 
			
		||||
 | 
			
		||||
from django.urls import reverse
 | 
			
		||||
 | 
			
		||||
from authentik.core.models import USER_ATTRIBUTE_SOURCES, Group, Source, User, UserSourceConnection
 | 
			
		||||
from authentik.core.models import (
 | 
			
		||||
    USER_ATTRIBUTE_SOURCES,
 | 
			
		||||
    Group,
 | 
			
		||||
    Source,
 | 
			
		||||
    User,
 | 
			
		||||
    UserSourceConnection,
 | 
			
		||||
)
 | 
			
		||||
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION
 | 
			
		||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
 | 
			
		||||
from authentik.events.models import Event, EventAction
 | 
			
		||||
 | 
			
		||||
@ -3641,6 +3641,46 @@
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    {
 | 
			
		||||
                        "type": "object",
 | 
			
		||||
                        "required": [
 | 
			
		||||
                            "model",
 | 
			
		||||
                            "identifiers"
 | 
			
		||||
                        ],
 | 
			
		||||
                        "properties": {
 | 
			
		||||
                            "model": {
 | 
			
		||||
                                "const": "authentik_policies_unique_password.uniquepasswordpolicy"
 | 
			
		||||
                            },
 | 
			
		||||
                            "id": {
 | 
			
		||||
                                "type": "string"
 | 
			
		||||
                            },
 | 
			
		||||
                            "state": {
 | 
			
		||||
                                "type": "string",
 | 
			
		||||
                                "enum": [
 | 
			
		||||
                                    "absent",
 | 
			
		||||
                                    "present",
 | 
			
		||||
                                    "created",
 | 
			
		||||
                                    "must_created"
 | 
			
		||||
                                ],
 | 
			
		||||
                                "default": "present"
 | 
			
		||||
                            },
 | 
			
		||||
                            "conditions": {
 | 
			
		||||
                                "type": "array",
 | 
			
		||||
                                "items": {
 | 
			
		||||
                                    "type": "boolean"
 | 
			
		||||
                                }
 | 
			
		||||
                            },
 | 
			
		||||
                            "permissions": {
 | 
			
		||||
                                "$ref": "#/$defs/model_authentik_policies_unique_password.uniquepasswordpolicy_permissions"
 | 
			
		||||
                            },
 | 
			
		||||
                            "attrs": {
 | 
			
		||||
                                "$ref": "#/$defs/model_authentik_policies_unique_password.uniquepasswordpolicy"
 | 
			
		||||
                            },
 | 
			
		||||
                            "identifiers": {
 | 
			
		||||
                                "$ref": "#/$defs/model_authentik_policies_unique_password.uniquepasswordpolicy"
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    },
 | 
			
		||||
                    {
 | 
			
		||||
                        "type": "object",
 | 
			
		||||
                        "required": [
 | 
			
		||||
@ -4822,6 +4862,7 @@
 | 
			
		||||
                        "authentik.core",
 | 
			
		||||
                        "authentik.enterprise",
 | 
			
		||||
                        "authentik.enterprise.audit",
 | 
			
		||||
                        "authentik.enterprise.policies.unique_password",
 | 
			
		||||
                        "authentik.enterprise.providers.google_workspace",
 | 
			
		||||
                        "authentik.enterprise.providers.microsoft_entra",
 | 
			
		||||
                        "authentik.enterprise.providers.ssf",
 | 
			
		||||
@ -4929,6 +4970,7 @@
 | 
			
		||||
                        "authentik_core.applicationentitlement",
 | 
			
		||||
                        "authentik_core.token",
 | 
			
		||||
                        "authentik_enterprise.license",
 | 
			
		||||
                        "authentik_policies_unique_password.uniquepasswordpolicy",
 | 
			
		||||
                        "authentik_providers_google_workspace.googleworkspaceprovider",
 | 
			
		||||
                        "authentik_providers_google_workspace.googleworkspaceprovidermapping",
 | 
			
		||||
                        "authentik_providers_microsoft_entra.microsoftentraprovider",
 | 
			
		||||
@ -5623,11 +5665,6 @@
 | 
			
		||||
                    "maximum": 2147483647,
 | 
			
		||||
                    "title": "Order"
 | 
			
		||||
                },
 | 
			
		||||
                "honor_order": {
 | 
			
		||||
                    "type": "boolean",
 | 
			
		||||
                    "title": "Honor order",
 | 
			
		||||
                    "description": "Honor order when evaluating policies."
 | 
			
		||||
                },
 | 
			
		||||
                "timeout": {
 | 
			
		||||
                    "type": "integer",
 | 
			
		||||
                    "minimum": 0,
 | 
			
		||||
@ -7089,6 +7126,14 @@
 | 
			
		||||
                            "authentik_policies_reputation.delete_reputationpolicy",
 | 
			
		||||
                            "authentik_policies_reputation.view_reputation",
 | 
			
		||||
                            "authentik_policies_reputation.view_reputationpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.add_uniquepasswordpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.add_userpasswordhistory",
 | 
			
		||||
                            "authentik_policies_unique_password.change_uniquepasswordpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.change_userpasswordhistory",
 | 
			
		||||
                            "authentik_policies_unique_password.delete_uniquepasswordpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.delete_userpasswordhistory",
 | 
			
		||||
                            "authentik_policies_unique_password.view_uniquepasswordpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.view_userpasswordhistory",
 | 
			
		||||
                            "authentik_providers_google_workspace.add_googleworkspaceprovider",
 | 
			
		||||
                            "authentik_providers_google_workspace.add_googleworkspaceprovidergroup",
 | 
			
		||||
                            "authentik_providers_google_workspace.add_googleworkspaceprovidermapping",
 | 
			
		||||
@ -13789,6 +13834,14 @@
 | 
			
		||||
                            "authentik_policies_reputation.delete_reputationpolicy",
 | 
			
		||||
                            "authentik_policies_reputation.view_reputation",
 | 
			
		||||
                            "authentik_policies_reputation.view_reputationpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.add_uniquepasswordpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.add_userpasswordhistory",
 | 
			
		||||
                            "authentik_policies_unique_password.change_uniquepasswordpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.change_userpasswordhistory",
 | 
			
		||||
                            "authentik_policies_unique_password.delete_uniquepasswordpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.delete_userpasswordhistory",
 | 
			
		||||
                            "authentik_policies_unique_password.view_uniquepasswordpolicy",
 | 
			
		||||
                            "authentik_policies_unique_password.view_userpasswordhistory",
 | 
			
		||||
                            "authentik_providers_google_workspace.add_googleworkspaceprovider",
 | 
			
		||||
                            "authentik_providers_google_workspace.add_googleworkspaceprovidergroup",
 | 
			
		||||
                            "authentik_providers_google_workspace.add_googleworkspaceprovidermapping",
 | 
			
		||||
@ -14473,6 +14526,61 @@
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "model_authentik_policies_unique_password.uniquepasswordpolicy": {
 | 
			
		||||
            "type": "object",
 | 
			
		||||
            "properties": {
 | 
			
		||||
                "name": {
 | 
			
		||||
                    "type": "string",
 | 
			
		||||
                    "minLength": 1,
 | 
			
		||||
                    "title": "Name"
 | 
			
		||||
                },
 | 
			
		||||
                "execution_logging": {
 | 
			
		||||
                    "type": "boolean",
 | 
			
		||||
                    "title": "Execution logging",
 | 
			
		||||
                    "description": "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged."
 | 
			
		||||
                },
 | 
			
		||||
                "password_field": {
 | 
			
		||||
                    "type": "string",
 | 
			
		||||
                    "minLength": 1,
 | 
			
		||||
                    "title": "Password field",
 | 
			
		||||
                    "description": "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
                },
 | 
			
		||||
                "num_historical_passwords": {
 | 
			
		||||
                    "type": "integer",
 | 
			
		||||
                    "minimum": 0,
 | 
			
		||||
                    "maximum": 2147483647,
 | 
			
		||||
                    "title": "Num historical passwords",
 | 
			
		||||
                    "description": "Number of passwords to check against."
 | 
			
		||||
                }
 | 
			
		||||
            },
 | 
			
		||||
            "required": []
 | 
			
		||||
        },
 | 
			
		||||
        "model_authentik_policies_unique_password.uniquepasswordpolicy_permissions": {
 | 
			
		||||
            "type": "array",
 | 
			
		||||
            "items": {
 | 
			
		||||
                "type": "object",
 | 
			
		||||
                "required": [
 | 
			
		||||
                    "permission"
 | 
			
		||||
                ],
 | 
			
		||||
                "properties": {
 | 
			
		||||
                    "permission": {
 | 
			
		||||
                        "type": "string",
 | 
			
		||||
                        "enum": [
 | 
			
		||||
                            "add_uniquepasswordpolicy",
 | 
			
		||||
                            "change_uniquepasswordpolicy",
 | 
			
		||||
                            "delete_uniquepasswordpolicy",
 | 
			
		||||
                            "view_uniquepasswordpolicy"
 | 
			
		||||
                        ]
 | 
			
		||||
                    },
 | 
			
		||||
                    "user": {
 | 
			
		||||
                        "type": "integer"
 | 
			
		||||
                    },
 | 
			
		||||
                    "role": {
 | 
			
		||||
                        "type": "string"
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "model_authentik_providers_google_workspace.googleworkspaceprovider": {
 | 
			
		||||
            "type": "object",
 | 
			
		||||
            "properties": {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.mod
									
									
									
									
									
								
							@ -7,7 +7,7 @@ require (
 | 
			
		||||
	github.com/coreos/go-oidc/v3 v3.14.1
 | 
			
		||||
	github.com/getsentry/sentry-go v0.32.0
 | 
			
		||||
	github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
 | 
			
		||||
	github.com/go-ldap/ldap/v3 v3.4.10
 | 
			
		||||
	github.com/go-ldap/ldap/v3 v3.4.11
 | 
			
		||||
	github.com/go-openapi/runtime v0.28.0
 | 
			
		||||
	github.com/golang-jwt/jwt/v5 v5.2.2
 | 
			
		||||
	github.com/google/uuid v1.6.0
 | 
			
		||||
@ -27,7 +27,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.2025024.7
 | 
			
		||||
	goauthentik.io/api/v3 v3.2025024.9
 | 
			
		||||
	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
 | 
			
		||||
	golang.org/x/oauth2 v0.29.0
 | 
			
		||||
	golang.org/x/sync v0.13.0
 | 
			
		||||
@ -43,7 +43,7 @@ require (
 | 
			
		||||
	github.com/davecgh/go-spew v1.1.1 // indirect
 | 
			
		||||
	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 | 
			
		||||
	github.com/felixge/httpsnoop v1.0.3 // indirect
 | 
			
		||||
	github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
 | 
			
		||||
	github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
 | 
			
		||||
	github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect
 | 
			
		||||
	github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect
 | 
			
		||||
	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										82
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										82
									
								
								go.sum
									
									
									
									
									
								
							@ -71,8 +71,8 @@ github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBd
 | 
			
		||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 | 
			
		||||
github.com/getsentry/sentry-go v0.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY=
 | 
			
		||||
github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
 | 
			
		||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
 | 
			
		||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
 | 
			
		||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
 | 
			
		||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
 | 
			
		||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
 | 
			
		||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
 | 
			
		||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 | 
			
		||||
@ -86,8 +86,8 @@ github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9
 | 
			
		||||
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw=
 | 
			
		||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
 | 
			
		||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
 | 
			
		||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
 | 
			
		||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
 | 
			
		||||
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
 | 
			
		||||
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
 | 
			
		||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 | 
			
		||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
 | 
			
		||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 | 
			
		||||
@ -148,7 +148,6 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 | 
			
		||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 | 
			
		||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 | 
			
		||||
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 | 
			
		||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 | 
			
		||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 | 
			
		||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 | 
			
		||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
 | 
			
		||||
@ -172,16 +171,13 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE
 | 
			
		||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
 | 
			
		||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
 | 
			
		||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
 | 
			
		||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
 | 
			
		||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
 | 
			
		||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
 | 
			
		||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 | 
			
		||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
 | 
			
		||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
 | 
			
		||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 | 
			
		||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
 | 
			
		||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 | 
			
		||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 | 
			
		||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
 | 
			
		||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 | 
			
		||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 | 
			
		||||
@ -266,15 +262,10 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 | 
			
		||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 | 
			
		||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 | 
			
		||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 | 
			
		||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 | 
			
		||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 | 
			
		||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 | 
			
		||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 | 
			
		||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 | 
			
		||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 | 
			
		||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 | 
			
		||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 | 
			
		||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 | 
			
		||||
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
 | 
			
		||||
@ -282,7 +273,6 @@ github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0
 | 
			
		||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 | 
			
		||||
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
 | 
			
		||||
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
 | 
			
		||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 | 
			
		||||
@ -300,20 +290,14 @@ 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.2025024.7 h1:OOBuyLzv+l5rtvrOYzoDs6Hy9cIfkE5sewRqR5ThSRc=
 | 
			
		||||
goauthentik.io/api/v3 v3.2025024.7/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
 | 
			
		||||
goauthentik.io/api/v3 v3.2025024.9 h1:i3tbkyotE32ZpJ729BsPWTuLQUdtZ54Li4aP1amZzsM=
 | 
			
		||||
goauthentik.io/api/v3 v3.2025024.9/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=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 | 
			
		||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
 | 
			
		||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
 | 
			
		||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 | 
			
		||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 | 
			
		||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
 | 
			
		||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
 | 
			
		||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
 | 
			
		||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 | 
			
		||||
@ -348,11 +332,6 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
 | 
			
		||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 | 
			
		||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 | 
			
		||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 | 
			
		||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 | 
			
		||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 | 
			
		||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 | 
			
		||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 | 
			
		||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 | 
			
		||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 | 
			
		||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 | 
			
		||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 | 
			
		||||
@ -379,17 +358,8 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
 | 
			
		||||
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 | 
			
		||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 | 
			
		||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 | 
			
		||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 | 
			
		||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 | 
			
		||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 | 
			
		||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 | 
			
		||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 | 
			
		||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
 | 
			
		||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
 | 
			
		||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 | 
			
		||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 | 
			
		||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
 | 
			
		||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
 | 
			
		||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
 | 
			
		||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 | 
			
		||||
@ -406,12 +376,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
 | 
			
		||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 | 
			
		||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 | 
			
		||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 | 
			
		||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 | 
			
		||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
 | 
			
		||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 | 
			
		||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
@ -440,40 +404,14 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w
 | 
			
		||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 | 
			
		||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 | 
			
		||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 | 
			
		||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
 | 
			
		||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 | 
			
		||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
 | 
			
		||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 | 
			
		||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 | 
			
		||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 | 
			
		||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
 | 
			
		||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
 | 
			
		||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
 | 
			
		||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 | 
			
		||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
 | 
			
		||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
 | 
			
		||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 | 
			
		||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 | 
			
		||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 | 
			
		||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 | 
			
		||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 | 
			
		||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 | 
			
		||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 | 
			
		||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 | 
			
		||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
 | 
			
		||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
 | 
			
		||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 | 
			
		||||
@ -519,10 +457,6 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
 | 
			
		||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 | 
			
		||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 | 
			
		||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 | 
			
		||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 | 
			
		||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 | 
			
		||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
 | 
			
		||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										8
									
								
								lifecycle/aws/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								lifecycle/aws/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -9,7 +9,7 @@
 | 
			
		||||
            "version": "0.0.0",
 | 
			
		||||
            "license": "MIT",
 | 
			
		||||
            "devDependencies": {
 | 
			
		||||
                "aws-cdk": "^2.1010.0",
 | 
			
		||||
                "aws-cdk": "^2.1012.0",
 | 
			
		||||
                "cross-env": "^7.0.3"
 | 
			
		||||
            },
 | 
			
		||||
            "engines": {
 | 
			
		||||
@ -17,9 +17,9 @@
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
        "node_modules/aws-cdk": {
 | 
			
		||||
            "version": "2.1010.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1010.0.tgz",
 | 
			
		||||
            "integrity": "sha512-kYNzBXVUZoRrTuYxRRA2Loz/Uvay0MqHobg8KPZaWylIbw/meUDgtoATRNt+stOdJ9PHODTjWmlDKI+2/KoF+w==",
 | 
			
		||||
            "version": "2.1012.0",
 | 
			
		||||
            "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1012.0.tgz",
 | 
			
		||||
            "integrity": "sha512-C6jSWkqP0hkY2Cs300VJHjspmTXDTMfB813kwZvRbd/OsKBfTBJBbYU16VoLAp1LVEOnQMf8otSlaSgzVF0X9A==",
 | 
			
		||||
            "dev": true,
 | 
			
		||||
            "license": "Apache-2.0",
 | 
			
		||||
            "bin": {
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@
 | 
			
		||||
        "node": ">=20"
 | 
			
		||||
    },
 | 
			
		||||
    "devDependencies": {
 | 
			
		||||
        "aws-cdk": "^2.1010.0",
 | 
			
		||||
        "aws-cdk": "^2.1012.0",
 | 
			
		||||
        "cross-env": "^7.0.3"
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ from lifecycle.migrate import BaseMigration
 | 
			
		||||
 | 
			
		||||
SQL_STATEMENT = """
 | 
			
		||||
BEGIN TRANSACTION;
 | 
			
		||||
ALTER TABLE authentik_tenants_tenant RENAME TO authentik_brands_brand;
 | 
			
		||||
ALTER TABLE IF EXISTS authentik_tenants_tenant RENAME TO authentik_brands_brand;
 | 
			
		||||
UPDATE django_migrations SET app = replace(app, 'authentik_tenants', 'authentik_brands');
 | 
			
		||||
UPDATE django_content_type SET app_label = replace(app_label, 'authentik_tenants', 'authentik_brands');
 | 
			
		||||
COMMIT;
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PACKAGE VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: \n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-17 00:09+0000\n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-23 09:00+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"
 | 
			
		||||
@ -451,6 +451,36 @@ msgstr ""
 | 
			
		||||
msgid "License Usage Records"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Number of passwords to check against."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Password not set in context"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "This password has been used previously. Please choose a different one."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Password Uniqueness Policy"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Password Uniqueness Policies"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "User Password History"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policy.py
 | 
			
		||||
msgid "Enterprise required to access this feature."
 | 
			
		||||
msgstr ""
 | 
			
		||||
@ -1175,10 +1205,6 @@ msgstr ""
 | 
			
		||||
msgid "Clear Policy's cache metrics"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "How many times the password hash is allowed to be on haveibeenpwned"
 | 
			
		||||
msgstr ""
 | 
			
		||||
@ -1188,10 +1214,6 @@ msgid ""
 | 
			
		||||
"If the zxcvbn score is equal or less than this value, the policy will fail."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Password not set in context"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Invalid password."
 | 
			
		||||
msgstr ""
 | 
			
		||||
@ -1233,20 +1255,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 ""
 | 
			
		||||
@ -3144,6 +3152,12 @@ msgid ""
 | 
			
		||||
"info is entered."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/stages/identification/models.py
 | 
			
		||||
msgid ""
 | 
			
		||||
"Show the user the 'Remember me on this device' toggle, allowing repeat users "
 | 
			
		||||
"to skip straight to entering their password."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#: authentik/stages/identification/models.py
 | 
			
		||||
msgid "Optional enrollment flow, which is linked at the bottom of the page."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@ -9,8 +9,8 @@
 | 
			
		||||
# Kyllian Delaye-Maillot, 2023
 | 
			
		||||
# Manuel Viens, 2023
 | 
			
		||||
# Mordecai, 2023
 | 
			
		||||
# Tina, 2024
 | 
			
		||||
# Charles Leclerc, 2025
 | 
			
		||||
# Tina, 2025
 | 
			
		||||
# nerdinator <florian.dupret@gmail.com>, 2025
 | 
			
		||||
# Marc Schmitt, 2025
 | 
			
		||||
# 
 | 
			
		||||
@ -19,7 +19,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PACKAGE VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: \n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-15 00:11+0000\n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-23 09:00+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"
 | 
			
		||||
@ -502,6 +502,38 @@ msgstr "Utilisation de la licence"
 | 
			
		||||
msgid "License Usage Records"
 | 
			
		||||
msgstr "Registre d'utilisation de la licence"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Clé de champ à vérifier ; les clés de champ définies dans les étapes de "
 | 
			
		||||
"d'invite sont disponibles."
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Number of passwords to check against."
 | 
			
		||||
msgstr "Nombre de mots de passe à vérifier."
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Password not set in context"
 | 
			
		||||
msgstr "Mot de passe non défini dans le contexte"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "This password has been used previously. Please choose a different one."
 | 
			
		||||
msgstr "Ce mot de passe a déjà été utilisé. Veuillez en choisir un autre."
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Password Uniqueness Policy"
 | 
			
		||||
msgstr "Politique d'unicité des mots de passe"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Password Uniqueness Policies"
 | 
			
		||||
msgstr "Politiques d'unicité des mots de passe"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "User Password History"
 | 
			
		||||
msgstr "Historique des mots de passe utilisateur"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policy.py
 | 
			
		||||
msgid "Enterprise required to access this feature."
 | 
			
		||||
msgstr "Entreprise est requis pour accéder à cette fonctionnalité."
 | 
			
		||||
@ -1296,12 +1328,6 @@ msgstr "Voir les métriques de cache de la politique"
 | 
			
		||||
msgid "Clear Policy's cache metrics"
 | 
			
		||||
msgstr "Nettoyer les métriques de cache de la politique"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Clé de champ à vérifier ; les clés de champ définies dans les étapes de "
 | 
			
		||||
"d'invite sont disponibles."
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "How many times the password hash is allowed to be on haveibeenpwned"
 | 
			
		||||
msgstr ""
 | 
			
		||||
@ -1315,10 +1341,6 @@ msgstr ""
 | 
			
		||||
"Si le score zxcvbn est égal ou inférieur à cette valeur, la politique "
 | 
			
		||||
"échouera."
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Password not set in context"
 | 
			
		||||
msgstr "Mot de passe non défini dans le contexte"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Invalid password."
 | 
			
		||||
msgstr "Mot de passe invalide."
 | 
			
		||||
@ -1360,22 +1382,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"
 | 
			
		||||
@ -2508,6 +2514,14 @@ msgstr "Le mot de passe ne correspond pas à la complexité d'Active Directory."
 | 
			
		||||
msgid "No token received."
 | 
			
		||||
msgstr "Pas de jeton reçu."
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "HTTP Basic Authentication"
 | 
			
		||||
msgstr "Authentification HTTP Basic"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "Include the client ID and secret as request parameters"
 | 
			
		||||
msgstr "Inclure le client ID et secret comme paramètres de la requête"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "Request Token URL"
 | 
			
		||||
msgstr "URL du jeton de requête"
 | 
			
		||||
@ -2549,6 +2563,14 @@ msgstr ""
 | 
			
		||||
msgid "Additional Scopes"
 | 
			
		||||
msgstr "Portées additionnelles"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid ""
 | 
			
		||||
"How to perform authentication during an authorization_code token request "
 | 
			
		||||
"flow"
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Comment effectuer l'authentification lors d'une demande de jeton pour le "
 | 
			
		||||
"flux authorization_code"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "OAuth Source"
 | 
			
		||||
msgstr "Source OAuth"
 | 
			
		||||
@ -3469,6 +3491,15 @@ msgstr ""
 | 
			
		||||
"Lorsqu'activé, l'étape réussira et continuera même lorsque les informations "
 | 
			
		||||
"utilisateurs entrées sont invalides."
 | 
			
		||||
 | 
			
		||||
#: authentik/stages/identification/models.py
 | 
			
		||||
msgid ""
 | 
			
		||||
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
 | 
			
		||||
" to skip straight to entering their password."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Afficher à l'utilisateur l'option \"Se souvenir de moi sur cet appareil\", "
 | 
			
		||||
"afin de permettre aux utilisateurs réguliers de passer directement à la "
 | 
			
		||||
"saisie de leur mot de passe."
 | 
			
		||||
 | 
			
		||||
#: authentik/stages/identification/models.py
 | 
			
		||||
msgid "Optional enrollment flow, which is linked at the bottom of the page."
 | 
			
		||||
msgstr "Flux d'inscription facultatif, qui sera accessible en bas de page."
 | 
			
		||||
 | 
			
		||||
@ -15,7 +15,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PACKAGE VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: \n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-15 00:11+0000\n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-23 09:00+0000\n"
 | 
			
		||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
 | 
			
		||||
"Last-Translator: deluxghost, 2025\n"
 | 
			
		||||
"Language-Team: Chinese Simplified (https://app.transifex.com/authentik/teams/119923/zh-Hans/)\n"
 | 
			
		||||
@ -461,6 +461,36 @@ msgstr "许可证使用情况"
 | 
			
		||||
msgid "License Usage Records"
 | 
			
		||||
msgstr "许可证使用情况记录"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
msgstr "要检查的字段键,可以使用输入阶段中定义的字段键。"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Number of passwords to check against."
 | 
			
		||||
msgstr "检查指定数量的密码。"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Password not set in context"
 | 
			
		||||
msgstr "未在上下文中设置密码"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "This password has been used previously. Please choose a different one."
 | 
			
		||||
msgstr "此密码被使用过。请选择其他密码。"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Password Uniqueness Policy"
 | 
			
		||||
msgstr "密码唯一性策略"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Password Uniqueness Policies"
 | 
			
		||||
msgstr "密码唯一性策略"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "User Password History"
 | 
			
		||||
msgstr "用户密码历史记录"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policy.py
 | 
			
		||||
msgid "Enterprise required to access this feature."
 | 
			
		||||
msgstr "访问此功能需要企业版。"
 | 
			
		||||
@ -1190,10 +1220,6 @@ msgstr "查看策略缓存指标"
 | 
			
		||||
msgid "Clear Policy's cache metrics"
 | 
			
		||||
msgstr "清除策略缓存指标"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
msgstr "要检查的字段键,可以使用输入阶段中定义的字段键。"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "How many times the password hash is allowed to be on haveibeenpwned"
 | 
			
		||||
msgstr "密码哈希允许出现在 HaveIBeenPwned 中多少次"
 | 
			
		||||
@ -1203,10 +1229,6 @@ msgid ""
 | 
			
		||||
"If the zxcvbn score is equal or less than this value, the policy will fail."
 | 
			
		||||
msgstr "如果 zxcvbn 分数小于等于此值,则策略失败。"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Password not set in context"
 | 
			
		||||
msgstr "未在上下文中设置密码"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Invalid password."
 | 
			
		||||
msgstr "无效密码。"
 | 
			
		||||
@ -1248,20 +1270,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 "权限被拒绝"
 | 
			
		||||
@ -2286,6 +2294,14 @@ msgstr "密码与 Active Directory 复杂度不匹配。"
 | 
			
		||||
msgid "No token received."
 | 
			
		||||
msgstr "未收到令牌。"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "HTTP Basic Authentication"
 | 
			
		||||
msgstr "HTTP 基本身份验证"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "Include the client ID and secret as request parameters"
 | 
			
		||||
msgstr "包括客户端 ID 和密钥作为请求参数"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "Request Token URL"
 | 
			
		||||
msgstr "请求令牌 URL"
 | 
			
		||||
@ -2324,6 +2340,12 @@ msgstr "authentik 用来获取用户信息的 URL。"
 | 
			
		||||
msgid "Additional Scopes"
 | 
			
		||||
msgstr "额外的作用域"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid ""
 | 
			
		||||
"How to perform authentication during an authorization_code token request "
 | 
			
		||||
"flow"
 | 
			
		||||
msgstr "在 authorization_code 令牌请求流程期间,如何执行身份验证"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "OAuth Source"
 | 
			
		||||
msgstr "OAuth 源"
 | 
			
		||||
@ -3194,6 +3216,12 @@ msgid ""
 | 
			
		||||
"info is entered."
 | 
			
		||||
msgstr "启用时,即使输入错误的用户信息,此阶段也会成功并继续。"
 | 
			
		||||
 | 
			
		||||
#: authentik/stages/identification/models.py
 | 
			
		||||
msgid ""
 | 
			
		||||
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
 | 
			
		||||
" to skip straight to entering their password."
 | 
			
		||||
msgstr "向用户显示“在此设备上记住我”开关,允许相同用户直接跳过输入密码。"
 | 
			
		||||
 | 
			
		||||
#: authentik/stages/identification/models.py
 | 
			
		||||
msgid "Optional enrollment flow, which is linked at the bottom of the page."
 | 
			
		||||
msgstr "可选注册流程,链接在页面底部。"
 | 
			
		||||
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@ -14,7 +14,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PACKAGE VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: \n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-15 00:11+0000\n"
 | 
			
		||||
"POT-Creation-Date: 2025-04-23 09:00+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"
 | 
			
		||||
@ -460,6 +460,36 @@ msgstr "许可证使用情况"
 | 
			
		||||
msgid "License Usage Records"
 | 
			
		||||
msgstr "许可证使用情况记录"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
msgstr "要检查的字段键,可以使用输入阶段中定义的字段键。"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Number of passwords to check against."
 | 
			
		||||
msgstr "检查指定数量的密码。"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Password not set in context"
 | 
			
		||||
msgstr "未在上下文中设置密码"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "This password has been used previously. Please choose a different one."
 | 
			
		||||
msgstr "此密码被使用过。请选择其他密码。"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Password Uniqueness Policy"
 | 
			
		||||
msgstr "密码唯一性策略"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "Password Uniqueness Policies"
 | 
			
		||||
msgstr "密码唯一性策略"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policies/unique_password/models.py
 | 
			
		||||
msgid "User Password History"
 | 
			
		||||
msgstr "用户密码历史记录"
 | 
			
		||||
 | 
			
		||||
#: authentik/enterprise/policy.py
 | 
			
		||||
msgid "Enterprise required to access this feature."
 | 
			
		||||
msgstr "访问此功能需要企业版。"
 | 
			
		||||
@ -1189,10 +1219,6 @@ msgstr "查看策略缓存指标"
 | 
			
		||||
msgid "Clear Policy's cache metrics"
 | 
			
		||||
msgstr "清除策略缓存指标"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Field key to check, field keys defined in Prompt stages are available."
 | 
			
		||||
msgstr "要检查的字段键,可以使用输入阶段中定义的字段键。"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "How many times the password hash is allowed to be on haveibeenpwned"
 | 
			
		||||
msgstr "密码哈希允许出现在 HaveIBeenPwned 中多少次"
 | 
			
		||||
@ -1202,10 +1228,6 @@ msgid ""
 | 
			
		||||
"If the zxcvbn score is equal or less than this value, the policy will fail."
 | 
			
		||||
msgstr "如果 zxcvbn 分数小于等于此值,则策略失败。"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Password not set in context"
 | 
			
		||||
msgstr "未在上下文中设置密码"
 | 
			
		||||
 | 
			
		||||
#: authentik/policies/password/models.py
 | 
			
		||||
msgid "Invalid password."
 | 
			
		||||
msgstr "无效密码。"
 | 
			
		||||
@ -1247,20 +1269,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 "权限被拒绝"
 | 
			
		||||
@ -2285,6 +2293,14 @@ msgstr "密码与 Active Directory 复杂度不匹配。"
 | 
			
		||||
msgid "No token received."
 | 
			
		||||
msgstr "未收到令牌。"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "HTTP Basic Authentication"
 | 
			
		||||
msgstr "HTTP 基本身份验证"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "Include the client ID and secret as request parameters"
 | 
			
		||||
msgstr "包括客户端 ID 和密钥作为请求参数"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "Request Token URL"
 | 
			
		||||
msgstr "请求令牌 URL"
 | 
			
		||||
@ -2323,6 +2339,12 @@ msgstr "authentik 用来获取用户信息的 URL。"
 | 
			
		||||
msgid "Additional Scopes"
 | 
			
		||||
msgstr "额外的作用域"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid ""
 | 
			
		||||
"How to perform authentication during an authorization_code token request "
 | 
			
		||||
"flow"
 | 
			
		||||
msgstr "在 authorization_code 令牌请求流程期间,如何执行身份验证"
 | 
			
		||||
 | 
			
		||||
#: authentik/sources/oauth/models.py
 | 
			
		||||
msgid "OAuth Source"
 | 
			
		||||
msgstr "OAuth 源"
 | 
			
		||||
@ -3193,6 +3215,12 @@ msgid ""
 | 
			
		||||
"info is entered."
 | 
			
		||||
msgstr "启用时,即使输入错误的用户信息,此阶段也会成功并继续。"
 | 
			
		||||
 | 
			
		||||
#: authentik/stages/identification/models.py
 | 
			
		||||
msgid ""
 | 
			
		||||
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
 | 
			
		||||
" to skip straight to entering their password."
 | 
			
		||||
msgstr "向用户显示“在此设备上记住我”开关,允许相同用户直接跳过输入密码。"
 | 
			
		||||
 | 
			
		||||
#: authentik/stages/identification/models.py
 | 
			
		||||
msgid "Optional enrollment flow, which is linked at the bottom of the page."
 | 
			
		||||
msgstr "可选注册流程,链接在页面底部。"
 | 
			
		||||
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							@ -18,9 +18,7 @@
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.badge--support-community {
 | 
			
		||||
    --ifm-badge-background-color: var(
 | 
			
		||||
        --ifm-color-secondary-contrast-foreground
 | 
			
		||||
    );
 | 
			
		||||
    --ifm-badge-background-color: var(--ifm-color-secondary-contrast-foreground);
 | 
			
		||||
    --ifm-badge-border-color: var(--ifm-color-secondary-dark);
 | 
			
		||||
    --ifm-badge-color: var(--ifm-color-secondary-contrast-background);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,12 +1,12 @@
 | 
			
		||||
:root {
 | 
			
		||||
    --ifm-font-family-base:
 | 
			
		||||
        RedHatVF, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell,
 | 
			
		||||
        Noto Sans, sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial,
 | 
			
		||||
        sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
 | 
			
		||||
        RedHatVF, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans,
 | 
			
		||||
        sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif,
 | 
			
		||||
        "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
 | 
			
		||||
 | 
			
		||||
    --ifm-font-family-monospace:
 | 
			
		||||
        RedHatMonoVF, SFMono-Regular, Menlo, Monaco, Consolas,
 | 
			
		||||
        "Liberation Mono", "Courier New", monospace;
 | 
			
		||||
        RedHatMonoVF, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
 | 
			
		||||
        monospace;
 | 
			
		||||
 | 
			
		||||
    --ifm-heading-font-family: RedHatDisplayVF, var(--ifm-font-family-base);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -7,11 +7,7 @@
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.homepage_hero__subtitle p {
 | 
			
		||||
    font-size: clamp(
 | 
			
		||||
        1.125rem,
 | 
			
		||||
        0.9946rem + 0.6522vi,
 | 
			
		||||
        1.5rem
 | 
			
		||||
    ); /* Adjust font as page scales */
 | 
			
		||||
    font-size: clamp(1.125rem, 0.9946rem + 0.6522vi, 1.5rem); /* Adjust font as page scales */
 | 
			
		||||
    max-width: 28ch; /* Apply a maximum to keep everything in the box */
 | 
			
		||||
    text-wrap: balance; /* Prevent widows, orphans, and runts. Doesn't work in Safari */
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
:root {
 | 
			
		||||
    --ifm-menu-link-padding-vertical: 1em;
 | 
			
		||||
    --ifm-menu-link-padding-vertical: 0.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.menu__list-item {
 | 
			
		||||
 | 
			
		||||
@ -75,17 +75,14 @@
 | 
			
		||||
        --ifm-navbar-item-padding-horizontal: 1rem;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .docs-wrapper .navbar {
 | 
			
		||||
    .navbar {
 | 
			
		||||
        margin: 0;
 | 
			
		||||
        padding-inline-start: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .navbar__brand {
 | 
			
		||||
        justify-content: center;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .docs-wrapper .navbar__brand {
 | 
			
		||||
        width: var(--doc-sidebar-width);
 | 
			
		||||
        width: var(--doc-sidebar-width, 300px);
 | 
			
		||||
        margin: 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -122,12 +119,8 @@
 | 
			
		||||
 | 
			
		||||
        @media (min-width: 999px) {
 | 
			
		||||
            border-inline-start: 1px solid var(--ifm-hover-overlay);
 | 
			
		||||
            margin-inline-start: calc(
 | 
			
		||||
                var(--ifm-navbar-item-padding-horizontal) / 2
 | 
			
		||||
            );
 | 
			
		||||
            padding-inline-start: calc(
 | 
			
		||||
                var(--ifm-navbar-item-padding-horizontal) / 2
 | 
			
		||||
            );
 | 
			
		||||
            margin-inline-start: calc(var(--ifm-navbar-item-padding-horizontal) / 2);
 | 
			
		||||
            padding-inline-start: calc(var(--ifm-navbar-item-padding-horizontal) / 2);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -151,19 +144,14 @@
 | 
			
		||||
        hsl(236.84deg 34.55% 10.78%)
 | 
			
		||||
    );
 | 
			
		||||
    --docsearch-key-shadow:
 | 
			
		||||
        inset 0 -2px 0 0 hsl(233.33deg 36% 24.51%),
 | 
			
		||||
        inset 0 0 1px 1px hsl(232.11deg 34.86% 57.25%),
 | 
			
		||||
        inset 0 -2px 0 0 hsl(233.33deg 36% 24.51%), inset 0 0 1px 1px hsl(232.11deg 34.86% 57.25%),
 | 
			
		||||
        0 2px 2px 0 rgba(3, 4, 9, 0.3);
 | 
			
		||||
    --docsearch-key-pressed-shadow:
 | 
			
		||||
        inset 0 -2px 0 0 #282d55,
 | 
			
		||||
        inset 0 0 1px 1px hsl(231.82deg 21.36% 40.39%),
 | 
			
		||||
        inset 0 -2px 0 0 #282d55, inset 0 0 1px 1px hsl(231.82deg 21.36% 40.39%),
 | 
			
		||||
        0 1px 1px 0 hsl(230deg 50% 2.35% / 30.2%);
 | 
			
		||||
 | 
			
		||||
    padding: var(--ifm-navbar-item-padding-vertical)
 | 
			
		||||
        var(--ifm-navbar-item-padding-horizontal) !important;
 | 
			
		||||
    padding-inline-end: calc(
 | 
			
		||||
        var(--ifm-navbar-item-padding-horizontal) * 1.25
 | 
			
		||||
    ) !important;
 | 
			
		||||
    padding: var(--ifm-navbar-item-padding-vertical) var(--ifm-navbar-item-padding-horizontal) !important;
 | 
			
		||||
    padding-inline-end: calc(var(--ifm-navbar-item-padding-horizontal) * 1.25) !important;
 | 
			
		||||
 | 
			
		||||
    .DocSearch-Button-Placeholder {
 | 
			
		||||
        font-family: var(--ifm-heading-font-family);
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,3 @@
 | 
			
		||||
 | 
			
		||||
    --ifm-color-content: hsl(216 35% 3%);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
    overscroll-behavior-x: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -4,8 +4,8 @@
 | 
			
		||||
 * @import { Config as DocusaurusConfig } from "@docusaurus/types"
 | 
			
		||||
 * @import { UserThemeConfig } from "./theme.js"
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { deepmerge } from "deepmerge-ts";
 | 
			
		||||
 | 
			
		||||
import { createThemeConfig } from "./theme.js";
 | 
			
		||||
 | 
			
		||||
//#region Types
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,6 @@
 | 
			
		||||
 * @import { UserThemeConfig as UserThemeConfigCommon } from "@docusaurus/theme-common";
 | 
			
		||||
 * @import { UserThemeConfig as UserThemeConfigAlgolia } from "@docusaurus/theme-search-algolia";
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
import { deepmerge } from "deepmerge-ts";
 | 
			
		||||
import { themes as prismThemes } from "prism-react-renderer";
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								packages/docusaurus-config/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								packages/docusaurus-config/package-lock.json
									
									
									
										generated
									
									
									
								
							@ -1,12 +1,12 @@
 | 
			
		||||
{
 | 
			
		||||
    "name": "@goauthentik/docusaurus-config",
 | 
			
		||||
    "version": "1.0.2",
 | 
			
		||||
    "version": "1.0.5",
 | 
			
		||||
    "lockfileVersion": 3,
 | 
			
		||||
    "requires": true,
 | 
			
		||||
    "packages": {
 | 
			
		||||
        "": {
 | 
			
		||||
            "name": "@goauthentik/docusaurus-config",
 | 
			
		||||
            "version": "1.0.2",
 | 
			
		||||
            "version": "1.0.5",
 | 
			
		||||
            "license": "MIT",
 | 
			
		||||
            "dependencies": {
 | 
			
		||||
                "deepmerge-ts": "^7.1.5",
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
{
 | 
			
		||||
    "name": "@goauthentik/docusaurus-config",
 | 
			
		||||
    "version": "1.0.4",
 | 
			
		||||
    "version": "1.0.5",
 | 
			
		||||
    "description": "authentik's Docusaurus config",
 | 
			
		||||
    "license": "MIT",
 | 
			
		||||
    "scripts": {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										424
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										424
									
								
								schema.yml
									
									
									
									
									
								
							@ -12092,10 +12092,6 @@ paths:
 | 
			
		||||
        name: enabled
 | 
			
		||||
        schema:
 | 
			
		||||
          type: boolean
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: honor_order
 | 
			
		||||
        schema:
 | 
			
		||||
          type: boolean
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: order
 | 
			
		||||
        schema:
 | 
			
		||||
@ -14725,6 +14721,302 @@ paths:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
  /policies/unique_password/:
 | 
			
		||||
    get:
 | 
			
		||||
      operationId: policies_unique_password_list
 | 
			
		||||
      description: Password Uniqueness Policy Viewset
 | 
			
		||||
      parameters:
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: created
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: date-time
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: execution_logging
 | 
			
		||||
        schema:
 | 
			
		||||
          type: boolean
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: last_updated
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: date-time
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: name
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: num_historical_passwords
 | 
			
		||||
        schema:
 | 
			
		||||
          type: integer
 | 
			
		||||
      - name: ordering
 | 
			
		||||
        required: false
 | 
			
		||||
        in: query
 | 
			
		||||
        description: Which field to use when ordering the results.
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
      - name: page
 | 
			
		||||
        required: false
 | 
			
		||||
        in: query
 | 
			
		||||
        description: A page number within the paginated result set.
 | 
			
		||||
        schema:
 | 
			
		||||
          type: integer
 | 
			
		||||
      - name: page_size
 | 
			
		||||
        required: false
 | 
			
		||||
        in: query
 | 
			
		||||
        description: Number of results to return per page.
 | 
			
		||||
        schema:
 | 
			
		||||
          type: integer
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: password_field
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
      - in: query
 | 
			
		||||
        name: policy_uuid
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
      - name: search
 | 
			
		||||
        required: false
 | 
			
		||||
        in: query
 | 
			
		||||
        description: A search term.
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
      tags:
 | 
			
		||||
      - policies
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/PaginatedUniquePasswordPolicyList'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '400':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ValidationError'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
    post:
 | 
			
		||||
      operationId: policies_unique_password_create
 | 
			
		||||
      description: Password Uniqueness Policy Viewset
 | 
			
		||||
      tags:
 | 
			
		||||
      - policies
 | 
			
		||||
      requestBody:
 | 
			
		||||
        content:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/UniquePasswordPolicyRequest'
 | 
			
		||||
        required: true
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '201':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/UniquePasswordPolicy'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '400':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ValidationError'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
  /policies/unique_password/{policy_uuid}/:
 | 
			
		||||
    get:
 | 
			
		||||
      operationId: policies_unique_password_retrieve
 | 
			
		||||
      description: Password Uniqueness Policy Viewset
 | 
			
		||||
      parameters:
 | 
			
		||||
      - in: path
 | 
			
		||||
        name: policy_uuid
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
        description: A UUID string identifying this Password Uniqueness Policy.
 | 
			
		||||
        required: true
 | 
			
		||||
      tags:
 | 
			
		||||
      - policies
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/UniquePasswordPolicy'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '400':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ValidationError'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
    put:
 | 
			
		||||
      operationId: policies_unique_password_update
 | 
			
		||||
      description: Password Uniqueness Policy Viewset
 | 
			
		||||
      parameters:
 | 
			
		||||
      - in: path
 | 
			
		||||
        name: policy_uuid
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
        description: A UUID string identifying this Password Uniqueness Policy.
 | 
			
		||||
        required: true
 | 
			
		||||
      tags:
 | 
			
		||||
      - policies
 | 
			
		||||
      requestBody:
 | 
			
		||||
        content:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/UniquePasswordPolicyRequest'
 | 
			
		||||
        required: true
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/UniquePasswordPolicy'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '400':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ValidationError'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
    patch:
 | 
			
		||||
      operationId: policies_unique_password_partial_update
 | 
			
		||||
      description: Password Uniqueness Policy Viewset
 | 
			
		||||
      parameters:
 | 
			
		||||
      - in: path
 | 
			
		||||
        name: policy_uuid
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
        description: A UUID string identifying this Password Uniqueness Policy.
 | 
			
		||||
        required: true
 | 
			
		||||
      tags:
 | 
			
		||||
      - policies
 | 
			
		||||
      requestBody:
 | 
			
		||||
        content:
 | 
			
		||||
          application/json:
 | 
			
		||||
            schema:
 | 
			
		||||
              $ref: '#/components/schemas/PatchedUniquePasswordPolicyRequest'
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/UniquePasswordPolicy'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '400':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ValidationError'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
    delete:
 | 
			
		||||
      operationId: policies_unique_password_destroy
 | 
			
		||||
      description: Password Uniqueness Policy Viewset
 | 
			
		||||
      parameters:
 | 
			
		||||
      - in: path
 | 
			
		||||
        name: policy_uuid
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
        description: A UUID string identifying this Password Uniqueness Policy.
 | 
			
		||||
        required: true
 | 
			
		||||
      tags:
 | 
			
		||||
      - policies
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '204':
 | 
			
		||||
          description: No response body
 | 
			
		||||
        '400':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ValidationError'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
  /policies/unique_password/{policy_uuid}/used_by/:
 | 
			
		||||
    get:
 | 
			
		||||
      operationId: policies_unique_password_used_by_list
 | 
			
		||||
      description: Get a list of all objects that use this object
 | 
			
		||||
      parameters:
 | 
			
		||||
      - in: path
 | 
			
		||||
        name: policy_uuid
 | 
			
		||||
        schema:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
        description: A UUID string identifying this Password Uniqueness Policy.
 | 
			
		||||
        required: true
 | 
			
		||||
      tags:
 | 
			
		||||
      - policies
 | 
			
		||||
      security:
 | 
			
		||||
      - authentik: []
 | 
			
		||||
      responses:
 | 
			
		||||
        '200':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                type: array
 | 
			
		||||
                items:
 | 
			
		||||
                  $ref: '#/components/schemas/UsedBy'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '400':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/ValidationError'
 | 
			
		||||
          description: ''
 | 
			
		||||
        '403':
 | 
			
		||||
          content:
 | 
			
		||||
            application/json:
 | 
			
		||||
              schema:
 | 
			
		||||
                $ref: '#/components/schemas/GenericError'
 | 
			
		||||
          description: ''
 | 
			
		||||
  /propertymappings/all/:
 | 
			
		||||
    get:
 | 
			
		||||
      operationId: propertymappings_all_list
 | 
			
		||||
@ -24620,6 +24912,7 @@ paths:
 | 
			
		||||
          - authentik_policies_geoip.geoippolicy
 | 
			
		||||
          - authentik_policies_password.passwordpolicy
 | 
			
		||||
          - authentik_policies_reputation.reputationpolicy
 | 
			
		||||
          - authentik_policies_unique_password.uniquepasswordpolicy
 | 
			
		||||
          - authentik_providers_google_workspace.googleworkspaceprovider
 | 
			
		||||
          - authentik_providers_google_workspace.googleworkspaceprovidermapping
 | 
			
		||||
          - authentik_providers_ldap.ldapprovider
 | 
			
		||||
@ -24867,6 +25160,7 @@ paths:
 | 
			
		||||
          - authentik_policies_geoip.geoippolicy
 | 
			
		||||
          - authentik_policies_password.passwordpolicy
 | 
			
		||||
          - authentik_policies_reputation.reputationpolicy
 | 
			
		||||
          - authentik_policies_unique_password.uniquepasswordpolicy
 | 
			
		||||
          - authentik_providers_google_workspace.googleworkspaceprovider
 | 
			
		||||
          - authentik_providers_google_workspace.googleworkspaceprovidermapping
 | 
			
		||||
          - authentik_providers_ldap.ldapprovider
 | 
			
		||||
@ -40647,6 +40941,7 @@ components:
 | 
			
		||||
      - authentik.core
 | 
			
		||||
      - authentik.enterprise
 | 
			
		||||
      - authentik.enterprise.audit
 | 
			
		||||
      - authentik.enterprise.policies.unique_password
 | 
			
		||||
      - authentik.enterprise.providers.google_workspace
 | 
			
		||||
      - authentik.enterprise.providers.microsoft_entra
 | 
			
		||||
      - authentik.enterprise.providers.ssf
 | 
			
		||||
@ -48066,6 +48361,7 @@ components:
 | 
			
		||||
      - authentik_core.applicationentitlement
 | 
			
		||||
      - authentik_core.token
 | 
			
		||||
      - authentik_enterprise.license
 | 
			
		||||
      - authentik_policies_unique_password.uniquepasswordpolicy
 | 
			
		||||
      - authentik_providers_google_workspace.googleworkspaceprovider
 | 
			
		||||
      - authentik_providers_google_workspace.googleworkspaceprovidermapping
 | 
			
		||||
      - authentik_providers_microsoft_entra.microsoftentraprovider
 | 
			
		||||
@ -50620,6 +50916,18 @@ components:
 | 
			
		||||
      required:
 | 
			
		||||
      - pagination
 | 
			
		||||
      - results
 | 
			
		||||
    PaginatedUniquePasswordPolicyList:
 | 
			
		||||
      type: object
 | 
			
		||||
      properties:
 | 
			
		||||
        pagination:
 | 
			
		||||
          $ref: '#/components/schemas/Pagination'
 | 
			
		||||
        results:
 | 
			
		||||
          type: array
 | 
			
		||||
          items:
 | 
			
		||||
            $ref: '#/components/schemas/UniquePasswordPolicy'
 | 
			
		||||
      required:
 | 
			
		||||
      - pagination
 | 
			
		||||
      - results
 | 
			
		||||
    PaginatedUserAssignedObjectPermissionList:
 | 
			
		||||
      type: object
 | 
			
		||||
      properties:
 | 
			
		||||
@ -53315,9 +53623,6 @@ components:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: -2147483648
 | 
			
		||||
        honor_order:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Honor order when evaluating policies.
 | 
			
		||||
        timeout:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
@ -54232,6 +54537,27 @@ components:
 | 
			
		||||
          nullable: true
 | 
			
		||||
        expiring:
 | 
			
		||||
          type: boolean
 | 
			
		||||
    PatchedUniquePasswordPolicyRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: Password Uniqueness Policy Serializer
 | 
			
		||||
      properties:
 | 
			
		||||
        name:
 | 
			
		||||
          type: string
 | 
			
		||||
          minLength: 1
 | 
			
		||||
        execution_logging:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: When this option is enabled, all executions of this policy
 | 
			
		||||
            will be logged. By default, only execution errors are logged.
 | 
			
		||||
        password_field:
 | 
			
		||||
          type: string
 | 
			
		||||
          minLength: 1
 | 
			
		||||
          description: Field key to check, field keys defined in Prompt stages are
 | 
			
		||||
            available.
 | 
			
		||||
        num_historical_passwords:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: Number of passwords to check against.
 | 
			
		||||
    PatchedUserDeleteStageRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: UserDeleteStage Serializer
 | 
			
		||||
@ -54887,9 +55213,6 @@ components:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: -2147483648
 | 
			
		||||
        honor_order:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Honor order when evaluating policies.
 | 
			
		||||
        timeout:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
@ -54932,9 +55255,6 @@ components:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: -2147483648
 | 
			
		||||
        honor_order:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Honor order when evaluating policies.
 | 
			
		||||
        timeout:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
@ -59196,9 +59516,6 @@ components:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: -2147483648
 | 
			
		||||
        honor_order:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: Honor order when evaluating policies.
 | 
			
		||||
        timeout:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
@ -59237,6 +59554,81 @@ components:
 | 
			
		||||
      - light
 | 
			
		||||
      - dark
 | 
			
		||||
      type: string
 | 
			
		||||
    UniquePasswordPolicy:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: Password Uniqueness Policy Serializer
 | 
			
		||||
      properties:
 | 
			
		||||
        pk:
 | 
			
		||||
          type: string
 | 
			
		||||
          format: uuid
 | 
			
		||||
          readOnly: true
 | 
			
		||||
          title: Policy uuid
 | 
			
		||||
        name:
 | 
			
		||||
          type: string
 | 
			
		||||
        execution_logging:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: When this option is enabled, all executions of this policy
 | 
			
		||||
            will be logged. By default, only execution errors are logged.
 | 
			
		||||
        component:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: Get object component so that we know how to edit the object
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        verbose_name:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: Return object's verbose_name
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        verbose_name_plural:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: Return object's plural verbose_name
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        meta_model_name:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: Return internal model name
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        bound_to:
 | 
			
		||||
          type: integer
 | 
			
		||||
          description: Return objects policy is bound to
 | 
			
		||||
          readOnly: true
 | 
			
		||||
        password_field:
 | 
			
		||||
          type: string
 | 
			
		||||
          description: Field key to check, field keys defined in Prompt stages are
 | 
			
		||||
            available.
 | 
			
		||||
        num_historical_passwords:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: Number of passwords to check against.
 | 
			
		||||
      required:
 | 
			
		||||
      - bound_to
 | 
			
		||||
      - component
 | 
			
		||||
      - meta_model_name
 | 
			
		||||
      - name
 | 
			
		||||
      - pk
 | 
			
		||||
      - verbose_name
 | 
			
		||||
      - verbose_name_plural
 | 
			
		||||
    UniquePasswordPolicyRequest:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: Password Uniqueness Policy Serializer
 | 
			
		||||
      properties:
 | 
			
		||||
        name:
 | 
			
		||||
          type: string
 | 
			
		||||
          minLength: 1
 | 
			
		||||
        execution_logging:
 | 
			
		||||
          type: boolean
 | 
			
		||||
          description: When this option is enabled, all executions of this policy
 | 
			
		||||
            will be logged. By default, only execution errors are logged.
 | 
			
		||||
        password_field:
 | 
			
		||||
          type: string
 | 
			
		||||
          minLength: 1
 | 
			
		||||
          description: Field key to check, field keys defined in Prompt stages are
 | 
			
		||||
            available.
 | 
			
		||||
        num_historical_passwords:
 | 
			
		||||
          type: integer
 | 
			
		||||
          maximum: 2147483647
 | 
			
		||||
          minimum: 0
 | 
			
		||||
          description: Number of passwords to check against.
 | 
			
		||||
      required:
 | 
			
		||||
      - name
 | 
			
		||||
    UsedBy:
 | 
			
		||||
      type: object
 | 
			
		||||
      description: A list of all objects referencing the queried object
 | 
			
		||||
 | 
			
		||||
@ -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,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
@ -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],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										70
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										70
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							@ -379,11 +379,11 @@ wheels = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "automat"
 | 
			
		||||
version = "24.8.1"
 | 
			
		||||
version = "25.4.16"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/2d/ede4ad7fc34ab4482389fa3369d304f2fa22e50770af706678f6a332fa82/automat-24.8.1.tar.gz", hash = "sha256:b34227cf63f6325b8ad2399ede780675083e439b20c323d376373d8ee6306d88", size = 128679 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/af/cc/55a32a2c98022d88812b5986d2a92c4ff3ee087e83b712ebc703bba452bf/Automat-24.8.1-py3-none-any.whl", hash = "sha256:bf029a7bc3da1e2c24da2343e7598affaa9f10bf0ab63ff808566ce90551e02a", size = 42585 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@ -558,30 +558,30 @@ wheels = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "boto3"
 | 
			
		||||
version = "1.37.34"
 | 
			
		||||
version = "1.37.35"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "botocore" },
 | 
			
		||||
    { name = "jmespath" },
 | 
			
		||||
    { name = "s3transfer" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/39/5d/6b1ca20ba4da350799509a69f2d295ae11d5ec08a98e82f74b5708a8180c/boto3-1.37.34.tar.gz", hash = "sha256:94ca07328474db3fa605eb99b011512caa73f7161740d365a1f00cfebfb6dd90", size = 111701 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/48/5f/e356ecd2f236e6ddc7711eaf3f075c15b13e2d044cfdb47719d49c4ae7dd/boto3-1.37.35.tar.gz", hash = "sha256:751ed599c8fd9ca24896edcd6620e8a32b3db1b68efea3a90126312240e668a2", size = 111640 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cb/2e/ad43d1e87d46d11dcf4104f97b9a7f6beb38a52a0e752edfadf3eb8b6e38/boto3-1.37.34-py3-none-any.whl", hash = "sha256:586bfa72a00601c04067f9adcbb08ecaf63b05b7d731103f33cb2ce0d6950b1b", size = 139920 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f6/e4/00958f65ac74ab0a76af33f16c8fdf5726a5c6f0d3c0d0c058ff0dd00fd7/boto3-1.37.35-py3-none-any.whl", hash = "sha256:5a90d674830adbaf86456d6b27a18f5f11378277da5286511fa860d2e7b14261", size = 139922 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "botocore"
 | 
			
		||||
version = "1.37.34"
 | 
			
		||||
version = "1.37.35"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "jmespath" },
 | 
			
		||||
    { name = "python-dateutil" },
 | 
			
		||||
    { name = "urllib3" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/60/9ec251a0e2d3994f3eac8bd9741576757c3aad189abbdec8fab6011f5a1a/botocore-1.37.34.tar.gz", hash = "sha256:2909b6dbf9c90347c71a6fa0364acee522d6a7664f13d6f7996c9dd1b1f46fac", size = 13817141 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/64/0b/d281d74d53f7d4733402aed7a536275084fa344a2672f7ea4dbc8ebe1f1b/botocore-1.37.35.tar.gz", hash = "sha256:197a9bf8251c45b9d882c405ec0d0ab40c10e2d2a55ee66960185daec4beb6ec", size = 13821053 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e8/51/19fff717cc5000708c4ce3d081bb0e63ca117c6823975b33101d52fdd9f5/botocore-1.37.34-py3-none-any.whl", hash = "sha256:bd9af0db1097befd2028ba8525e32cacc04f26ccb9dbd5d48d6ecd05bc16c27a", size = 13483679 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/22/00/bf9c894f5af8e35b06ecf757d4a95883408e71c48642dc7f8760580584fd/botocore-1.37.35-py3-none-any.whl", hash = "sha256:50839212e90650d0b0fa6b8f7514876bf802f6164f2775f3abcd4d53c98bb73c", size = 13485892 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@ -1726,16 +1726,16 @@ wheels = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "kombu"
 | 
			
		||||
version = "5.5.2"
 | 
			
		||||
version = "5.5.3"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "amqp" },
 | 
			
		||||
    { name = "tzdata" },
 | 
			
		||||
    { name = "vine" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/c8/12/7a340f48920f30d6febb65d0c4aca70ed01b29e116131152977df78a9a39/kombu-5.5.2.tar.gz", hash = "sha256:2dd27ec84fd843a4e0a7187424313f87514b344812cb98c25daddafbb6a7ff0e", size = 461522 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/60/0a/128b65651ed8120460fc5af754241ad595eac74993115ec0de4f2d7bc459/kombu-5.5.3.tar.gz", hash = "sha256:021a0e11fcfcd9b0260ef1fb64088c0e92beb976eb59c1dfca7ddd4ad4562ea2", size = 461784 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/af/ba/939f3db0fca87715c883e42cc93045347d61a9d519c270a38e54a06db6e1/kombu-5.5.2-py3-none-any.whl", hash = "sha256:40f3674ed19603b8a771b6c74de126dbf8879755a0337caac6602faa82d539cd", size = 209763 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/5d/35/1407fb0b2f5b07b50cbaf97fce09ad87d3bfefbf64f7171a8651cd8d2f68/kombu-5.5.3-py3-none-any.whl", hash = "sha256:5b0dbceb4edee50aa464f59469d34b97864be09111338cfb224a10b6a163909b", size = 209921 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@ -3185,15 +3185,15 @@ socks = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "uvicorn"
 | 
			
		||||
version = "0.34.1"
 | 
			
		||||
version = "0.34.2"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "click" },
 | 
			
		||||
    { name = "h11" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/86/37/dd92f1f9cedb5eaf74d9999044306e06abe65344ff197864175dbbd91871/uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc", size = 76755 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/5f/38/a5801450940a858c102a7ad9e6150146a25406a119851c993148d56ab041/uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065", size = 62404 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.optional-dependencies]
 | 
			
		||||
@ -3382,33 +3382,33 @@ wheels = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "yarl"
 | 
			
		||||
version = "1.19.0"
 | 
			
		||||
version = "1.20.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "idna" },
 | 
			
		||||
    { name = "multidict" },
 | 
			
		||||
    { name = "propcache" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/4d/8a8f57caccce49573e567744926f88c6ab3ca0b47a257806d1cf88584c5f/yarl-1.19.0.tar.gz", hash = "sha256:01e02bb80ae0dbed44273c304095295106e1d9470460e773268a27d11e594892", size = 184396 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/62/51/c0edba5219027f6eab262e139f73e2417b0f4efffa23bf562f6e18f76ca5/yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", size = 185258 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b8/70/44ef8f69d61cb5123167a4dda87f6c739a833fbdb2ed52960b4e8409d65c/yarl-1.19.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b687c334da3ff8eab848c9620c47a253d005e78335e9ce0d6868ed7e8fd170b", size = 146855 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c3/94/38c14d6c8217cc818647689f2dd647b976ced8fea08d0ac84e3c8168252b/yarl-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b0fe766febcf523a2930b819c87bb92407ae1368662c1bc267234e79b20ff894", size = 97523 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/35/a5/43a613586a6255105c4655a911c307ef3420e49e540d6ae2c5829863fb25/yarl-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:742ceffd3c7beeb2b20d47cdb92c513eef83c9ef88c46829f88d5b06be6734ee", size = 95540 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d4/60/ed26049f4a8b06ebfa6d5f3cb6a51b152fd57081aa818b6497474f65a631/yarl-1.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2af682a1e97437382ee0791eacbf540318bd487a942e068e7e0a6c571fadbbd3", size = 344386 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/49/a6/b84899cab411f49af5986cfb44b514040788d81c8084f5811e6a7c0f1ce6/yarl-1.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63702f1a098d0eaaea755e9c9d63172be1acb9e2d4aeb28b187092bcc9ca2d17", size = 338889 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cc/ce/0704f7166a781b1f81bdd45c4f49eadbae0230ebd35b9ec7cd7769d3a6ff/yarl-1.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3560dcba3c71ae7382975dc1e912ee76e50b4cd7c34b454ed620d55464f11876", size = 353107 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/75/e5/0ecd6f2a9cc4264c16d8dfb0d3d71ba8d03cb58f3bcd42b1df4358331189/yarl-1.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68972df6a0cc47c8abaf77525a76ee5c5f6ea9bbdb79b9565b3234ded3c5e675", size = 353128 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ad/c7/cd0fd1de581f1c2e8f996e704c9fd979e00106f18eebd91b0173cf1a13c6/yarl-1.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5684e7ff93ea74e47542232bd132f608df4d449f8968fde6b05aaf9e08a140f9", size = 349107 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e6/34/ba3e5a20bd1d6a09034fc7985aaf1309976f2a7a5aefd093c9e56f6e1e0c/yarl-1.19.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8182ad422bfacdebd4759ce3adc6055c0c79d4740aea1104e05652a81cd868c6", size = 335144 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1e/98/d9b7beb932fade015906efe0980aa7d522b8f93cf5ebf1082e74faa314b7/yarl-1.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aee5b90a5a9b71ac57400a7bdd0feaa27c51e8f961decc8d412e720a004a1791", size = 360795 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/9a/11/70b8770039cc54af5948970591517a1e1d093df3f04f328c655c9a0fefb7/yarl-1.19.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8c0b2371858d5a814b08542d5d548adb03ff2d7ab32f23160e54e92250961a72", size = 360140 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d4/67/708e3e36fafc4d9d96b4eecc6c8b9f37c8ad50df8a16c7a1d5ba9df53050/yarl-1.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd430c2b7df4ae92498da09e9b12cad5bdbb140d22d138f9e507de1aa3edfea3", size = 364431 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c3/8b/937fbbcc895553a7e16fcd86ae4e0724c6ac9468237ad8e7c29cc3b1c9d9/yarl-1.19.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a93208282c0ccdf73065fd76c6c129bd428dba5ff65d338ae7d2ab27169861a0", size = 373832 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f8/ca/288ddc2230c9b6647fe907504f1119adb41252ac533eb564d3fc73511215/yarl-1.19.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b8179280cdeb4c36eb18d6534a328f9d40da60d2b96ac4a295c5f93e2799e9d9", size = 378122 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/4f/5a/79e1ef31d14968fbfc0ecec70a6683b574890d9c7550c376dd6d40de7754/yarl-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eda3c2b42dc0c389b7cfda2c4df81c12eeb552019e0de28bde8f913fc3d1fcf3", size = 375178 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/95/38/9b0e56bf14026c3f550ad6425679f6d1a2f4821d70767f39d6f4c56a0820/yarl-1.19.0-cp312-cp312-win32.whl", hash = "sha256:57f3fed859af367b9ca316ecc05ce79ce327d6466342734305aa5cc380e4d8be", size = 86172 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b3/96/5c2f3987c4bb4e5cdebea3caf99a45946b13a9516f849c02222203d99860/yarl-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:5507c1f7dd3d41251b67eecba331c8b2157cfd324849879bebf74676ce76aff7", size = 92617 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a4/06/ae25a353e8f032322df6f30d6bb1fc329773ee48e1a80a2196ccb8d1206b/yarl-1.19.0-py3-none-any.whl", hash = "sha256:a727101eb27f66727576630d02985d8a065d09cd0b5fcbe38a5793f71b2a97ef", size = 45990 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c3/e8/3efdcb83073df978bb5b1a9cc0360ce596680e6c3fac01f2a994ccbb8939/yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", size = 147089 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/60/c3/9e776e98ea350f76f94dd80b408eaa54e5092643dbf65fd9babcffb60509/yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", size = 97706 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/0c/5b/45cdfb64a3b855ce074ae607b9fc40bc82e7613b94e7612b030255c93a09/yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", size = 95719 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/2d/4e/929633b249611eeed04e2f861a14ed001acca3ef9ec2a984a757b1515889/yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", size = 343972 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/49/fd/047535d326c913f1a90407a3baf7ff535b10098611eaef2c527e32e81ca1/yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", size = 339639 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/48/2f/11566f1176a78f4bafb0937c0072410b1b0d3640b297944a6a7a556e1d0b/yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", size = 353745 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/26/17/07dfcf034d6ae8837b33988be66045dd52f878dfb1c4e8f80a7343f677be/yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", size = 354178 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/15/45/212604d3142d84b4065d5f8cab6582ed3d78e4cc250568ef2a36fe1cf0a5/yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", size = 349219 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e6/e0/a10b30f294111c5f1c682461e9459935c17d467a760c21e1f7db400ff499/yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", size = 337266 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/33/a6/6efa1d85a675d25a46a167f9f3e80104cde317dfdf7f53f112ae6b16a60a/yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", size = 360873 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/77/67/c8ab718cb98dfa2ae9ba0f97bf3cbb7d45d37f13fe1fbad25ac92940954e/yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", size = 360524 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/bd/e8/c3f18660cea1bc73d9f8a2b3ef423def8dadbbae6c4afabdb920b73e0ead/yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", size = 365370 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c9/99/33f3b97b065e62ff2d52817155a89cfa030a1a9b43fee7843ef560ad9603/yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", size = 373297 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3d/89/7519e79e264a5f08653d2446b26d4724b01198a93a74d2e259291d538ab1/yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", size = 378771 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3a/58/6c460bbb884abd2917c3eef6f663a4a873f8dc6f498561fc0ad92231c113/yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", size = 375000 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3b/2a/dd7ed1aa23fea996834278d7ff178f215b24324ee527df53d45e34d21d28/yarl-1.20.0-cp312-cp312-win32.whl", hash = "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64", size = 86355 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ca/c6/333fe0338305c0ac1c16d5aa7cc4841208d3252bbe62172e0051006b5445/yarl-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c", size = 92904 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ea/1f/70c57b3d7278e94ed22d85e09685d3f0a38ebdd8c5c73b65ba4c0d0fe002/yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", size = 46124 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
 | 
			
		||||
@ -1,23 +0,0 @@
 | 
			
		||||
{
 | 
			
		||||
    "arrowParens": "always",
 | 
			
		||||
    "bracketSpacing": true,
 | 
			
		||||
    "embeddedLanguageFormatting": "auto",
 | 
			
		||||
    "htmlWhitespaceSensitivity": "css",
 | 
			
		||||
    "insertPragma": false,
 | 
			
		||||
    "jsxSingleQuote": false,
 | 
			
		||||
    "printWidth": 100,
 | 
			
		||||
    "proseWrap": "preserve",
 | 
			
		||||
    "quoteProps": "consistent",
 | 
			
		||||
    "requirePragma": false,
 | 
			
		||||
    "semi": true,
 | 
			
		||||
    "singleQuote": false,
 | 
			
		||||
    "tabWidth": 4,
 | 
			
		||||
    "trailingComma": "all",
 | 
			
		||||
    "useTabs": false,
 | 
			
		||||
    "vueIndentScriptAndStyle": false,
 | 
			
		||||
    "plugins": ["@trivago/prettier-plugin-sort-imports"],
 | 
			
		||||
    "importOrder": ["^(@?)lit(.*)$", "\\.css$", "^@goauthentik/api$", "^[./]"],
 | 
			
		||||
    "importOrderSeparation": true,
 | 
			
		||||
    "importOrderSortSpecifiers": true,
 | 
			
		||||
    "importOrderParserPlugins": ["typescript", "jsx", "classProperties", "decorators-legacy"]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										850
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										850
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -12,8 +12,7 @@
 | 
			
		||||
        "@floating-ui/dom": "^1.6.11",
 | 
			
		||||
        "@formatjs/intl-listformat": "^7.5.7",
 | 
			
		||||
        "@fortawesome/fontawesome-free": "^6.6.0",
 | 
			
		||||
        "@goauthentik/api": "^2025.2.4-1744640358",
 | 
			
		||||
        "@lit-labs/ssr": "^3.2.2",
 | 
			
		||||
        "@goauthentik/api": "^2025.2.4-1745325566",
 | 
			
		||||
        "@lit/context": "^1.1.2",
 | 
			
		||||
        "@lit/localize": "^0.12.2",
 | 
			
		||||
        "@lit/reactive-element": "^2.0.4",
 | 
			
		||||
@ -54,6 +53,7 @@
 | 
			
		||||
        "remark-gfm": "^4.0.1",
 | 
			
		||||
        "remark-mdx-frontmatter": "^5.0.0",
 | 
			
		||||
        "style-mod": "^4.1.2",
 | 
			
		||||
        "trusted-types": "^2.0.0",
 | 
			
		||||
        "ts-pattern": "^5.4.0",
 | 
			
		||||
        "unist-util-visit": "^5.0.0",
 | 
			
		||||
        "webcomponent-qr-code": "^1.2.0",
 | 
			
		||||
@ -61,6 +61,9 @@
 | 
			
		||||
    },
 | 
			
		||||
    "devDependencies": {
 | 
			
		||||
        "@eslint/js": "^9.11.1",
 | 
			
		||||
        "@goauthentik/esbuild-plugin-live-reload": "^1.0.4",
 | 
			
		||||
        "@goauthentik/prettier-config": "^1.0.4",
 | 
			
		||||
        "@goauthentik/tsconfig": "^1.0.4",
 | 
			
		||||
        "@hcaptcha/types": "^1.0.4",
 | 
			
		||||
        "@lit/localize-tools": "^0.8.0",
 | 
			
		||||
        "@rollup/plugin-replace": "^6.0.1",
 | 
			
		||||
@ -72,7 +75,7 @@
 | 
			
		||||
        "@storybook/manager-api": "^8.3.4",
 | 
			
		||||
        "@storybook/web-components": "^8.3.4",
 | 
			
		||||
        "@storybook/web-components-vite": "^8.3.4",
 | 
			
		||||
        "@trivago/prettier-plugin-sort-imports": "^4.3.0",
 | 
			
		||||
        "@trivago/prettier-plugin-sort-imports": "^5.2.2",
 | 
			
		||||
        "@types/chart.js": "^2.9.41",
 | 
			
		||||
        "@types/codemirror": "^5.60.15",
 | 
			
		||||
        "@types/dompurify": "^3.0.5",
 | 
			
		||||
@ -95,7 +98,6 @@
 | 
			
		||||
        "eslint": "^9.11.1",
 | 
			
		||||
        "eslint-plugin-lit": "^1.15.0",
 | 
			
		||||
        "eslint-plugin-wc": "^2.1.1",
 | 
			
		||||
        "find-free-ports": "^3.1.1",
 | 
			
		||||
        "github-slugger": "^2.0.0",
 | 
			
		||||
        "glob": "^11.0.0",
 | 
			
		||||
        "globals": "^15.10.0",
 | 
			
		||||
@ -136,6 +138,7 @@
 | 
			
		||||
            "axios": "^1.8.4"
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    "prettier": "@goauthentik/prettier-config",
 | 
			
		||||
    "private": true,
 | 
			
		||||
    "scripts": {
 | 
			
		||||
        "build": "wireit",
 | 
			
		||||
@ -271,7 +274,7 @@
 | 
			
		||||
            "command": "tsc --noEmit -p ./tests"
 | 
			
		||||
        },
 | 
			
		||||
        "lint:types": {
 | 
			
		||||
            "command": "tsc --noEmit -p .",
 | 
			
		||||
            "command": "NODE_OPTIONS=\"--max-old-space-size=3000\" tsc -b .",
 | 
			
		||||
            "dependencies": [
 | 
			
		||||
                "build-locales",
 | 
			
		||||
                "lint:types:tests"
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,18 @@
 | 
			
		||||
/// <reference types="./types.js" />
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @file
 | 
			
		||||
 * Client-side observer for ESBuild events.
 | 
			
		||||
 * @file Client-side observer for ESBuild events.
 | 
			
		||||
 *
 | 
			
		||||
 * @import { Message as ESBuildMessage } from "esbuild";
 | 
			
		||||
 */
 | 
			
		||||
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;
 | 
			
		||||
/**
 | 
			
		||||
 * @template {unknown} [Data=unknown]
 | 
			
		||||
 * @typedef {(event: MessageEvent) => void} BuildEventListener
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A client-side watcher for ESBuild.
 | 
			
		||||
@ -16,14 +21,13 @@ type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
 | 
			
		||||
 * 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/client");
 | 
			
		||||
 *
 | 
			
		||||
 *   new ESBuildObserver(process.env.WATCHER_URL);
 | 
			
		||||
 * if (process.env.NODE_ENV === "development") {
 | 
			
		||||
 *   await import("@goauthentik/esbuild-plugin-live-reload/client")
 | 
			
		||||
 *     .catch(() => console.warn("Failed to import watcher"))
 | 
			
		||||
 * }
 | 
			
		||||
 * ```
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 *
 | 
			
		||||
 * @implements {Disposable}
 | 
			
		||||
 */
 | 
			
		||||
export class ESBuildObserver extends EventSource {
 | 
			
		||||
    /**
 | 
			
		||||
@ -58,15 +62,19 @@ export class ESBuildObserver extends EventSource {
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The interval for the keep-alive check.
 | 
			
		||||
     * @type {ReturnType<typeof setInterval> | undefined}
 | 
			
		||||
     */
 | 
			
		||||
    #keepAliveInterval: ReturnType<typeof setInterval> | undefined;
 | 
			
		||||
    #keepAliveInterval;
 | 
			
		||||
 | 
			
		||||
    #trackActivity = () => {
 | 
			
		||||
        this.lastUpdatedAt = Date.now();
 | 
			
		||||
        this.alive = true;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    #startListener: BuildEventListener = () => {
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {BuildEventListener}
 | 
			
		||||
     */
 | 
			
		||||
    #startListener = () => {
 | 
			
		||||
        this.#trackActivity();
 | 
			
		||||
        log("⏰  Build started...");
 | 
			
		||||
    };
 | 
			
		||||
@ -82,13 +90,18 @@ export class ESBuildObserver extends EventSource {
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    #errorListener: BuildEventListener<string> = (event) => {
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {BuildEventListener<string>}
 | 
			
		||||
     */
 | 
			
		||||
    #errorListener = (event) => {
 | 
			
		||||
        this.#trackActivity();
 | 
			
		||||
 | 
			
		||||
        // eslint-disable-next-line no-console
 | 
			
		||||
        console.group(logPrefix, "⛔️⛔️⛔️  Build error...");
 | 
			
		||||
 | 
			
		||||
        const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
 | 
			
		||||
        /**
 | 
			
		||||
         * @type {ESBuildMessage[]}
 | 
			
		||||
         */
 | 
			
		||||
        const esbuildErrorMessages = JSON.parse(event.data);
 | 
			
		||||
 | 
			
		||||
        for (const error of esbuildErrorMessages) {
 | 
			
		||||
            console.warn(error.text);
 | 
			
		||||
@ -101,11 +114,13 @@ export class ESBuildObserver extends EventSource {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // eslint-disable-next-line no-console
 | 
			
		||||
        console.groupEnd();
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    #endListener: BuildEventListener = () => {
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {BuildEventListener}
 | 
			
		||||
     */
 | 
			
		||||
    #endListener = () => {
 | 
			
		||||
        cancelAnimationFrame(this.#reloadFrameID);
 | 
			
		||||
 | 
			
		||||
        this.#trackActivity();
 | 
			
		||||
@ -126,12 +141,36 @@ export class ESBuildObserver extends EventSource {
 | 
			
		||||
        });
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    #keepAliveListener: BuildEventListener = () => {
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {BuildEventListener}
 | 
			
		||||
     */
 | 
			
		||||
    #keepAliveListener = () => {
 | 
			
		||||
        this.#trackActivity();
 | 
			
		||||
        log("🏓 Keep-alive");
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    constructor(url: string | URL) {
 | 
			
		||||
    /**
 | 
			
		||||
     * Initialize the ESBuild observer.
 | 
			
		||||
     * This should be called once in your application.
 | 
			
		||||
     *
 | 
			
		||||
     * @param {string | URL} [url]
 | 
			
		||||
     * @returns {ESBuildObserver}
 | 
			
		||||
     */
 | 
			
		||||
    static initialize = (url) => {
 | 
			
		||||
        const esbuildObserver = new ESBuildObserver(url);
 | 
			
		||||
 | 
			
		||||
        return esbuildObserver;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     *
 | 
			
		||||
     * @param {string | URL} [url]
 | 
			
		||||
     */
 | 
			
		||||
    constructor(url) {
 | 
			
		||||
        if (!url) {
 | 
			
		||||
            throw new TypeError("ESBuildObserver: Cannot construct without a URL");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        super(url);
 | 
			
		||||
 | 
			
		||||
        this.addEventListener("esbuild:start", this.#startListener);
 | 
			
		||||
@ -167,4 +206,14 @@ export class ESBuildObserver extends EventSource {
 | 
			
		||||
            log("👋  Waiting for build to start...");
 | 
			
		||||
        }, 15_000);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [Symbol.dispose]() {
 | 
			
		||||
        return this.close();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    dispose() {
 | 
			
		||||
        return this[Symbol.dispose]();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default ESBuildObserver;
 | 
			
		||||
							
								
								
									
										13
									
								
								web/packages/esbuild-plugin-live-reload/client/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/packages/esbuild-plugin-live-reload/client/index.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,13 @@
 | 
			
		||||
/// <reference types="./types.js" />
 | 
			
		||||
/**
 | 
			
		||||
 * @file Entry point for the ESBuild client-side observer.
 | 
			
		||||
 */
 | 
			
		||||
import { ESBuildObserver } from "./ESBuildObserver.js";
 | 
			
		||||
 | 
			
		||||
if (import.meta.env?.ESBUILD_WATCHER_URL) {
 | 
			
		||||
    const buildObserver = new ESBuildObserver(import.meta.env.ESBUILD_WATCHER_URL);
 | 
			
		||||
 | 
			
		||||
    window.addEventListener("beforeunload", () => {
 | 
			
		||||
        buildObserver.dispose();
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								web/packages/esbuild-plugin-live-reload/client/types.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/packages/esbuild-plugin-live-reload/client/types.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file Import meta environment variables available via ESBuild.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export {};
 | 
			
		||||
declare global {
 | 
			
		||||
    interface ImportMeta {
 | 
			
		||||
        readonly env: {
 | 
			
		||||
            /**
 | 
			
		||||
             * The injected watcher URL for ESBuild.
 | 
			
		||||
             * This is used for live reloading in development mode.
 | 
			
		||||
             *
 | 
			
		||||
             * @format url
 | 
			
		||||
             */
 | 
			
		||||
            ESBUILD_WATCHER_URL: string;
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										2
									
								
								web/packages/esbuild-plugin-live-reload/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								web/packages/esbuild-plugin-live-reload/index.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,2 @@
 | 
			
		||||
export * from "./client/index.js";
 | 
			
		||||
export * from "./plugin/index.js";
 | 
			
		||||
							
								
								
									
										53
									
								
								web/packages/esbuild-plugin-live-reload/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								web/packages/esbuild-plugin-live-reload/package.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,53 @@
 | 
			
		||||
{
 | 
			
		||||
    "name": "@goauthentik/esbuild-plugin-live-reload",
 | 
			
		||||
    "description": "ESBuild plugin to watch for file changes and trigger client-side reloads.",
 | 
			
		||||
    "version": "1.0.4",
 | 
			
		||||
    "dependencies": {
 | 
			
		||||
        "find-free-ports": "^3.1.1"
 | 
			
		||||
    },
 | 
			
		||||
    "devDependencies": {
 | 
			
		||||
        "@goauthentik/prettier-config": "^1.0.4",
 | 
			
		||||
        "@goauthentik/tsconfig": "^1.0.4",
 | 
			
		||||
        "@trivago/prettier-plugin-sort-imports": "^5.2.2",
 | 
			
		||||
        "@types/node": "^22.14.1",
 | 
			
		||||
        "esbuild": "^0.25.0",
 | 
			
		||||
        "prettier": "^3.3.3",
 | 
			
		||||
        "typescript": "^5.6.2"
 | 
			
		||||
    },
 | 
			
		||||
    "engines": {
 | 
			
		||||
        "node": ">=20.11"
 | 
			
		||||
    },
 | 
			
		||||
    "exports": {
 | 
			
		||||
        "./package.json": "./package.json",
 | 
			
		||||
        ".": {
 | 
			
		||||
            "types": "./out/index.d.ts",
 | 
			
		||||
            "import": "./index.js"
 | 
			
		||||
        },
 | 
			
		||||
        "./client": {
 | 
			
		||||
            "types": "./out/client/index.d.ts",
 | 
			
		||||
            "import": "./client/index.js"
 | 
			
		||||
        },
 | 
			
		||||
        "./plugin": {
 | 
			
		||||
            "types": "./out/plugin/index.d.ts",
 | 
			
		||||
            "import": "./plugin/index.js"
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    "files": [
 | 
			
		||||
        "./index.js",
 | 
			
		||||
        "client/**/*",
 | 
			
		||||
        "plugin/**/*",
 | 
			
		||||
        "out/**/*"
 | 
			
		||||
    ],
 | 
			
		||||
    "license": "MIT",
 | 
			
		||||
    "main": "index.js",
 | 
			
		||||
    "peerDependencies": {
 | 
			
		||||
        "esbuild": "^0.25.0"
 | 
			
		||||
    },
 | 
			
		||||
    "prettier": "@goauthentik/prettier-config",
 | 
			
		||||
    "private": true,
 | 
			
		||||
    "publishConfig": {
 | 
			
		||||
        "access": "public"
 | 
			
		||||
    },
 | 
			
		||||
    "type": "module",
 | 
			
		||||
    "types": "./out/index.d.ts"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										243
									
								
								web/packages/esbuild-plugin-live-reload/plugin/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										243
									
								
								web/packages/esbuild-plugin-live-reload/plugin/index.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,243 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file Live reload plugin for ESBuild.
 | 
			
		||||
 *
 | 
			
		||||
 * @import { ListenOptions } from "node:net";
 | 
			
		||||
 * @import {Server as HTTPServer} from "node:http";
 | 
			
		||||
 * @import {Server as HTTPSServer} from "node:https";
 | 
			
		||||
 */
 | 
			
		||||
import { findFreePorts } from "find-free-ports";
 | 
			
		||||
import * as http from "node:http";
 | 
			
		||||
import * as path from "node:path";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Serializes a custom event to a text stream.
 | 
			
		||||
 * @param {Event} event
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export function serializeCustomEventToStream(event) {
 | 
			
		||||
    // @ts-expect-error - TS doesn't know about the detail property
 | 
			
		||||
    const data = event.detail ?? {};
 | 
			
		||||
 | 
			
		||||
    const eventContent = [`event: ${event.type}`, `data: ${JSON.stringify(data)}`];
 | 
			
		||||
 | 
			
		||||
    return eventContent.join("\n") + "\n\n";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const MIN_PORT = 1025;
 | 
			
		||||
const MAX_PORT = 65535;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Find a random port that is not in use, sufficiently far from the default port.
 | 
			
		||||
 * @returns {Promise<number>}
 | 
			
		||||
 */
 | 
			
		||||
async function findDisparatePort() {
 | 
			
		||||
    const startPort = Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
 | 
			
		||||
 | 
			
		||||
    const wathcherPorts = await findFreePorts(1, {
 | 
			
		||||
        startPort,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const [port] = wathcherPorts;
 | 
			
		||||
 | 
			
		||||
    if (!port) {
 | 
			
		||||
        throw new Error("No free ports available");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return port;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Event server initialization options.
 | 
			
		||||
 *
 | 
			
		||||
 * @typedef {Object} EventServerInit
 | 
			
		||||
 *
 | 
			
		||||
 * @property {string} pathname
 | 
			
		||||
 * @property {EventTarget} dispatcher
 | 
			
		||||
 * @property {string} [logPrefix]
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @typedef {(req: http.IncomingMessage, res: http.ServerResponse) => void} RequestHandler
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Create an event request handler.
 | 
			
		||||
 * @param {EventServerInit} options
 | 
			
		||||
 * @returns {RequestHandler}
 | 
			
		||||
 * @category ESBuild
 | 
			
		||||
 */
 | 
			
		||||
export function createRequestHandler({ pathname, dispatcher, logPrefix = "Build Observer" }) {
 | 
			
		||||
    // eslint-disable-next-line no-console
 | 
			
		||||
    const log = console.log.bind(console, `[${logPrefix}]`);
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {RequestHandler}
 | 
			
		||||
     */
 | 
			
		||||
    const requestHandler = (req, res) => {
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Origin", "*");
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Methods", "GET");
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Headers", "Content-Type");
 | 
			
		||||
 | 
			
		||||
        if (req.url !== pathname) {
 | 
			
		||||
            log(`🚫 Invalid request to ${req.url}`);
 | 
			
		||||
            res.writeHead(404);
 | 
			
		||||
            res.end();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        log("🔌 Client connected");
 | 
			
		||||
 | 
			
		||||
        res.writeHead(200, {
 | 
			
		||||
            "Content-Type": "text/event-stream",
 | 
			
		||||
            "Cache-Control": "no-cache",
 | 
			
		||||
            "Connection": "keep-alive",
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * @param {Event} event
 | 
			
		||||
         */
 | 
			
		||||
        const listener = (event) => {
 | 
			
		||||
            const body = serializeCustomEventToStream(event);
 | 
			
		||||
 | 
			
		||||
            res.write(body);
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        dispatcher.addEventListener("esbuild:start", listener);
 | 
			
		||||
        dispatcher.addEventListener("esbuild:error", listener);
 | 
			
		||||
        dispatcher.addEventListener("esbuild:end", listener);
 | 
			
		||||
 | 
			
		||||
        req.on("close", () => {
 | 
			
		||||
            log("🔌 Client disconnected");
 | 
			
		||||
 | 
			
		||||
            clearInterval(keepAliveInterval);
 | 
			
		||||
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:start", listener);
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:error", listener);
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:end", listener);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const keepAliveInterval = setInterval(() => {
 | 
			
		||||
            console.timeStamp("🏓 Keep-alive");
 | 
			
		||||
 | 
			
		||||
            res.write("event: keep-alive\n\n");
 | 
			
		||||
            res.write(serializeCustomEventToStream(new CustomEvent("esbuild:keep-alive")));
 | 
			
		||||
        }, 15_000);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return requestHandler;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Options for the build observer plugin.
 | 
			
		||||
 *
 | 
			
		||||
 * @typedef {object} BuildObserverOptions
 | 
			
		||||
 *
 | 
			
		||||
 * @property {HTTPServer | HTTPSServer} [server]
 | 
			
		||||
 * @property {ListenOptions} [listenOptions]
 | 
			
		||||
 * @property {string | URL} [publicURL]
 | 
			
		||||
 * @property {string} [logPrefix]
 | 
			
		||||
 * @property {string} [relativeRoot]
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a plugin that listens for build events and sends them to a server-sent event stream.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {BuildObserverOptions} [options]
 | 
			
		||||
 * @returns {import('esbuild').Plugin}
 | 
			
		||||
 */
 | 
			
		||||
export function liveReloadPlugin(options = {}) {
 | 
			
		||||
    return {
 | 
			
		||||
        name: "build-watcher",
 | 
			
		||||
        setup: async (build) => {
 | 
			
		||||
            const logPrefix = options.logPrefix || "Build Observer";
 | 
			
		||||
 | 
			
		||||
            const timerLabel = `[${logPrefix}] 🏁`;
 | 
			
		||||
            const relativeRoot = options.relativeRoot || process.cwd();
 | 
			
		||||
 | 
			
		||||
            const dispatcher = new EventTarget();
 | 
			
		||||
 | 
			
		||||
            /**
 | 
			
		||||
             * @type {URL}
 | 
			
		||||
             */
 | 
			
		||||
            let publicURL;
 | 
			
		||||
 | 
			
		||||
            if (!options.publicURL) {
 | 
			
		||||
                const port = await findDisparatePort();
 | 
			
		||||
 | 
			
		||||
                publicURL = new URL(`http://localhost:${port}/events`);
 | 
			
		||||
            } else {
 | 
			
		||||
                publicURL =
 | 
			
		||||
                    typeof options.publicURL === "string"
 | 
			
		||||
                        ? new URL(options.publicURL)
 | 
			
		||||
                        : options.publicURL;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            build.initialOptions.define = {
 | 
			
		||||
                ...build.initialOptions.define,
 | 
			
		||||
                "import.meta.env.ESBUILD_WATCHER_URL": JSON.stringify(publicURL.href),
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            const requestHandler = createRequestHandler({
 | 
			
		||||
                pathname: publicURL.pathname,
 | 
			
		||||
                dispatcher,
 | 
			
		||||
                logPrefix,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            const server = options.server || http.createServer(requestHandler);
 | 
			
		||||
 | 
			
		||||
            const listenOptions = options.listenOptions || {
 | 
			
		||||
                port: parseInt(publicURL.port, 10),
 | 
			
		||||
                host: publicURL.hostname,
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            server.listen(listenOptions, () => {
 | 
			
		||||
                // eslint-disable-next-line no-console
 | 
			
		||||
                console.log(`[${logPrefix}] Listening`);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            build.onDispose(() => {
 | 
			
		||||
                server?.close();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            build.onStart(() => {
 | 
			
		||||
                console.time(timerLabel);
 | 
			
		||||
 | 
			
		||||
                dispatcher.dispatchEvent(
 | 
			
		||||
                    new CustomEvent("esbuild:start", {
 | 
			
		||||
                        detail: new Date().toISOString(),
 | 
			
		||||
                    }),
 | 
			
		||||
                );
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            build.onEnd((buildResult) => {
 | 
			
		||||
                console.timeEnd(timerLabel);
 | 
			
		||||
 | 
			
		||||
                if (!buildResult.errors.length) {
 | 
			
		||||
                    dispatcher.dispatchEvent(
 | 
			
		||||
                        new CustomEvent("esbuild:end", {
 | 
			
		||||
                            detail: new Date().toISOString(),
 | 
			
		||||
                        }),
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                console.warn(`Build ended with ${buildResult.errors.length} errors`);
 | 
			
		||||
 | 
			
		||||
                dispatcher.dispatchEvent(
 | 
			
		||||
                    new CustomEvent("esbuild:error", {
 | 
			
		||||
                        detail: buildResult.errors.map((error) => ({
 | 
			
		||||
                            ...error,
 | 
			
		||||
                            location: error.location
 | 
			
		||||
                                ? {
 | 
			
		||||
                                      ...error.location,
 | 
			
		||||
                                      file: path.resolve(relativeRoot, error.location.file),
 | 
			
		||||
                                  }
 | 
			
		||||
                                : null,
 | 
			
		||||
                        })),
 | 
			
		||||
                    }),
 | 
			
		||||
                );
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								web/packages/esbuild-plugin-live-reload/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								web/packages/esbuild-plugin-live-reload/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
			
		||||
{
 | 
			
		||||
    "extends": "@goauthentik/tsconfig",
 | 
			
		||||
    "compilerOptions": {
 | 
			
		||||
        "lib": ["ESNext", "DOM", "DOM.Iterable"],
 | 
			
		||||
        "resolveJsonModule": true,
 | 
			
		||||
        "baseUrl": ".",
 | 
			
		||||
        "checkJs": true,
 | 
			
		||||
        "emitDeclarationOnly": true
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@ -1,8 +1,13 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file ESBuild script for building the authentik web UI.
 | 
			
		||||
 *
 | 
			
		||||
 * @import { BuildOptions } from "esbuild";
 | 
			
		||||
 */
 | 
			
		||||
import { liveReloadPlugin } from "@goauthentik/esbuild-plugin-live-reload/plugin";
 | 
			
		||||
import { execFileSync } from "child_process";
 | 
			
		||||
import { deepmerge } from "deepmerge-ts";
 | 
			
		||||
import esbuild from "esbuild";
 | 
			
		||||
import { polyfillNode } from "esbuild-plugin-polyfill-node";
 | 
			
		||||
import findFreePorts from "find-free-ports";
 | 
			
		||||
import { copyFileSync, mkdirSync, readFileSync, statSync } from "fs";
 | 
			
		||||
import { globSync } from "glob";
 | 
			
		||||
import * as path from "path";
 | 
			
		||||
@ -11,7 +16,6 @@ import process from "process";
 | 
			
		||||
import { fileURLToPath } from "url";
 | 
			
		||||
 | 
			
		||||
import { mdxPlugin } from "./esbuild/build-mdx-plugin.mjs";
 | 
			
		||||
import { buildObserverPlugin } from "./esbuild/build-observer-plugin.mjs";
 | 
			
		||||
 | 
			
		||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
 | 
			
		||||
let authentikProjectRoot = path.join(__dirname, "..", "..");
 | 
			
		||||
@ -120,7 +124,7 @@ const BASE_ESBUILD_OPTIONS = {
 | 
			
		||||
    splitting: true,
 | 
			
		||||
    treeShaking: true,
 | 
			
		||||
    external: ["*.woff", "*.woff2"],
 | 
			
		||||
    tsconfig: "./tsconfig.json",
 | 
			
		||||
    tsconfig: path.resolve(__dirname, "..", "tsconfig.build.json"),
 | 
			
		||||
    loader: {
 | 
			
		||||
        ".css": "text",
 | 
			
		||||
    },
 | 
			
		||||
@ -220,26 +224,17 @@ function doHelp() {
 | 
			
		||||
async function doWatch() {
 | 
			
		||||
    console.log("Watching all entry points...");
 | 
			
		||||
 | 
			
		||||
    const wathcherPorts = await findFreePorts(entryPoints.length);
 | 
			
		||||
 | 
			
		||||
    const buildContexts = await Promise.all(
 | 
			
		||||
        entryPoints.map((entryPoint, i) => {
 | 
			
		||||
            const port = wathcherPorts[i];
 | 
			
		||||
            const serverURL = new URL(`http://localhost:${port}/events`);
 | 
			
		||||
 | 
			
		||||
        entryPoints.map((entryPoint) => {
 | 
			
		||||
            return esbuild.context(
 | 
			
		||||
                createEntryPointOptions(entryPoint, {
 | 
			
		||||
                    define: definitions,
 | 
			
		||||
                    plugins: [
 | 
			
		||||
                        buildObserverPlugin({
 | 
			
		||||
                            serverURL,
 | 
			
		||||
                            logPrefix: entryPoint[1],
 | 
			
		||||
                        liveReloadPlugin({
 | 
			
		||||
                            logPrefix: `Build Observer (${entryPoint[1]})`,
 | 
			
		||||
                            relativeRoot: path.join(__dirname, ".."),
 | 
			
		||||
                        }),
 | 
			
		||||
                    ],
 | 
			
		||||
                    define: {
 | 
			
		||||
                        ...definitions,
 | 
			
		||||
                        "process.env.WATCHER_URL": JSON.stringify(serverURL.toString()),
 | 
			
		||||
                    },
 | 
			
		||||
                }),
 | 
			
		||||
            );
 | 
			
		||||
        }),
 | 
			
		||||
 | 
			
		||||
@ -1,141 +0,0 @@
 | 
			
		||||
import * as http from "http";
 | 
			
		||||
import path from "path";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Serializes a custom event to a text stream.
 | 
			
		||||
 * a
 | 
			
		||||
 * @param {Event} event
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export function serializeCustomEventToStream(event) {
 | 
			
		||||
    // @ts-expect-error - TS doesn't know about the detail property
 | 
			
		||||
    const data = event.detail ?? {};
 | 
			
		||||
 | 
			
		||||
    const eventContent = [`event: ${event.type}`, `data: ${JSON.stringify(data)}`];
 | 
			
		||||
 | 
			
		||||
    return eventContent.join("\n") + "\n\n";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Options for the build observer plugin.
 | 
			
		||||
 *
 | 
			
		||||
 * @typedef {Object} BuildObserverOptions
 | 
			
		||||
 *
 | 
			
		||||
 * @property {URL} serverURL
 | 
			
		||||
 * @property {string} logPrefix
 | 
			
		||||
 * @property {string} relativeRoot
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates a plugin that listens for build events and sends them to a server-sent event stream.
 | 
			
		||||
 *
 | 
			
		||||
 * @param {BuildObserverOptions} options
 | 
			
		||||
 * @returns {import('esbuild').Plugin}
 | 
			
		||||
 */
 | 
			
		||||
export function buildObserverPlugin({ serverURL, logPrefix, relativeRoot }) {
 | 
			
		||||
    const timerLabel = `[${logPrefix}] Build`;
 | 
			
		||||
    const endpoint = serverURL.pathname;
 | 
			
		||||
    const dispatcher = new EventTarget();
 | 
			
		||||
 | 
			
		||||
    const eventServer = http.createServer((req, res) => {
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Origin", "*");
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Methods", "GET");
 | 
			
		||||
        res.setHeader("Access-Control-Allow-Headers", "Content-Type");
 | 
			
		||||
 | 
			
		||||
        if (req.url !== endpoint) {
 | 
			
		||||
            console.log(`🚫 Invalid request to ${req.url}`);
 | 
			
		||||
            res.writeHead(404);
 | 
			
		||||
            res.end();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        console.log("🔌 Client connected");
 | 
			
		||||
 | 
			
		||||
        res.writeHead(200, {
 | 
			
		||||
            "Content-Type": "text/event-stream",
 | 
			
		||||
            "Cache-Control": "no-cache",
 | 
			
		||||
            "Connection": "keep-alive",
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * @param {Event} event
 | 
			
		||||
         */
 | 
			
		||||
        const listener = (event) => {
 | 
			
		||||
            const body = serializeCustomEventToStream(event);
 | 
			
		||||
 | 
			
		||||
            res.write(body);
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        dispatcher.addEventListener("esbuild:start", listener);
 | 
			
		||||
        dispatcher.addEventListener("esbuild:error", listener);
 | 
			
		||||
        dispatcher.addEventListener("esbuild:end", listener);
 | 
			
		||||
 | 
			
		||||
        req.on("close", () => {
 | 
			
		||||
            console.log("🔌 Client disconnected");
 | 
			
		||||
 | 
			
		||||
            clearInterval(keepAliveInterval);
 | 
			
		||||
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:start", listener);
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:error", listener);
 | 
			
		||||
            dispatcher.removeEventListener("esbuild:end", listener);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const keepAliveInterval = setInterval(() => {
 | 
			
		||||
            console.timeStamp("🏓 Keep-alive");
 | 
			
		||||
 | 
			
		||||
            res.write("event: keep-alive\n\n");
 | 
			
		||||
            res.write(serializeCustomEventToStream(new CustomEvent("esbuild:keep-alive")));
 | 
			
		||||
        }, 15_000);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        name: "build-watcher",
 | 
			
		||||
        setup: (build) => {
 | 
			
		||||
            eventServer.listen(parseInt(serverURL.port, 10), serverURL.hostname);
 | 
			
		||||
 | 
			
		||||
            build.onDispose(() => {
 | 
			
		||||
                eventServer.close();
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            build.onStart(() => {
 | 
			
		||||
                console.time(timerLabel);
 | 
			
		||||
 | 
			
		||||
                dispatcher.dispatchEvent(
 | 
			
		||||
                    new CustomEvent("esbuild:start", {
 | 
			
		||||
                        detail: new Date().toISOString(),
 | 
			
		||||
                    }),
 | 
			
		||||
                );
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            build.onEnd((buildResult) => {
 | 
			
		||||
                console.timeEnd(timerLabel);
 | 
			
		||||
 | 
			
		||||
                if (!buildResult.errors.length) {
 | 
			
		||||
                    dispatcher.dispatchEvent(
 | 
			
		||||
                        new CustomEvent("esbuild:end", {
 | 
			
		||||
                            detail: new Date().toISOString(),
 | 
			
		||||
                        }),
 | 
			
		||||
                    );
 | 
			
		||||
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                console.warn(`Build ended with ${buildResult.errors.length} errors`);
 | 
			
		||||
 | 
			
		||||
                dispatcher.dispatchEvent(
 | 
			
		||||
                    new CustomEvent("esbuild:error", {
 | 
			
		||||
                        detail: buildResult.errors.map((error) => ({
 | 
			
		||||
                            ...error,
 | 
			
		||||
                            location: error.location
 | 
			
		||||
                                ? {
 | 
			
		||||
                                      ...error.location,
 | 
			
		||||
                                      file: path.resolve(relativeRoot, error.location.file),
 | 
			
		||||
                                  }
 | 
			
		||||
                                : null,
 | 
			
		||||
                        })),
 | 
			
		||||
                    }),
 | 
			
		||||
                );
 | 
			
		||||
            });
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
@ -4,13 +4,17 @@ import { ROUTES } from "@goauthentik/admin/Routes";
 | 
			
		||||
import {
 | 
			
		||||
    EVENT_API_DRAWER_TOGGLE,
 | 
			
		||||
    EVENT_NOTIFICATION_DRAWER_TOGGLE,
 | 
			
		||||
    EVENT_SIDEBAR_TOGGLE,
 | 
			
		||||
} from "@goauthentik/common/constants";
 | 
			
		||||
import { configureSentry } from "@goauthentik/common/sentry";
 | 
			
		||||
import { me } from "@goauthentik/common/users";
 | 
			
		||||
import { WebsocketClient } from "@goauthentik/common/ws";
 | 
			
		||||
import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
 | 
			
		||||
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
 | 
			
		||||
import "@goauthentik/elements/ak-locale-context";
 | 
			
		||||
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
 | 
			
		||||
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
 | 
			
		||||
import "@goauthentik/elements/banner/VersionBanner";
 | 
			
		||||
import "@goauthentik/elements/banner/VersionBanner";
 | 
			
		||||
import "@goauthentik/elements/messages/MessageContainer";
 | 
			
		||||
import "@goauthentik/elements/messages/MessageContainer";
 | 
			
		||||
@ -21,21 +25,32 @@ import "@goauthentik/elements/router/RouterOutlet";
 | 
			
		||||
import "@goauthentik/elements/sidebar/Sidebar";
 | 
			
		||||
import "@goauthentik/elements/sidebar/SidebarItem";
 | 
			
		||||
 | 
			
		||||
import { CSSResult, TemplateResult, css, html } from "lit";
 | 
			
		||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
 | 
			
		||||
import { customElement, property, query, state } from "lit/decorators.js";
 | 
			
		||||
import { classMap } from "lit/directives/class-map.js";
 | 
			
		||||
 | 
			
		||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
 | 
			
		||||
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
 | 
			
		||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
 | 
			
		||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
 | 
			
		||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
 | 
			
		||||
 | 
			
		||||
import { SessionUser, UiThemeEnum } from "@goauthentik/api";
 | 
			
		||||
import { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
import "./AdminSidebar";
 | 
			
		||||
import {
 | 
			
		||||
    AdminSidebarEnterpriseEntries,
 | 
			
		||||
    AdminSidebarEntries,
 | 
			
		||||
    renderSidebarItems,
 | 
			
		||||
} from "./AdminSidebar.js";
 | 
			
		||||
 | 
			
		||||
if (process.env.NODE_ENV === "development") {
 | 
			
		||||
    await import("@goauthentik/esbuild-plugin-live-reload/client");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@customElement("ak-interface-admin")
 | 
			
		||||
export class AdminInterface extends AuthenticatedInterface {
 | 
			
		||||
export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
 | 
			
		||||
    //#region Properties
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean })
 | 
			
		||||
    notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
 | 
			
		||||
 | 
			
		||||
@ -50,12 +65,29 @@ export class AdminInterface extends AuthenticatedInterface {
 | 
			
		||||
    @query("ak-about-modal")
 | 
			
		||||
    aboutModal?: AboutModal;
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean, reflect: true })
 | 
			
		||||
    public sidebarOpen: boolean;
 | 
			
		||||
 | 
			
		||||
    #toggleSidebar = () => {
 | 
			
		||||
        this.sidebarOpen = !this.sidebarOpen;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    #sidebarMatcher: MediaQueryList;
 | 
			
		||||
    #sidebarListener = (event: MediaQueryListEvent) => {
 | 
			
		||||
        this.sidebarOpen = event.matches;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    //#endregion
 | 
			
		||||
 | 
			
		||||
    //#region Styles
 | 
			
		||||
 | 
			
		||||
    static get styles(): CSSResult[] {
 | 
			
		||||
        return [
 | 
			
		||||
            PFBase,
 | 
			
		||||
            PFPage,
 | 
			
		||||
            PFButton,
 | 
			
		||||
            PFDrawer,
 | 
			
		||||
            PFNav,
 | 
			
		||||
            css`
 | 
			
		||||
                .pf-c-page__main,
 | 
			
		||||
                .pf-c-drawer__content,
 | 
			
		||||
@ -63,23 +95,30 @@ export class AdminInterface extends AuthenticatedInterface {
 | 
			
		||||
                    z-index: auto !important;
 | 
			
		||||
                    background-color: transparent;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .display-none {
 | 
			
		||||
                    display: none;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .pf-c-page {
 | 
			
		||||
                    background-color: var(--pf-c-page--BackgroundColor) !important;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                :host([theme="dark"]) {
 | 
			
		||||
                    /* Global page background colour */
 | 
			
		||||
                :host([theme="dark"]) .pf-c-page {
 | 
			
		||||
                    .pf-c-page {
 | 
			
		||||
                        --pf-c-page--BackgroundColor: var(--ak-dark-background);
 | 
			
		||||
                    }
 | 
			
		||||
                ak-enterprise-status,
 | 
			
		||||
                ak-version-banner {
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                ak-page-navbar {
 | 
			
		||||
                    grid-area: header;
 | 
			
		||||
                }
 | 
			
		||||
                ak-admin-sidebar {
 | 
			
		||||
 | 
			
		||||
                .ak-sidebar {
 | 
			
		||||
                    grid-area: nav;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .pf-c-drawer__panel {
 | 
			
		||||
                    z-index: var(--pf-global--ZIndex--xl);
 | 
			
		||||
                }
 | 
			
		||||
@ -87,10 +126,23 @@ export class AdminInterface extends AuthenticatedInterface {
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //#endregion
 | 
			
		||||
 | 
			
		||||
    //#region Lifecycle
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.ws = new WebsocketClient();
 | 
			
		||||
 | 
			
		||||
        this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)");
 | 
			
		||||
        this.sidebarOpen = this.#sidebarMatcher.matches;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public connectedCallback() {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
 | 
			
		||||
        window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
 | 
			
		||||
 | 
			
		||||
        window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
 | 
			
		||||
            this.notificationDrawerOpen = !this.notificationDrawerOpen;
 | 
			
		||||
            updateURLParams({
 | 
			
		||||
@ -104,6 +156,14 @@ export class AdminInterface extends AuthenticatedInterface {
 | 
			
		||||
                apiDrawerOpen: this.apiDrawerOpen,
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.#sidebarMatcher.addEventListener("change", this.#sidebarListener);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public disconnectedCallback(): void {
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
        window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
 | 
			
		||||
        this.#sidebarMatcher.removeEventListener("change", this.#sidebarListener);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async firstUpdated(): Promise<void> {
 | 
			
		||||
@ -114,27 +174,22 @@ export class AdminInterface extends AuthenticatedInterface {
 | 
			
		||||
            this.user.user.isSuperuser ||
 | 
			
		||||
            // TODO: somehow add `access_admin_interface` to the API schema
 | 
			
		||||
            this.user.user.systemPermissions.includes("access_admin_interface");
 | 
			
		||||
 | 
			
		||||
        if (!canAccessAdmin && this.user.user.pk > 0) {
 | 
			
		||||
            window.location.assign("/if/user/");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async connectedCallback(): Promise<void> {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
 | 
			
		||||
        if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
 | 
			
		||||
            const { ESBuildObserver } = await import("@goauthentik/common/client");
 | 
			
		||||
 | 
			
		||||
            new ESBuildObserver(process.env.WATCHER_URL);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(): TemplateResult {
 | 
			
		||||
        const sidebarClasses = {
 | 
			
		||||
            "pf-c-page__sidebar": true,
 | 
			
		||||
            "pf-m-light": this.activeTheme === UiThemeEnum.Light,
 | 
			
		||||
            "pf-m-expanded": this.sidebarOpen,
 | 
			
		||||
            "pf-m-collapsed": !this.sidebarOpen,
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen;
 | 
			
		||||
 | 
			
		||||
        const drawerClasses = {
 | 
			
		||||
            "pf-m-expanded": drawerOpen,
 | 
			
		||||
            "pf-m-collapsed": !drawerOpen,
 | 
			
		||||
@ -142,11 +197,18 @@ export class AdminInterface extends AuthenticatedInterface {
 | 
			
		||||
 | 
			
		||||
        return html` <ak-locale-context>
 | 
			
		||||
            <div class="pf-c-page">
 | 
			
		||||
                <ak-enterprise-status interface="admin"></ak-enterprise-status>
 | 
			
		||||
                <ak-page-navbar>
 | 
			
		||||
                    <ak-version-banner></ak-version-banner>
 | 
			
		||||
                <ak-admin-sidebar
 | 
			
		||||
                    class="pf-c-page__sidebar ${classMap(sidebarClasses)}"
 | 
			
		||||
                ></ak-admin-sidebar>
 | 
			
		||||
                    <ak-enterprise-status interface="admin"></ak-enterprise-status>
 | 
			
		||||
                </ak-page-navbar>
 | 
			
		||||
 | 
			
		||||
                <ak-sidebar class="${classMap(sidebarClasses)}">
 | 
			
		||||
                    ${renderSidebarItems(AdminSidebarEntries)}
 | 
			
		||||
                    ${this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed
 | 
			
		||||
                        ? renderSidebarItems(AdminSidebarEnterpriseEntries)
 | 
			
		||||
                        : nothing}
 | 
			
		||||
                </ak-sidebar>
 | 
			
		||||
 | 
			
		||||
                <div class="pf-c-page__drawer">
 | 
			
		||||
                    <div class="pf-c-drawer ${classMap(drawerClasses)}">
 | 
			
		||||
                        <div class="pf-c-drawer__main">
 | 
			
		||||
 | 
			
		||||
@ -1,100 +1,10 @@
 | 
			
		||||
import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants";
 | 
			
		||||
import { me } from "@goauthentik/common/users";
 | 
			
		||||
import { AKElement } from "@goauthentik/elements/Base";
 | 
			
		||||
import {
 | 
			
		||||
    CapabilitiesEnum,
 | 
			
		||||
    WithCapabilitiesConfig,
 | 
			
		||||
} from "@goauthentik/elements/Interface/capabilitiesProvider";
 | 
			
		||||
import { WithVersion } from "@goauthentik/elements/Interface/versionProvider";
 | 
			
		||||
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";
 | 
			
		||||
 | 
			
		||||
import { msg } from "@lit/localize";
 | 
			
		||||
import { TemplateResult, html, nothing } from "lit";
 | 
			
		||||
import { customElement, property, state } from "lit/decorators.js";
 | 
			
		||||
import { map } from "lit/directives/map.js";
 | 
			
		||||
import { repeat } from "lit/directives/repeat.js";
 | 
			
		||||
 | 
			
		||||
import { UiThemeEnum } from "@goauthentik/api";
 | 
			
		||||
import type { SessionUser, UserSelf } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
@customElement("ak-admin-sidebar")
 | 
			
		||||
export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement)) {
 | 
			
		||||
    @property({ type: Boolean, reflect: true })
 | 
			
		||||
    open = true;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    impersonation: UserSelf["username"] | null = null;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        me().then((user: SessionUser) => {
 | 
			
		||||
            this.impersonation = user.original ? user.user.username : null;
 | 
			
		||||
        });
 | 
			
		||||
        this.toggleOpen = this.toggleOpen.bind(this);
 | 
			
		||||
        this.checkWidth = this.checkWidth.bind(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // This has to be a bound method so the event listener can be removed on disconnection as
 | 
			
		||||
    // needed.
 | 
			
		||||
    toggleOpen() {
 | 
			
		||||
        this.open = !this.open;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    checkWidth() {
 | 
			
		||||
        // This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in
 | 
			
		||||
        // REMs. If that changes, this code will have to be adjusted as well.
 | 
			
		||||
        const minWidth =
 | 
			
		||||
            parseFloat(getRootStyle("--ak-sidebar--minimum-auto-width")) *
 | 
			
		||||
            parseFloat(getRootStyle("font-size"));
 | 
			
		||||
        this.open = window.innerWidth >= minWidth;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
        window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
 | 
			
		||||
        window.addEventListener("resize", this.checkWidth);
 | 
			
		||||
        // After connecting to the DOM, we can now perform this check to see if the sidebar should
 | 
			
		||||
        // be open by default.
 | 
			
		||||
        this.checkWidth();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // The symmetry (☟, ☝) here is critical in that you want to start adding these handlers after
 | 
			
		||||
    // connection, and removing them before disconnection.
 | 
			
		||||
 | 
			
		||||
    disconnectedCallback() {
 | 
			
		||||
        window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
 | 
			
		||||
        window.removeEventListener("resize", this.checkWidth);
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        return html`
 | 
			
		||||
            <ak-sidebar
 | 
			
		||||
                class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this
 | 
			
		||||
                    .activeTheme === UiThemeEnum.Light
 | 
			
		||||
                    ? "pf-m-light"
 | 
			
		||||
                    : ""}"
 | 
			
		||||
            >
 | 
			
		||||
                ${this.renderSidebarItems()}
 | 
			
		||||
            </ak-sidebar>
 | 
			
		||||
        `;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updated() {
 | 
			
		||||
        // This is permissible as`:host.classList` is not one of the properties Lit uses as a
 | 
			
		||||
        // scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger
 | 
			
		||||
        // a browser reflow, which may trigger some other styling the application is monitoring,
 | 
			
		||||
        // triggering a re-render which triggers a browser reflow, ad infinitum. But we've been
 | 
			
		||||
        // living with that since jQuery, and it's both well-known and fortunately rare.
 | 
			
		||||
 | 
			
		||||
        // eslint-disable-next-line wc/no-self-class
 | 
			
		||||
        this.classList.remove("pf-m-expanded", "pf-m-collapsed");
 | 
			
		||||
        // eslint-disable-next-line wc/no-self-class
 | 
			
		||||
        this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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 = [
 | 
			
		||||
@ -104,29 +14,64 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
 | 
			
		||||
    children?: SidebarEntry[],
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Recursively renders a sidebar entry.
 | 
			
		||||
 */
 | 
			
		||||
export function renderSidebarItem([
 | 
			
		||||
    path,
 | 
			
		||||
    label,
 | 
			
		||||
    attributes,
 | 
			
		||||
    children,
 | 
			
		||||
]: SidebarEntry): TemplateResult {
 | 
			
		||||
    const properties = Array.isArray(attributes)
 | 
			
		||||
        ? { ".activeWhen": attributes }
 | 
			
		||||
        : (attributes ?? {});
 | 
			
		||||
 | 
			
		||||
    if (path) {
 | 
			
		||||
        properties.path = path;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return html`<ak-sidebar-item ${spread(properties)}>
 | 
			
		||||
        ${label ? html`<span slot="label">${label}</span>` : nothing}
 | 
			
		||||
        ${children ? renderSidebarItems(children) : nothing}
 | 
			
		||||
    </ak-sidebar-item>`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Recursively renders a collection of sidebar entries.
 | 
			
		||||
 */
 | 
			
		||||
export function renderSidebarItems(entries: readonly SidebarEntry[]) {
 | 
			
		||||
    return repeat(entries, ([path, label]) => path || label, renderSidebarItem);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// prettier-ignore
 | 
			
		||||
        const sidebarContent: SidebarEntry[] = [
 | 
			
		||||
export const AdminSidebarEntries: readonly SidebarEntry[] = [
 | 
			
		||||
    [null, msg("Dashboards"), { "?expanded": true }, [
 | 
			
		||||
        ["/administration/overview", msg("Overview")],
 | 
			
		||||
        ["/administration/dashboard/users", msg("User Statistics")],
 | 
			
		||||
                ["/administration/system-tasks", msg("System Tasks")]]],
 | 
			
		||||
        ["/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")]]],
 | 
			
		||||
        ["/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")]]],
 | 
			
		||||
        ["/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")]]],
 | 
			
		||||
        ["/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")]]],
 | 
			
		||||
        ["/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})$`]],
 | 
			
		||||
@ -134,53 +79,19 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
 | 
			
		||||
        ["/identity/initial-permissions", msg("Initial Permissions"), [`^/identity/initial-permissions/(?<id>${ID_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")]]],
 | 
			
		||||
        ["/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")]]],
 | 
			
		||||
        ["/admin/settings", msg("Settings")]]
 | 
			
		||||
    ],
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
        // Typescript requires the type here to correctly type the recursive path
 | 
			
		||||
        type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
 | 
			
		||||
 | 
			
		||||
        const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
 | 
			
		||||
            const properties = Array.isArray(attributes)
 | 
			
		||||
                ? { ".activeWhen": attributes }
 | 
			
		||||
                : (attributes ?? {});
 | 
			
		||||
            if (path) {
 | 
			
		||||
                properties.path = path;
 | 
			
		||||
            }
 | 
			
		||||
            return html`<ak-sidebar-item ${spread(properties)}>
 | 
			
		||||
                ${label ? html`<span slot="label">${label}</span>` : nothing}
 | 
			
		||||
                ${map(children, renderOneSidebarItem)}
 | 
			
		||||
            </ak-sidebar-item>`;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
// prettier-ignore
 | 
			
		||||
        return html`
 | 
			
		||||
            ${map(sidebarContent, renderOneSidebarItem)}
 | 
			
		||||
            ${this.renderEnterpriseMenu()}
 | 
			
		||||
        `;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderEnterpriseMenu() {
 | 
			
		||||
        return this.can(CapabilitiesEnum.IsEnterprise)
 | 
			
		||||
            ? html`
 | 
			
		||||
                  <ak-sidebar-item>
 | 
			
		||||
                      <span slot="label">${msg("Enterprise")}</span>
 | 
			
		||||
                      <ak-sidebar-item path="/enterprise/licenses">
 | 
			
		||||
                          <span slot="label">${msg("Licenses")}</span>
 | 
			
		||||
                      </ak-sidebar-item>
 | 
			
		||||
                  </ak-sidebar-item>
 | 
			
		||||
              `
 | 
			
		||||
            : nothing;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
    interface HTMLElementTagNameMap {
 | 
			
		||||
        "ak-admin-sidebar": AkAdminSidebar;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
export const AdminSidebarEnterpriseEntries: readonly SidebarEntry[] = [
 | 
			
		||||
    [null, msg("Enterprise"), null, [
 | 
			
		||||
        ["/enterprise/licenses", msg("Licenses"), null]
 | 
			
		||||
    ],
 | 
			
		||||
]]
 | 
			
		||||
 | 
			
		||||
@ -94,10 +94,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(): TemplateResult {
 | 
			
		||||
        const name = this.user?.user.name ?? this.user?.user.username;
 | 
			
		||||
        const username = this.user?.user.name || this.user?.user.username;
 | 
			
		||||
 | 
			
		||||
        return html`<ak-page-header description=${msg("General system status")} ?hasIcon=${false}>
 | 
			
		||||
                <span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span>
 | 
			
		||||
        return html` <ak-page-header
 | 
			
		||||
                header=${msg(str`Welcome, ${username || ""}.`)}
 | 
			
		||||
                description=${msg("General system status")}
 | 
			
		||||
                ?hasIcon=${false}
 | 
			
		||||
            >
 | 
			
		||||
            </ak-page-header>
 | 
			
		||||
            <section class="pf-c-page__main-section">
 | 
			
		||||
                <div class="pf-l-grid pf-m-gutter">
 | 
			
		||||
 | 
			
		||||
@ -83,13 +83,10 @@ export class AdminSettingsPage extends AKElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        if (!this.settings) {
 | 
			
		||||
            return nothing;
 | 
			
		||||
        }
 | 
			
		||||
        if (!this.settings) return nothing;
 | 
			
		||||
 | 
			
		||||
        return html`
 | 
			
		||||
            <ak-page-header icon="fa fa-cog" header="" description="">
 | 
			
		||||
                <span slot="header"> ${msg("System settings")} </span>
 | 
			
		||||
            </ak-page-header>
 | 
			
		||||
            <ak-page-header icon="fa fa-cog" header="${msg("System settings")}"> </ak-page-header>
 | 
			
		||||
            <section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
 | 
			
		||||
                <div class="pf-c-card">
 | 
			
		||||
                    <div class="pf-c-card__body">
 | 
			
		||||
 | 
			
		||||
@ -52,7 +52,6 @@ function renderRadiusOverview(rawProvider: OneOfProvider) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function renderRACOverview(rawProvider: OneOfProvider) {
 | 
			
		||||
    // @ts-expect-error TS6133
 | 
			
		||||
    const _provider = rawProvider as RACProvider;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -61,7 +61,6 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
 | 
			
		||||
            new TableColumn(this.allowedTypesLabel),
 | 
			
		||||
            new TableColumn(msg("Enabled"), "enabled"),
 | 
			
		||||
            new TableColumn(msg("Timeout"), "timeout"),
 | 
			
		||||
            new TableColumn(msg("Honor order"), "honor_order"),
 | 
			
		||||
            new TableColumn(msg("Actions")),
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
@ -166,7 +165,6 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
 | 
			
		||||
            html`${this.getPolicyUserGroupRow(item)}`,
 | 
			
		||||
            html`<ak-status-label type="warning" ?good=${item.enabled}></ak-status-label>`,
 | 
			
		||||
            html`${item.timeout}`,
 | 
			
		||||
            html`<ak-status-label type="info" ?good=${item.honorOrder}></ak-status-label>`,
 | 
			
		||||
            html` ${this.getObjectEditButton(item)}
 | 
			
		||||
                <ak-forms-modal size=${PFSize.Medium}>
 | 
			
		||||
                    <span slot="submit"> ${msg("Update")} </span>
 | 
			
		||||
 | 
			
		||||
@ -310,26 +310,6 @@ export class PolicyBindingForm extends ModelForm<PolicyBinding, string> {
 | 
			
		||||
                    required
 | 
			
		||||
                />
 | 
			
		||||
            </ak-form-element-horizontal>
 | 
			
		||||
            <ak-form-element-horizontal name="honorOrder">
 | 
			
		||||
                <label class="pf-c-switch">
 | 
			
		||||
                    <input
 | 
			
		||||
                        class="pf-c-switch__input"
 | 
			
		||||
                        type="checkbox"
 | 
			
		||||
                        ?checked=${first(this.instance?.honorOrder, false)}
 | 
			
		||||
                    />
 | 
			
		||||
                    <span class="pf-c-switch__toggle">
 | 
			
		||||
                        <span class="pf-c-switch__toggle-icon">
 | 
			
		||||
                            <i class="fas fa-check" aria-hidden="true"></i>
 | 
			
		||||
                        </span>
 | 
			
		||||
                    </span>
 | 
			
		||||
                    <span class="pf-c-switch__label">${msg("Honor order")}</span>
 | 
			
		||||
                </label>
 | 
			
		||||
                <p class="pf-c-form__helper-text">
 | 
			
		||||
                    ${msg(
 | 
			
		||||
                        "Honor the order of policies. Use if policies must be evaluated sequentially following the specified order. May impact performance.",
 | 
			
		||||
                    )}
 | 
			
		||||
                </p>
 | 
			
		||||
            </ak-form-element-horizontal>
 | 
			
		||||
            <ak-form-element-horizontal label=${msg("Timeout")} ?required=${true} name="timeout">
 | 
			
		||||
                <input
 | 
			
		||||
                    type="number"
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,7 @@ import "@goauthentik/admin/policies/expiry/ExpiryPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/policies/expression/ExpressionPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/policies/password/PasswordPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/policies/reputation/ReputationPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/policies/unique_password/UniquePasswordPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/rbac/ObjectPermissionModal";
 | 
			
		||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
			
		||||
import { PFColor } from "@goauthentik/elements/Label";
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,7 @@ import "@goauthentik/admin/policies/expression/ExpressionPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/policies/geoip/GeoIPPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/policies/password/PasswordPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/policies/reputation/ReputationPolicyForm";
 | 
			
		||||
import "@goauthentik/admin/policies/unique_password/UniquePasswordPolicyForm";
 | 
			
		||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
			
		||||
import { AKElement } from "@goauthentik/elements/Base";
 | 
			
		||||
import "@goauthentik/elements/forms/ProxyForm";
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,103 @@
 | 
			
		||||
import { BasePolicyForm } from "@goauthentik/admin/policies/BasePolicyForm";
 | 
			
		||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
			
		||||
import { first } from "@goauthentik/common/utils";
 | 
			
		||||
import "@goauthentik/elements/forms/FormGroup";
 | 
			
		||||
import "@goauthentik/elements/forms/HorizontalFormElement";
 | 
			
		||||
 | 
			
		||||
import { msg } from "@lit/localize";
 | 
			
		||||
import { TemplateResult, html } from "lit";
 | 
			
		||||
import { customElement } from "lit/decorators.js";
 | 
			
		||||
import { ifDefined } from "lit/directives/if-defined.js";
 | 
			
		||||
 | 
			
		||||
import { PoliciesApi, UniquePasswordPolicy } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
@customElement("ak-policy-password-uniqueness-form")
 | 
			
		||||
export class UniquePasswordPolicyForm extends BasePolicyForm<UniquePasswordPolicy> {
 | 
			
		||||
    async loadInstance(pk: string): Promise<UniquePasswordPolicy> {
 | 
			
		||||
        return new PoliciesApi(DEFAULT_CONFIG).policiesUniquePasswordRetrieve({
 | 
			
		||||
            policyUuid: pk,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async send(data: UniquePasswordPolicy): Promise<UniquePasswordPolicy> {
 | 
			
		||||
        if (this.instance) {
 | 
			
		||||
            return new PoliciesApi(DEFAULT_CONFIG).policiesUniquePasswordUpdate({
 | 
			
		||||
                policyUuid: this.instance.pk || "",
 | 
			
		||||
                uniquePasswordPolicyRequest: data,
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            return new PoliciesApi(DEFAULT_CONFIG).policiesUniquePasswordCreate({
 | 
			
		||||
                uniquePasswordPolicyRequest: data,
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderForm(): TemplateResult {
 | 
			
		||||
        return html` <span>
 | 
			
		||||
                ${msg(
 | 
			
		||||
                    "Ensure that the user's new password is different from their previous passwords. The number of past passwords to check is configurable.",
 | 
			
		||||
                )}
 | 
			
		||||
            </span>
 | 
			
		||||
            <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
 | 
			
		||||
                <input
 | 
			
		||||
                    type="text"
 | 
			
		||||
                    value="${ifDefined(this.instance?.name || "")}"
 | 
			
		||||
                    class="pf-c-form-control"
 | 
			
		||||
                    required
 | 
			
		||||
                />
 | 
			
		||||
            </ak-form-element-horizontal>
 | 
			
		||||
            <ak-form-element-horizontal name="executionLogging">
 | 
			
		||||
                <label class="pf-c-switch">
 | 
			
		||||
                    <input
 | 
			
		||||
                        class="pf-c-switch__input"
 | 
			
		||||
                        type="checkbox"
 | 
			
		||||
                        ?checked=${first(this.instance?.executionLogging, false)}
 | 
			
		||||
                    />
 | 
			
		||||
                    <span class="pf-c-switch__toggle">
 | 
			
		||||
                        <span class="pf-c-switch__toggle-icon">
 | 
			
		||||
                            <i class="fas fa-check" aria-hidden="true"></i>
 | 
			
		||||
                        </span>
 | 
			
		||||
                    </span>
 | 
			
		||||
                    <span class="pf-c-switch__label">${msg("Execution logging")}</span>
 | 
			
		||||
                </label>
 | 
			
		||||
                <p class="pf-c-form__helper-text">
 | 
			
		||||
                    ${msg(
 | 
			
		||||
                        "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.",
 | 
			
		||||
                    )}
 | 
			
		||||
                </p>
 | 
			
		||||
            </ak-form-element-horizontal>
 | 
			
		||||
            <ak-form-element-horizontal
 | 
			
		||||
                label=${msg("Password field")}
 | 
			
		||||
                ?required=${true}
 | 
			
		||||
                name="passwordField"
 | 
			
		||||
            >
 | 
			
		||||
                <input
 | 
			
		||||
                    type="text"
 | 
			
		||||
                    value="${ifDefined(this.instance?.passwordField || "password")}"
 | 
			
		||||
                    class="pf-c-form-control"
 | 
			
		||||
                    required
 | 
			
		||||
                />
 | 
			
		||||
                <p class="pf-c-form__helper-text">
 | 
			
		||||
                    ${msg("Field key to check, field keys defined in Prompt stages are available.")}
 | 
			
		||||
                </p>
 | 
			
		||||
            </ak-form-element-horizontal>
 | 
			
		||||
            <ak-form-element-horizontal
 | 
			
		||||
                label=${msg("Number of previous passwords to check")}
 | 
			
		||||
                ?required=${true}
 | 
			
		||||
                name="numHistoricalPasswords"
 | 
			
		||||
            >
 | 
			
		||||
                <input
 | 
			
		||||
                    type="number"
 | 
			
		||||
                    value="${first(this.instance?.numHistoricalPasswords, 1)}"
 | 
			
		||||
                    class="pf-c-form-control"
 | 
			
		||||
                    required
 | 
			
		||||
                />
 | 
			
		||||
            </ak-form-element-horizontal>`;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
    interface HTMLElementTagNameMap {
 | 
			
		||||
        "ak-policy-password-uniqueness-form": UniquePasswordPolicyForm;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
										
											Binary file not shown.
										
									
								
							| 
		 Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 1.4 MiB  | 
@ -1,25 +1,110 @@
 | 
			
		||||
import type { Config as DOMPurifyConfig } from "dompurify";
 | 
			
		||||
import DOMPurify from "dompurify";
 | 
			
		||||
import { trustedTypes } from "trusted-types";
 | 
			
		||||
 | 
			
		||||
import { render } from "@lit-labs/ssr";
 | 
			
		||||
import { collectResult } from "@lit-labs/ssr/lib/render-result.js";
 | 
			
		||||
import { TemplateResult, html } from "lit";
 | 
			
		||||
import { render } from "lit";
 | 
			
		||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
 | 
			
		||||
import { until } from "lit/directives/until.js";
 | 
			
		||||
 | 
			
		||||
export const DOM_PURIFY_STRICT: DOMPurify.Config = {
 | 
			
		||||
/**
 | 
			
		||||
 * Trusted types policy that escapes HTML content in place.
 | 
			
		||||
 *
 | 
			
		||||
 * @see {@linkcode SanitizedTrustPolicy} to strip HTML content.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {TrustedHTML} All HTML content, escaped.
 | 
			
		||||
 */
 | 
			
		||||
export const EscapeTrustPolicy = trustedTypes.createPolicy("authentik-escape", {
 | 
			
		||||
    createHTML: (untrustedHTML: string) => {
 | 
			
		||||
        return DOMPurify.sanitize(untrustedHTML, {
 | 
			
		||||
            RETURN_TRUSTED_TYPE: false,
 | 
			
		||||
        });
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Trusted types policy, stripping all HTML content.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {TrustedHTML} Text content only, all HTML tags stripped.
 | 
			
		||||
 */
 | 
			
		||||
export const SanitizedTrustPolicy = trustedTypes.createPolicy("authentik-sanitize", {
 | 
			
		||||
    createHTML: (untrustedHTML: string) => {
 | 
			
		||||
        return DOMPurify.sanitize(untrustedHTML, {
 | 
			
		||||
            RETURN_TRUSTED_TYPE: false,
 | 
			
		||||
            ALLOWED_TAGS: ["#text"],
 | 
			
		||||
};
 | 
			
		||||
        });
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export async function renderStatic(input: TemplateResult): Promise<string> {
 | 
			
		||||
    return await collectResult(render(input));
 | 
			
		||||
/**
 | 
			
		||||
 * Trusted types policy, allowing a minimal set of _safe_ HTML tags supplied by
 | 
			
		||||
 * a trusted source, such as the brand API.
 | 
			
		||||
 */
 | 
			
		||||
export const BrandedHTMLPolicy = trustedTypes.createPolicy("authentik-restrict", {
 | 
			
		||||
    createHTML: (untrustedHTML: string) => {
 | 
			
		||||
        return DOMPurify.sanitize(untrustedHTML, {
 | 
			
		||||
            RETURN_TRUSTED_TYPE: false,
 | 
			
		||||
            FORBID_TAGS: [
 | 
			
		||||
                "script",
 | 
			
		||||
                "style",
 | 
			
		||||
                "iframe",
 | 
			
		||||
                "link",
 | 
			
		||||
                "object",
 | 
			
		||||
                "embed",
 | 
			
		||||
                "applet",
 | 
			
		||||
                "meta",
 | 
			
		||||
                "base",
 | 
			
		||||
                "form",
 | 
			
		||||
                "input",
 | 
			
		||||
                "textarea",
 | 
			
		||||
                "select",
 | 
			
		||||
                "button",
 | 
			
		||||
            ],
 | 
			
		||||
            FORBID_ATTR: [
 | 
			
		||||
                "onerror",
 | 
			
		||||
                "onclick",
 | 
			
		||||
                "onload",
 | 
			
		||||
                "onmouseover",
 | 
			
		||||
                "onmouseout",
 | 
			
		||||
                "onmouseup",
 | 
			
		||||
                "onmousedown",
 | 
			
		||||
                "onfocus",
 | 
			
		||||
                "onblur",
 | 
			
		||||
                "onsubmit",
 | 
			
		||||
            ],
 | 
			
		||||
        });
 | 
			
		||||
    },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type AuthentikTrustPolicy =
 | 
			
		||||
    | typeof EscapeTrustPolicy
 | 
			
		||||
    | typeof SanitizedTrustPolicy
 | 
			
		||||
    | typeof BrandedHTMLPolicy;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Sanitize an untrusted HTML string using a trusted types policy.
 | 
			
		||||
 */
 | 
			
		||||
export function sanitizeHTML(trustPolicy: AuthentikTrustPolicy, untrustedHTML: string) {
 | 
			
		||||
    return unsafeHTML(trustPolicy.createHTML(untrustedHTML).toString());
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function purify(input: TemplateResult): TemplateResult {
 | 
			
		||||
    return html`${until(
 | 
			
		||||
        (async () => {
 | 
			
		||||
            const rendered = await renderStatic(input);
 | 
			
		||||
            const purified = DOMPurify.sanitize(rendered);
 | 
			
		||||
            return html`${unsafeHTML(purified)}`;
 | 
			
		||||
        })(),
 | 
			
		||||
    )}`;
 | 
			
		||||
/**
 | 
			
		||||
 * DOMPurify configuration for strict sanitization.
 | 
			
		||||
 *
 | 
			
		||||
 * This configuration only allows text nodes and disallows all HTML tags.
 | 
			
		||||
 */
 | 
			
		||||
export const DOM_PURIFY_STRICT = {
 | 
			
		||||
    ALLOWED_TAGS: ["#text"],
 | 
			
		||||
} as const satisfies DOMPurifyConfig;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Render untrusted HTML to a string without escaping it.
 | 
			
		||||
 *
 | 
			
		||||
 * @returns {string} The rendered HTML string.
 | 
			
		||||
 */
 | 
			
		||||
export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string {
 | 
			
		||||
    const container = document.createElement("html");
 | 
			
		||||
    render(untrustedHTML, container);
 | 
			
		||||
 | 
			
		||||
    const result = container.innerHTML;
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -17,8 +17,16 @@
 | 
			
		||||
 | 
			
		||||
    /* Minimum width after which the sidebar becomes automatic */
 | 
			
		||||
    --ak-sidebar--minimum-auto-width: 80rem;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * The height of the navbar and branded sidebar.
 | 
			
		||||
     * @todo This shouldn't be necessary. The sidebar can instead use a grid layout
 | 
			
		||||
     * ensuring they share the same height.
 | 
			
		||||
     */
 | 
			
		||||
    --ak-navbar--height: 7rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@supports selector(::-webkit-scrollbar) {
 | 
			
		||||
    ::-webkit-scrollbar {
 | 
			
		||||
        width: 5px;
 | 
			
		||||
        height: 5px;
 | 
			
		||||
@ -33,6 +41,13 @@
 | 
			
		||||
    ::-webkit-scrollbar-corner {
 | 
			
		||||
        background-color: transparent;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@supports not selector(::-webkit-scrollbar) {
 | 
			
		||||
    :root {
 | 
			
		||||
        scrollbar-color: var(--ak-accent) transparent;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
html {
 | 
			
		||||
    --pf-c-nav__link--PaddingTop: 0.5rem;
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										220
									
								
								web/src/common/stylesheets.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								web/src/common/stylesheets.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,220 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file Stylesheet utilities.
 | 
			
		||||
 */
 | 
			
		||||
import { CSSResult, CSSResultOrNative, ReactiveElement, css } from "lit";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Elements containing adoptable stylesheets.
 | 
			
		||||
 */
 | 
			
		||||
export type StyleSheetParent = Pick<DocumentOrShadowRoot, "adoptedStyleSheets">;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Type-predicate to determine if a given object has adoptable stylesheets.
 | 
			
		||||
 */
 | 
			
		||||
export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheetParent {
 | 
			
		||||
    // Sanity check - Does the input have the right shape?
 | 
			
		||||
 | 
			
		||||
    if (!input || typeof input !== "object") return false;
 | 
			
		||||
 | 
			
		||||
    if (!("adoptedStyleSheets" in input) || !input.adoptedStyleSheets) return false;
 | 
			
		||||
 | 
			
		||||
    if (typeof input.adoptedStyleSheets !== "object") return false;
 | 
			
		||||
 | 
			
		||||
    // We avoid `Array.isArray` because the adopted stylesheets property
 | 
			
		||||
    // is defined as a proxied array.
 | 
			
		||||
    // All we care about is that it's shaped like an array.
 | 
			
		||||
    if (!("length" in input.adoptedStyleSheets)) return false;
 | 
			
		||||
 | 
			
		||||
    if (typeof input.adoptedStyleSheets.length !== "number") return false;
 | 
			
		||||
 | 
			
		||||
    // Finally is the array mutable?
 | 
			
		||||
    return "push" in input.adoptedStyleSheets;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Assert that the given input can adopt stylesheets.
 | 
			
		||||
 */
 | 
			
		||||
export function assertAdoptableStyleSheetParent<T>(
 | 
			
		||||
    input: T,
 | 
			
		||||
): asserts input is T & StyleSheetParent {
 | 
			
		||||
    if (isAdoptableStyleSheetParent(input)) return;
 | 
			
		||||
 | 
			
		||||
    console.debug("Given input missing `adoptedStyleSheets`", input);
 | 
			
		||||
 | 
			
		||||
    throw new TypeError("Assertion failed: `adoptedStyleSheets` missing in given input");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function resolveStyleSheetParent<T extends HTMLElement | DocumentFragment | Document>(
 | 
			
		||||
    renderRoot: T,
 | 
			
		||||
) {
 | 
			
		||||
    const styleRoot = "ShadyDOM" in window ? document : renderRoot;
 | 
			
		||||
 | 
			
		||||
    assertAdoptableStyleSheetParent(styleRoot);
 | 
			
		||||
 | 
			
		||||
    return styleRoot;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type StyleSheetInit = string | CSSResult | CSSStyleSheet;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Given a source of CSS, create a `CSSStyleSheet`.
 | 
			
		||||
 *
 | 
			
		||||
 * @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
 | 
			
		||||
 *
 | 
			
		||||
 * @remarks
 | 
			
		||||
 *
 | 
			
		||||
 * Storybook's `build` does not currently have a coherent way of importing
 | 
			
		||||
 * CSS-as-text into CSSStyleSheet.
 | 
			
		||||
 *
 | 
			
		||||
 * It works well when Storybook is running in `dev`, but in `build` it fails.
 | 
			
		||||
 * Storied components will have to map their textual CSS imports.
 | 
			
		||||
 */
 | 
			
		||||
export function createStyleSheet(input: string): CSSResult {
 | 
			
		||||
    const inputTemplate = [input] as unknown as TemplateStringsArray;
 | 
			
		||||
 | 
			
		||||
    const result = css(inputTemplate, []);
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Given a source of CSS, create a `CSSStyleSheet`.
 | 
			
		||||
 *
 | 
			
		||||
 * @see {@linkcode createStyleSheet}
 | 
			
		||||
 */
 | 
			
		||||
export function normalizeCSSSource(css: string): CSSStyleSheet;
 | 
			
		||||
export function normalizeCSSSource(styleSheet: CSSStyleSheet): CSSStyleSheet;
 | 
			
		||||
export function normalizeCSSSource(cssResult: CSSResult): CSSResult;
 | 
			
		||||
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative;
 | 
			
		||||
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative {
 | 
			
		||||
    if (typeof input === "string") return createStyleSheet(input);
 | 
			
		||||
 | 
			
		||||
    return input;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function createStyleSheetUnsafe(input: StyleSheetInit): CSSStyleSheet {
 | 
			
		||||
    const result = normalizeCSSSource(input);
 | 
			
		||||
    if (result instanceof CSSStyleSheet) return result;
 | 
			
		||||
 | 
			
		||||
    if (!result.styleSheet) {
 | 
			
		||||
        console.debug(
 | 
			
		||||
            "authentik/common/stylesheets: CSSResult missing styleSheet, returning empty",
 | 
			
		||||
            { result, input },
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        throw new TypeError("Expected a CSSStyleSheet");
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return result.styleSheet;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Append stylesheet(s) to the given roots.
 | 
			
		||||
 */
 | 
			
		||||
export function appendStyleSheet(
 | 
			
		||||
    insertions: CSSStyleSheet | Iterable<CSSStyleSheet>,
 | 
			
		||||
    ...styleParents: StyleSheetParent[]
 | 
			
		||||
): void {
 | 
			
		||||
    insertions = Array.isArray(insertions) ? insertions : [insertions];
 | 
			
		||||
 | 
			
		||||
    for (const nextStyleSheet of insertions) {
 | 
			
		||||
        for (const styleParent of styleParents) {
 | 
			
		||||
            if (styleParent.adoptedStyleSheets.includes(nextStyleSheet)) return;
 | 
			
		||||
 | 
			
		||||
            styleParent.adoptedStyleSheets = [...styleParent.adoptedStyleSheets, nextStyleSheet];
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Remove a stylesheet from the given roots, matching by referential equality.
 | 
			
		||||
 */
 | 
			
		||||
export function removeStyleSheet(
 | 
			
		||||
    currentStyleSheet: CSSStyleSheet,
 | 
			
		||||
    ...styleParents: StyleSheetParent[]
 | 
			
		||||
): void {
 | 
			
		||||
    for (const styleParent of styleParents) {
 | 
			
		||||
        const nextAdoptedStyleSheets = styleParent.adoptedStyleSheets.filter(
 | 
			
		||||
            (styleSheet) => styleSheet !== currentStyleSheet,
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        if (nextAdoptedStyleSheets.length === styleParent.adoptedStyleSheets.length) return;
 | 
			
		||||
 | 
			
		||||
        styleParent.adoptedStyleSheets = nextAdoptedStyleSheets;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Serialize a stylesheet to a string.
 | 
			
		||||
 *
 | 
			
		||||
 * This is useful for debugging or inspecting the contents of a stylesheet.
 | 
			
		||||
 */
 | 
			
		||||
export function serializeStyleSheet(stylesheet: CSSStyleSheet): string {
 | 
			
		||||
    return Array.from(stylesheet.cssRules || [], (rule) => rule.cssText || "").join("\n");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Inspect the adopted stylesheets of a given style parent, serializing them to strings.
 | 
			
		||||
 */
 | 
			
		||||
export function inspectStyleSheets(styleParent: StyleSheetParent): string[] {
 | 
			
		||||
    return styleParent.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface InspectedStyleSheetEntry {
 | 
			
		||||
    tagName: string;
 | 
			
		||||
    element: ReactiveElement;
 | 
			
		||||
    styles: string[];
 | 
			
		||||
    children?: InspectedStyleSheetEntry[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Recursively inspect the adopted stylesheets of a given style parent, serializing them to strings.
 | 
			
		||||
 */
 | 
			
		||||
export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleSheetEntry {
 | 
			
		||||
    const styleParent = resolveStyleSheetParent(element.renderRoot);
 | 
			
		||||
    const styles = inspectStyleSheets(styleParent);
 | 
			
		||||
    const tagName = element.tagName.toLowerCase();
 | 
			
		||||
 | 
			
		||||
    const treewalker = document.createTreeWalker(element.renderRoot, NodeFilter.SHOW_ELEMENT, {
 | 
			
		||||
        acceptNode(node) {
 | 
			
		||||
            if (node instanceof ReactiveElement) {
 | 
			
		||||
                return NodeFilter.FILTER_ACCEPT;
 | 
			
		||||
            }
 | 
			
		||||
            return NodeFilter.FILTER_SKIP;
 | 
			
		||||
        },
 | 
			
		||||
    });
 | 
			
		||||
    const children: InspectedStyleSheetEntry[] = [];
 | 
			
		||||
    let currentNode: Node | null = treewalker.nextNode();
 | 
			
		||||
    while (currentNode) {
 | 
			
		||||
        const childElement = currentNode as ReactiveElement;
 | 
			
		||||
 | 
			
		||||
        if (!isAdoptableStyleSheetParent(childElement.renderRoot)) {
 | 
			
		||||
            currentNode = treewalker.nextNode();
 | 
			
		||||
            continue;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const childStyles = inspectStyleSheets(childElement.renderRoot);
 | 
			
		||||
 | 
			
		||||
        children.push({
 | 
			
		||||
            tagName: childElement.tagName.toLowerCase(),
 | 
			
		||||
            element: childElement,
 | 
			
		||||
            styles: childStyles,
 | 
			
		||||
        });
 | 
			
		||||
        currentNode = treewalker.nextNode();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        tagName,
 | 
			
		||||
        element,
 | 
			
		||||
        styles,
 | 
			
		||||
        children,
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
if (process.env.NODE_ENV === "development") {
 | 
			
		||||
    Object.assign(window, {
 | 
			
		||||
        inspectStyleSheetTree,
 | 
			
		||||
        serializeStyleSheet,
 | 
			
		||||
        inspectStyleSheets,
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										200
									
								
								web/src/common/theme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								web/src/common/theme.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,200 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @file Theme utilities.
 | 
			
		||||
 */
 | 
			
		||||
import { UIConfig } from "@goauthentik/common/ui/config";
 | 
			
		||||
 | 
			
		||||
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
//#region Scheme Types
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Valid CSS color scheme values.
 | 
			
		||||
 *
 | 
			
		||||
 * @link {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme | MDN}
 | 
			
		||||
 *
 | 
			
		||||
 * @category CSS
 | 
			
		||||
 */
 | 
			
		||||
export type CSSColorSchemeValue = "dark" | "light" | "auto";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A CSS color scheme value that can be preferred by the user, i.e. not `"auto"`.
 | 
			
		||||
 *
 | 
			
		||||
 * @category CSS
 | 
			
		||||
 */
 | 
			
		||||
export type ResolvedCSSColorSchemeValue = Exclude<CSSColorSchemeValue, "auto">;
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
//#region UI Theme Types
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A UI color scheme value that can be preferred by the user.
 | 
			
		||||
 *
 | 
			
		||||
 * i.e. not an lack of preference or unknown value.
 | 
			
		||||
 *
 | 
			
		||||
 * @category CSS
 | 
			
		||||
 */
 | 
			
		||||
export type ResolvedUITheme = typeof UiThemeEnum.Light | typeof UiThemeEnum.Dark;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A mapping of theme values to their respective inversion.
 | 
			
		||||
 *
 | 
			
		||||
 * @category CSS
 | 
			
		||||
 */
 | 
			
		||||
export const UIThemeInversion = {
 | 
			
		||||
    dark: "light",
 | 
			
		||||
    light: "dark",
 | 
			
		||||
} as const satisfies Record<ResolvedUITheme, ResolvedUITheme>;
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Either a valid CSS color scheme value, or a theme preference.
 | 
			
		||||
 */
 | 
			
		||||
export type UIThemeHint = CSSColorSchemeValue | UiThemeEnum;
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
//#region Scheme Functions
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Creates an event target for the given color scheme.
 | 
			
		||||
 *
 | 
			
		||||
 * @param colorScheme The color scheme to target.
 | 
			
		||||
 * @returns A {@linkcode MediaQueryList} that can be used to listen for changes to the color scheme.
 | 
			
		||||
 *
 | 
			
		||||
 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList | MDN}
 | 
			
		||||
 *
 | 
			
		||||
 * @category CSS
 | 
			
		||||
 */
 | 
			
		||||
export function createColorSchemeTarget(colorScheme: ResolvedCSSColorSchemeValue): MediaQueryList {
 | 
			
		||||
    return window.matchMedia(`(prefers-color-scheme: ${colorScheme})`);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Formats the given input into a valid CSS color scheme value.
 | 
			
		||||
 *
 | 
			
		||||
 * If the input is not provided, it defaults to "auto".
 | 
			
		||||
 *
 | 
			
		||||
 * @category CSS
 | 
			
		||||
 */
 | 
			
		||||
export function formatColorScheme(theme: ResolvedUITheme): ResolvedCSSColorSchemeValue;
 | 
			
		||||
export function formatColorScheme(
 | 
			
		||||
    colorScheme: ResolvedCSSColorSchemeValue,
 | 
			
		||||
): ResolvedCSSColorSchemeValue;
 | 
			
		||||
export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue;
 | 
			
		||||
export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue {
 | 
			
		||||
    if (!hint) return "auto";
 | 
			
		||||
 | 
			
		||||
    switch (hint) {
 | 
			
		||||
        case "dark":
 | 
			
		||||
        case UiThemeEnum.Dark:
 | 
			
		||||
            return "dark";
 | 
			
		||||
        case "light":
 | 
			
		||||
        case UiThemeEnum.Light:
 | 
			
		||||
            return "light";
 | 
			
		||||
        case "auto":
 | 
			
		||||
        case UiThemeEnum.Automatic:
 | 
			
		||||
            return "auto";
 | 
			
		||||
        default:
 | 
			
		||||
            console.warn(`Unknown color scheme hint: ${hint}. Defaulting to "auto".`);
 | 
			
		||||
            return "auto";
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
//#region Theme Functions
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Resolve the current UI theme based on the user's preference or the provided color scheme.
 | 
			
		||||
 *
 | 
			
		||||
 * @param hint The color scheme hint to use.
 | 
			
		||||
 *
 | 
			
		||||
 * @category CSS
 | 
			
		||||
 */
 | 
			
		||||
export function resolveUITheme(
 | 
			
		||||
    hint?: UIThemeHint,
 | 
			
		||||
    defaultUITheme: ResolvedUITheme = UiThemeEnum.Light,
 | 
			
		||||
): ResolvedUITheme {
 | 
			
		||||
    const colorScheme = formatColorScheme(hint);
 | 
			
		||||
 | 
			
		||||
    if (colorScheme !== "auto") return colorScheme;
 | 
			
		||||
 | 
			
		||||
    // Given that we don't know the user's preference,
 | 
			
		||||
    // we can determine the theme based on whether the default theme is
 | 
			
		||||
    // currently being overridden.
 | 
			
		||||
 | 
			
		||||
    const colorSchemeInversion = formatColorScheme(UIThemeInversion[defaultUITheme]);
 | 
			
		||||
 | 
			
		||||
    const mediaQueryList = createColorSchemeTarget(colorSchemeInversion);
 | 
			
		||||
 | 
			
		||||
    return mediaQueryList.matches ? colorSchemeInversion : defaultUITheme;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Effect listener invoked when the color scheme changes.
 | 
			
		||||
 */
 | 
			
		||||
export type UIThemeListener = (currentUITheme: ResolvedUITheme) => void;
 | 
			
		||||
/**
 | 
			
		||||
 * Create an effect that runs
 | 
			
		||||
 *
 | 
			
		||||
 * @returns A cleanup function that removes the effect.
 | 
			
		||||
 */
 | 
			
		||||
export function createUIThemeEffect(
 | 
			
		||||
    effect: UIThemeListener,
 | 
			
		||||
    listenerOptions?: AddEventListenerOptions,
 | 
			
		||||
): () => void {
 | 
			
		||||
    const colorSchemeTarget = resolveUITheme();
 | 
			
		||||
    const invertedColorSchemeTarget = UIThemeInversion[colorSchemeTarget];
 | 
			
		||||
 | 
			
		||||
    let previousUITheme: ResolvedUITheme | undefined;
 | 
			
		||||
 | 
			
		||||
    // First, wrap the effect to ensure we can abort it.
 | 
			
		||||
    const changeListener = (event: MediaQueryListEvent) => {
 | 
			
		||||
        if (listenerOptions?.signal?.aborted) return;
 | 
			
		||||
 | 
			
		||||
        const currentUITheme = event.matches ? colorSchemeTarget : invertedColorSchemeTarget;
 | 
			
		||||
 | 
			
		||||
        if (previousUITheme === currentUITheme) return;
 | 
			
		||||
 | 
			
		||||
        previousUITheme = currentUITheme;
 | 
			
		||||
 | 
			
		||||
        effect(currentUITheme);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const mediaQueryList = createColorSchemeTarget(colorSchemeTarget);
 | 
			
		||||
 | 
			
		||||
    // Trigger the effect immediately.
 | 
			
		||||
    effect(colorSchemeTarget);
 | 
			
		||||
 | 
			
		||||
    // Listen for changes to the color scheme...
 | 
			
		||||
    mediaQueryList.addEventListener("change", changeListener, listenerOptions);
 | 
			
		||||
 | 
			
		||||
    // Finally, allow the caller to remove the effect.
 | 
			
		||||
    const cleanup = () => {
 | 
			
		||||
        mediaQueryList.removeEventListener("change", changeListener);
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    return cleanup;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
//#region Theme Element
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * An element that can be themed.
 | 
			
		||||
 */
 | 
			
		||||
export interface ThemedElement extends HTMLElement {
 | 
			
		||||
    brand?: CurrentBrand;
 | 
			
		||||
    uiConfig?: UIConfig;
 | 
			
		||||
    config?: Config;
 | 
			
		||||
    activeTheme: ResolvedUITheme;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function rootInterface<T extends ThemedElement = ThemedElement>(): T | null {
 | 
			
		||||
    const element = document.body.querySelector<T>("[data-ak-interface-root]");
 | 
			
		||||
 | 
			
		||||
    return element;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
@ -95,7 +95,7 @@ export class NavigationButtons extends AKElement {
 | 
			
		||||
            );
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg">
 | 
			
		||||
        return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl">
 | 
			
		||||
            <button class="pf-c-button pf-m-plain" type="button" @click=${onClick}>
 | 
			
		||||
                <pf-tooltip position="top" content=${msg("Open API drawer")}>
 | 
			
		||||
                    <i class="fas fa-code" aria-hidden="true"></i>
 | 
			
		||||
@ -116,7 +116,7 @@ export class NavigationButtons extends AKElement {
 | 
			
		||||
            );
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg">
 | 
			
		||||
        return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-xl">
 | 
			
		||||
            <button
 | 
			
		||||
                class="pf-c-button pf-m-plain"
 | 
			
		||||
                type="button"
 | 
			
		||||
@ -156,9 +156,7 @@ export class NavigationButtons extends AKElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderImpersonation() {
 | 
			
		||||
        if (!this.me?.original) {
 | 
			
		||||
            return nothing;
 | 
			
		||||
        }
 | 
			
		||||
        if (!this.me?.original) return nothing;
 | 
			
		||||
 | 
			
		||||
        const onClick = async () => {
 | 
			
		||||
            await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve();
 | 
			
		||||
@ -175,6 +173,14 @@ export class NavigationButtons extends AKElement {
 | 
			
		||||
            </div>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderAvatar() {
 | 
			
		||||
        return html`<img
 | 
			
		||||
            class="pf-c-page__header-tools-item pf-c-avatar pf-m-hidden pf-m-visible-on-xl"
 | 
			
		||||
            src=${ifDefined(this.me?.user.avatar)}
 | 
			
		||||
            alt="${msg("Avatar image")}"
 | 
			
		||||
        />`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get userDisplayName() {
 | 
			
		||||
        return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay)
 | 
			
		||||
            .with(UserDisplay.username, () => this.me?.user.username)
 | 
			
		||||
@ -206,17 +212,13 @@ export class NavigationButtons extends AKElement {
 | 
			
		||||
            </div>
 | 
			
		||||
            ${this.renderImpersonation()}
 | 
			
		||||
            ${this.userDisplayName != ""
 | 
			
		||||
                ? html`<div class="pf-c-page__header-tools-group">
 | 
			
		||||
                      <div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md">
 | 
			
		||||
                ? html`<div class="pf-c-page__header-tools-group pf-m-hidden">
 | 
			
		||||
                      <div class="pf-c-page__header-tools-item pf-m-visible-on-2xl">
 | 
			
		||||
                          ${this.userDisplayName}
 | 
			
		||||
                      </div>
 | 
			
		||||
                  </div>`
 | 
			
		||||
                : nothing}
 | 
			
		||||
            <img
 | 
			
		||||
                class="pf-c-avatar"
 | 
			
		||||
                src=${ifDefined(this.me?.user.avatar)}
 | 
			
		||||
                alt="${msg("Avatar image")}"
 | 
			
		||||
            />
 | 
			
		||||
            ${this.renderAvatar()}
 | 
			
		||||
        </div>`;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,165 +1,127 @@
 | 
			
		||||
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
 | 
			
		||||
import { globalAK } from "@goauthentik/common/global";
 | 
			
		||||
import { UIConfig } from "@goauthentik/common/ui/config";
 | 
			
		||||
import { adaptCSS } from "@goauthentik/common/utils";
 | 
			
		||||
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet";
 | 
			
		||||
import {
 | 
			
		||||
    StyleSheetInit,
 | 
			
		||||
    StyleSheetParent,
 | 
			
		||||
    appendStyleSheet,
 | 
			
		||||
    createStyleSheetUnsafe,
 | 
			
		||||
    removeStyleSheet,
 | 
			
		||||
    resolveStyleSheetParent,
 | 
			
		||||
} from "@goauthentik/common/stylesheets";
 | 
			
		||||
import { ResolvedUITheme, createUIThemeEffect, resolveUITheme } from "@goauthentik/common/theme";
 | 
			
		||||
import { type ThemedElement } from "@goauthentik/common/theme";
 | 
			
		||||
 | 
			
		||||
import { localized } from "@lit/localize";
 | 
			
		||||
import { LitElement, ReactiveElement } from "lit";
 | 
			
		||||
import { CSSResultGroup, CSSResultOrNative, LitElement } from "lit";
 | 
			
		||||
import { property } from "lit/decorators.js";
 | 
			
		||||
 | 
			
		||||
import AKGlobal from "@goauthentik/common/styles/authentik.css";
 | 
			
		||||
import OneDark from "@goauthentik/common/styles/one-dark.css";
 | 
			
		||||
import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
 | 
			
		||||
 | 
			
		||||
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
 | 
			
		||||
import { CurrentBrand, UiThemeEnum } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
type AkInterface = HTMLElement & {
 | 
			
		||||
    getTheme: () => Promise<UiThemeEnum>;
 | 
			
		||||
    brand?: CurrentBrand;
 | 
			
		||||
    uiConfig?: UIConfig;
 | 
			
		||||
    config?: Config;
 | 
			
		||||
    get activeTheme(): UiThemeEnum | undefined;
 | 
			
		||||
};
 | 
			
		||||
// Re-export the theme helpers
 | 
			
		||||
export { rootInterface } from "@goauthentik/common/theme";
 | 
			
		||||
 | 
			
		||||
export const rootInterface = <T extends AkInterface>(): T | undefined =>
 | 
			
		||||
    (document.body.querySelector("[data-ak-interface-root]") as T) ?? undefined;
 | 
			
		||||
 | 
			
		||||
export const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)";
 | 
			
		||||
 | 
			
		||||
// Ensure themes are converted to a static instance of CSS Stylesheet, otherwise the
 | 
			
		||||
// when changing themes we might not remove the correct css stylesheet instance.
 | 
			
		||||
const _darkTheme = ensureCSSStyleSheet(ThemeDark);
 | 
			
		||||
export interface AKElementInit {
 | 
			
		||||
    brand?: Partial<CurrentBrand>;
 | 
			
		||||
    styleParents?: StyleSheetParent[];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@localized()
 | 
			
		||||
export class AKElement extends LitElement {
 | 
			
		||||
    _mediaMatcher?: MediaQueryList;
 | 
			
		||||
    _mediaMatcherHandler?: (ev?: MediaQueryListEvent) => void;
 | 
			
		||||
    _activeTheme?: UiThemeEnum;
 | 
			
		||||
 | 
			
		||||
    get activeTheme(): UiThemeEnum | undefined {
 | 
			
		||||
        return this._activeTheme;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setInitialStyles(root: DocumentOrShadowRoot) {
 | 
			
		||||
        const styleRoot: DocumentOrShadowRoot = (
 | 
			
		||||
            "ShadyDOM" in window ? document : root
 | 
			
		||||
        ) as DocumentOrShadowRoot;
 | 
			
		||||
        styleRoot.adoptedStyleSheets = adaptCSS([
 | 
			
		||||
            ...styleRoot.adoptedStyleSheets,
 | 
			
		||||
            ensureCSSStyleSheet(AKGlobal),
 | 
			
		||||
            ensureCSSStyleSheet(OneDark),
 | 
			
		||||
        ]);
 | 
			
		||||
        this._initTheme(styleRoot);
 | 
			
		||||
        this._initCustomCSS(styleRoot);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected createRenderRoot() {
 | 
			
		||||
        this.fixElementStyles();
 | 
			
		||||
        const root = super.createRenderRoot();
 | 
			
		||||
        this.setInitialStyles(root as unknown as DocumentOrShadowRoot);
 | 
			
		||||
        return root;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getTheme(): Promise<UiThemeEnum> {
 | 
			
		||||
        return rootInterface()?.getTheme() || UiThemeEnum.Automatic;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fixElementStyles() {
 | 
			
		||||
        // Ensure all style sheets being passed are really style sheets.
 | 
			
		||||
        (this.constructor as typeof ReactiveElement).elementStyles = (
 | 
			
		||||
            this.constructor as typeof ReactiveElement
 | 
			
		||||
        ).elementStyles.map(ensureCSSStyleSheet);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _initTheme(root: DocumentOrShadowRoot): Promise<void> {
 | 
			
		||||
        // Early activate theme based on media query to prevent light flash
 | 
			
		||||
        // when dark is preferred
 | 
			
		||||
        this._applyTheme(root, globalAK().brand.uiTheme);
 | 
			
		||||
        this._applyTheme(root, await this.getTheme());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _initCustomCSS(root: DocumentOrShadowRoot): Promise<void> {
 | 
			
		||||
        const brand = globalAK().brand;
 | 
			
		||||
        if (!brand) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const sheet = await new CSSStyleSheet().replace(brand.brandingCustomCss);
 | 
			
		||||
        root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _applyTheme(root: DocumentOrShadowRoot, theme?: UiThemeEnum): void {
 | 
			
		||||
        if (!theme) {
 | 
			
		||||
            theme = UiThemeEnum.Automatic;
 | 
			
		||||
        }
 | 
			
		||||
        if (theme === UiThemeEnum.Automatic) {
 | 
			
		||||
            // Create a media matcher to automatically switch the theme depending on
 | 
			
		||||
            // prefers-color-scheme
 | 
			
		||||
            if (!this._mediaMatcher) {
 | 
			
		||||
                this._mediaMatcher = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT);
 | 
			
		||||
                this._mediaMatcherHandler = (ev?: MediaQueryListEvent) => {
 | 
			
		||||
                    const theme =
 | 
			
		||||
                        ev?.matches || this._mediaMatcher?.matches
 | 
			
		||||
                            ? UiThemeEnum.Light
 | 
			
		||||
                            : UiThemeEnum.Dark;
 | 
			
		||||
                    this._activateTheme(theme, root);
 | 
			
		||||
                };
 | 
			
		||||
                this._mediaMatcherHandler(undefined);
 | 
			
		||||
                this._mediaMatcher.addEventListener("change", this._mediaMatcherHandler);
 | 
			
		||||
            }
 | 
			
		||||
            return;
 | 
			
		||||
        } else if (this._mediaMatcher && this._mediaMatcherHandler) {
 | 
			
		||||
            // Theme isn't automatic and we have a matcher configured, remove the matcher
 | 
			
		||||
            // to prevent changes
 | 
			
		||||
            this._mediaMatcher.removeEventListener("change", this._mediaMatcherHandler);
 | 
			
		||||
            this._mediaMatcher = undefined;
 | 
			
		||||
        }
 | 
			
		||||
        this._activateTheme(theme, root);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    static themeToStylesheet(theme?: UiThemeEnum): CSSStyleSheet | undefined {
 | 
			
		||||
        if (theme === UiThemeEnum.Dark) {
 | 
			
		||||
            return _darkTheme;
 | 
			
		||||
        }
 | 
			
		||||
        return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
export class AKElement extends LitElement implements ThemedElement {
 | 
			
		||||
    /**
 | 
			
		||||
     * Directly activate a given theme, accepts multiple document/ShadowDOMs to apply the stylesheet
 | 
			
		||||
     * to. The stylesheets are applied to each DOM in order. Does nothing if the given theme is already active.
 | 
			
		||||
     * The resolved theme of the current element.
 | 
			
		||||
     *
 | 
			
		||||
     * @remarks
 | 
			
		||||
     *
 | 
			
		||||
     * Unlike the browser's current color scheme, this is a value that can be
 | 
			
		||||
     * resolved to a specific theme, i.e. dark or light.
 | 
			
		||||
     */
 | 
			
		||||
    _activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]) {
 | 
			
		||||
        if (theme === this._activeTheme) {
 | 
			
		||||
            return;
 | 
			
		||||
    @property({
 | 
			
		||||
        attribute: "theme",
 | 
			
		||||
        type: String,
 | 
			
		||||
        reflect: true,
 | 
			
		||||
    })
 | 
			
		||||
    public activeTheme: ResolvedUITheme;
 | 
			
		||||
 | 
			
		||||
    protected static finalizeStyles(styles?: CSSResultGroup): CSSResultOrNative[] {
 | 
			
		||||
        // Ensure all style sheets being passed are really style sheets.
 | 
			
		||||
        const baseStyles: StyleSheetInit[] = [AKGlobal, OneDark];
 | 
			
		||||
 | 
			
		||||
        if (!styles) return baseStyles.map(createStyleSheetUnsafe);
 | 
			
		||||
 | 
			
		||||
        if (Array.isArray(styles)) {
 | 
			
		||||
            return [
 | 
			
		||||
                //---
 | 
			
		||||
                ...(styles as unknown as CSSResultOrNative[]),
 | 
			
		||||
                ...baseStyles,
 | 
			
		||||
            ].flatMap(createStyleSheetUnsafe);
 | 
			
		||||
        }
 | 
			
		||||
        // Make sure we only get to this callback once we've picked a concise theme choice
 | 
			
		||||
        this.dispatchEvent(
 | 
			
		||||
            new CustomEvent(EVENT_THEME_CHANGE, {
 | 
			
		||||
                bubbles: true,
 | 
			
		||||
                composed: true,
 | 
			
		||||
                detail: theme,
 | 
			
		||||
            }),
 | 
			
		||||
        return [styles, ...baseStyles].map(createStyleSheetUnsafe);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    constructor(init?: AKElementInit) {
 | 
			
		||||
        super();
 | 
			
		||||
 | 
			
		||||
        const config = globalAK();
 | 
			
		||||
        const { brand = config.brand, styleParents = [] } = init || {};
 | 
			
		||||
 | 
			
		||||
        this.activeTheme = resolveUITheme(brand?.uiTheme);
 | 
			
		||||
        this.#styleParents = styleParents;
 | 
			
		||||
 | 
			
		||||
        this.#customCSSStyleSheet = brand?.brandingCustomCss
 | 
			
		||||
            ? createStyleSheetUnsafe(brand.brandingCustomCss)
 | 
			
		||||
            : null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #styleParents: StyleSheetParent[] = [];
 | 
			
		||||
    #customCSSStyleSheet: CSSStyleSheet | null;
 | 
			
		||||
    #darkThemeStyleSheet: CSSStyleSheet | null = null;
 | 
			
		||||
 | 
			
		||||
    #themeAbortController: AbortController | null = null;
 | 
			
		||||
 | 
			
		||||
    public disconnectedCallback(): void {
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
        this.#themeAbortController?.abort();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    protected createRenderRoot(): HTMLElement | DocumentFragment {
 | 
			
		||||
        const renderRoot = super.createRenderRoot();
 | 
			
		||||
 | 
			
		||||
        const styleRoot = resolveStyleSheetParent(renderRoot);
 | 
			
		||||
        const styleParents = Array.from(
 | 
			
		||||
            new Set<StyleSheetParent>([styleRoot, ...this.#styleParents]),
 | 
			
		||||
        );
 | 
			
		||||
        this.setAttribute("theme", theme);
 | 
			
		||||
        const stylesheet = AKElement.themeToStylesheet(theme);
 | 
			
		||||
        const oldStylesheet = AKElement.themeToStylesheet(this._activeTheme);
 | 
			
		||||
        roots.forEach((root) => {
 | 
			
		||||
            if (stylesheet) {
 | 
			
		||||
                root.adoptedStyleSheets = [
 | 
			
		||||
                    ...root.adoptedStyleSheets,
 | 
			
		||||
                    ensureCSSStyleSheet(stylesheet),
 | 
			
		||||
 | 
			
		||||
        if (this.#customCSSStyleSheet) {
 | 
			
		||||
            console.debug(`authentik/element[${this.tagName.toLowerCase()}]: Adding custom CSS`);
 | 
			
		||||
 | 
			
		||||
            styleRoot.adoptedStyleSheets = [
 | 
			
		||||
                ...styleRoot.adoptedStyleSheets,
 | 
			
		||||
                this.#customCSSStyleSheet,
 | 
			
		||||
            ];
 | 
			
		||||
        }
 | 
			
		||||
            if (oldStylesheet) {
 | 
			
		||||
                root.adoptedStyleSheets = root.adoptedStyleSheets.filter(
 | 
			
		||||
                    (v) => v !== oldStylesheet,
 | 
			
		||||
 | 
			
		||||
        this.#themeAbortController = new AbortController();
 | 
			
		||||
 | 
			
		||||
        createUIThemeEffect(
 | 
			
		||||
            (currentUITheme) => {
 | 
			
		||||
                if (currentUITheme === UiThemeEnum.Dark) {
 | 
			
		||||
                    this.#darkThemeStyleSheet ||= createStyleSheetUnsafe(ThemeDark);
 | 
			
		||||
 | 
			
		||||
                    appendStyleSheet(this.#darkThemeStyleSheet, ...styleParents);
 | 
			
		||||
                } else if (this.#darkThemeStyleSheet) {
 | 
			
		||||
                    removeStyleSheet(this.#darkThemeStyleSheet, ...styleParents);
 | 
			
		||||
                    this.#darkThemeStyleSheet = null;
 | 
			
		||||
                }
 | 
			
		||||
                this.activeTheme = currentUITheme;
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
                signal: this.#themeAbortController.signal,
 | 
			
		||||
            },
 | 
			
		||||
        );
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        this._activeTheme = theme;
 | 
			
		||||
        this.requestUpdate();
 | 
			
		||||
 | 
			
		||||
        return renderRoot;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,6 @@
 | 
			
		||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
			
		||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
 | 
			
		||||
import { ThemedElement } from "@goauthentik/common/theme";
 | 
			
		||||
import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts";
 | 
			
		||||
import type { ReactiveElementHost } from "@goauthentik/elements/types.js";
 | 
			
		||||
 | 
			
		||||
@ -9,14 +10,12 @@ import type { ReactiveController } from "lit";
 | 
			
		||||
import type { CurrentBrand } from "@goauthentik/api";
 | 
			
		||||
import { CoreApi } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
import type { AkInterface } from "./Interface";
 | 
			
		||||
 | 
			
		||||
export class BrandContextController implements ReactiveController {
 | 
			
		||||
    host!: ReactiveElementHost<AkInterface>;
 | 
			
		||||
    host!: ReactiveElementHost<ThemedElement>;
 | 
			
		||||
 | 
			
		||||
    context!: ContextProvider<{ __context__: CurrentBrand | undefined }>;
 | 
			
		||||
 | 
			
		||||
    constructor(host: ReactiveElementHost<AkInterface>) {
 | 
			
		||||
    constructor(host: ReactiveElementHost<ThemedElement>) {
 | 
			
		||||
        this.host = host;
 | 
			
		||||
        this.context = new ContextProvider(this.host, {
 | 
			
		||||
            context: authentikBrandContext,
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 | 
			
		||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
 | 
			
		||||
import { globalAK } from "@goauthentik/common/global";
 | 
			
		||||
import { ThemedElement } from "@goauthentik/common/theme";
 | 
			
		||||
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
 | 
			
		||||
import type { ReactiveElementHost } from "@goauthentik/elements/types.js";
 | 
			
		||||
 | 
			
		||||
@ -10,14 +11,12 @@ import type { ReactiveController } from "lit";
 | 
			
		||||
import type { Config } from "@goauthentik/api";
 | 
			
		||||
import { RootApi } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
import type { AkInterface } from "./Interface";
 | 
			
		||||
 | 
			
		||||
export class ConfigContextController implements ReactiveController {
 | 
			
		||||
    host!: ReactiveElementHost<AkInterface>;
 | 
			
		||||
    host!: ReactiveElementHost<ThemedElement>;
 | 
			
		||||
 | 
			
		||||
    context!: ContextProvider<{ __context__: Config | undefined }>;
 | 
			
		||||
 | 
			
		||||
    constructor(host: ReactiveElementHost<AkInterface>) {
 | 
			
		||||
    constructor(host: ReactiveElementHost<ThemedElement>) {
 | 
			
		||||
        this.host = host;
 | 
			
		||||
        this.context = new ContextProvider(this.host, {
 | 
			
		||||
            context: authentikConfigContext,
 | 
			
		||||
 | 
			
		||||
@ -1,107 +1,85 @@
 | 
			
		||||
import { UIConfig, uiConfig } from "@goauthentik/common/ui/config";
 | 
			
		||||
import {
 | 
			
		||||
    appendStyleSheet,
 | 
			
		||||
    createStyleSheetUnsafe,
 | 
			
		||||
    resolveStyleSheetParent,
 | 
			
		||||
} from "@goauthentik/common/stylesheets";
 | 
			
		||||
import { ThemedElement } from "@goauthentik/common/theme";
 | 
			
		||||
import { UIConfig } from "@goauthentik/common/ui/config";
 | 
			
		||||
import { AKElement, AKElementInit } from "@goauthentik/elements/Base";
 | 
			
		||||
import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController";
 | 
			
		||||
import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
 | 
			
		||||
import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet";
 | 
			
		||||
 | 
			
		||||
import { state } from "lit/decorators.js";
 | 
			
		||||
 | 
			
		||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
 | 
			
		||||
 | 
			
		||||
import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api";
 | 
			
		||||
import { UiThemeEnum } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
import { AKElement, rootInterface } from "../Base";
 | 
			
		||||
import { BrandContextController } from "./BrandContextController";
 | 
			
		||||
import { ConfigContextController } from "./ConfigContextController";
 | 
			
		||||
import { EnterpriseContextController } from "./EnterpriseContextController";
 | 
			
		||||
 | 
			
		||||
export type AkInterface = HTMLElement & {
 | 
			
		||||
    getTheme: () => Promise<UiThemeEnum>;
 | 
			
		||||
    brand?: CurrentBrand;
 | 
			
		||||
    uiConfig?: UIConfig;
 | 
			
		||||
    config?: Config;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const brandContext = Symbol("brandContext");
 | 
			
		||||
const configContext = Symbol("configContext");
 | 
			
		||||
const modalController = Symbol("modalController");
 | 
			
		||||
const versionContext = Symbol("versionContext");
 | 
			
		||||
 | 
			
		||||
export class Interface extends AKElement implements AkInterface {
 | 
			
		||||
    [brandContext]!: BrandContextController;
 | 
			
		||||
export abstract class Interface extends AKElement implements ThemedElement {
 | 
			
		||||
    protected static readonly PFBaseStyleSheet = createStyleSheetUnsafe(PFBase);
 | 
			
		||||
 | 
			
		||||
    [configContext]!: ConfigContextController;
 | 
			
		||||
    [brandContext]: BrandContextController;
 | 
			
		||||
 | 
			
		||||
    [modalController]!: ModalOrchestrationController;
 | 
			
		||||
    [configContext]: ConfigContextController;
 | 
			
		||||
 | 
			
		||||
    [modalController]: ModalOrchestrationController;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    uiConfig?: UIConfig;
 | 
			
		||||
    public config?: Config;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    config?: Config;
 | 
			
		||||
    public brand?: CurrentBrand;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    brand?: CurrentBrand;
 | 
			
		||||
    constructor({ styleParents = [], ...init }: AKElementInit = {}) {
 | 
			
		||||
        const styleParent = resolveStyleSheetParent(document);
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)];
 | 
			
		||||
        this._initContexts();
 | 
			
		||||
        this.dataset.akInterfaceRoot = "true";
 | 
			
		||||
    }
 | 
			
		||||
        super({
 | 
			
		||||
            ...init,
 | 
			
		||||
            styleParents: [styleParent, ...styleParents],
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
 | 
			
		||||
 | 
			
		||||
        appendStyleSheet(Interface.PFBaseStyleSheet, styleParent);
 | 
			
		||||
 | 
			
		||||
    _initContexts() {
 | 
			
		||||
        this[brandContext] = new BrandContextController(this);
 | 
			
		||||
        this[configContext] = new ConfigContextController(this);
 | 
			
		||||
        this[modalController] = new ModalOrchestrationController(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _activateTheme(theme: UiThemeEnum, ...roots: DocumentOrShadowRoot[]): void {
 | 
			
		||||
        if (theme === this._activeTheme) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        console.debug(
 | 
			
		||||
            `authentik/interface[${rootInterface()?.tagName.toLowerCase()}]: Enabling theme ${theme}`,
 | 
			
		||||
        );
 | 
			
		||||
        // Special case for root interfaces, as they need to modify the global document CSS too
 | 
			
		||||
        // Instead of calling ._activateTheme() twice, we insert the root document in the call
 | 
			
		||||
        // since multiple calls to ._activateTheme() would not do anything after the first call
 | 
			
		||||
        // as the theme is already enabled.
 | 
			
		||||
        roots.unshift(document as unknown as DocumentOrShadowRoot);
 | 
			
		||||
        super._activateTheme(theme, ...roots);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    async getTheme(): Promise<UiThemeEnum> {
 | 
			
		||||
        if (!this.uiConfig) {
 | 
			
		||||
            this.uiConfig = await uiConfig();
 | 
			
		||||
        }
 | 
			
		||||
        return this.uiConfig.theme?.base || UiThemeEnum.Automatic;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export type AkAuthenticatedInterface = AkInterface & {
 | 
			
		||||
export interface AkAuthenticatedInterface extends ThemedElement {
 | 
			
		||||
    licenseSummary?: LicenseSummary;
 | 
			
		||||
    version?: Version;
 | 
			
		||||
};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const enterpriseContext = Symbol("enterpriseContext");
 | 
			
		||||
 | 
			
		||||
export class AuthenticatedInterface extends Interface {
 | 
			
		||||
export class AuthenticatedInterface extends Interface implements AkAuthenticatedInterface {
 | 
			
		||||
    [enterpriseContext]!: EnterpriseContextController;
 | 
			
		||||
    [versionContext]!: VersionContextController;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    licenseSummary?: LicenseSummary;
 | 
			
		||||
    public uiConfig?: UIConfig;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    version?: Version;
 | 
			
		||||
    public licenseSummary?: LicenseSummary;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
    }
 | 
			
		||||
    @state()
 | 
			
		||||
    public version?: Version;
 | 
			
		||||
 | 
			
		||||
    constructor(init?: AKElementInit) {
 | 
			
		||||
        super(init);
 | 
			
		||||
 | 
			
		||||
    _initContexts(): void {
 | 
			
		||||
        super._initContexts();
 | 
			
		||||
        this[enterpriseContext] = new EnterpriseContextController(this);
 | 
			
		||||
        this[versionContext] = new VersionContextController(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import { authentikVersionContext } from "@goauthentik/elements/AuthentikContexts";
 | 
			
		||||
 | 
			
		||||
import { consume } from "@lit/context";
 | 
			
		||||
import { Constructor } from "@lit/reactive-element/decorators/base";
 | 
			
		||||
import { Constructor } from "@lit/reactive-element/decorators/base.js";
 | 
			
		||||
import type { LitElement } from "lit";
 | 
			
		||||
 | 
			
		||||
import type { Version } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
@ -5,20 +5,23 @@ import {
 | 
			
		||||
} 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 { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config";
 | 
			
		||||
import { me } from "@goauthentik/common/users";
 | 
			
		||||
import "@goauthentik/components/ak-nav-buttons";
 | 
			
		||||
import { AKElement } from "@goauthentik/elements/Base";
 | 
			
		||||
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
 | 
			
		||||
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
 | 
			
		||||
import { themeImage } from "@goauthentik/elements/utils/images";
 | 
			
		||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
 | 
			
		||||
 | 
			
		||||
import { msg } from "@lit/localize";
 | 
			
		||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
 | 
			
		||||
import { CSSResult, LitElement, TemplateResult, css, html, nothing } from "lit";
 | 
			
		||||
import { customElement, property, state } from "lit/decorators.js";
 | 
			
		||||
 | 
			
		||||
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
 | 
			
		||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
 | 
			
		||||
import PFContent from "@patternfly/patternfly/components/Content/content.css";
 | 
			
		||||
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
 | 
			
		||||
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
 | 
			
		||||
import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css";
 | 
			
		||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
 | 
			
		||||
@ -26,34 +29,52 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
 | 
			
		||||
 | 
			
		||||
import { SessionUser } from "@goauthentik/api";
 | 
			
		||||
 | 
			
		||||
@customElement("ak-page-header")
 | 
			
		||||
export class PageHeader extends WithBrandConfig(AKElement) {
 | 
			
		||||
    @property()
 | 
			
		||||
    icon?: string;
 | 
			
		||||
//#region Page Navbar
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean })
 | 
			
		||||
    iconImage = false;
 | 
			
		||||
 | 
			
		||||
    @property()
 | 
			
		||||
    header = "";
 | 
			
		||||
 | 
			
		||||
    @property()
 | 
			
		||||
export interface PageNavbarDetails {
 | 
			
		||||
    header?: string;
 | 
			
		||||
    description?: string;
 | 
			
		||||
    icon?: string;
 | 
			
		||||
    iconImage?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean })
 | 
			
		||||
    hasIcon = true;
 | 
			
		||||
/**
 | 
			
		||||
 * A global navbar component at the top of the page.
 | 
			
		||||
 *
 | 
			
		||||
 * Internally, this component listens for the `ak-page-header` event, which is
 | 
			
		||||
 * dispatched by the `ak-page-header` component.
 | 
			
		||||
 */
 | 
			
		||||
@customElement("ak-page-navbar")
 | 
			
		||||
export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails {
 | 
			
		||||
    //#region Static Properties
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    me?: SessionUser;
 | 
			
		||||
    private static elementRef: AKPageNavbar | null = null;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    uiConfig!: UIConfig;
 | 
			
		||||
    static readonly setNavbarDetails = (detail: Partial<PageNavbarDetails>): void => {
 | 
			
		||||
        const { elementRef } = AKPageNavbar;
 | 
			
		||||
        if (!elementRef) {
 | 
			
		||||
            console.debug(
 | 
			
		||||
                `ak-page-header: Could not find ak-page-navbar, skipping event dispatch.`,
 | 
			
		||||
            );
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { header, description, icon, iconImage } = detail;
 | 
			
		||||
 | 
			
		||||
        elementRef.header = header;
 | 
			
		||||
        elementRef.description = description;
 | 
			
		||||
        elementRef.icon = icon;
 | 
			
		||||
        elementRef.iconImage = iconImage || false;
 | 
			
		||||
        elementRef.hasIcon = !!icon;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    static get styles(): CSSResult[] {
 | 
			
		||||
        return [
 | 
			
		||||
            PFBase,
 | 
			
		||||
            PFButton,
 | 
			
		||||
            PFPage,
 | 
			
		||||
            PFDrawer,
 | 
			
		||||
 | 
			
		||||
            PFNotificationBadge,
 | 
			
		||||
            PFContent,
 | 
			
		||||
            PFAvatar,
 | 
			
		||||
@ -63,127 +84,313 @@ export class PageHeader extends WithBrandConfig(AKElement) {
 | 
			
		||||
                    position: sticky;
 | 
			
		||||
                    top: 0;
 | 
			
		||||
                    z-index: var(--pf-global--ZIndex--lg);
 | 
			
		||||
                    --pf-c-page__header-tools--MarginRight: 0;
 | 
			
		||||
                    --ak-brand-logo-height: var(--pf-global--FontSize--4xl, 2.25rem);
 | 
			
		||||
                    --ak-brand-background-color: var(
 | 
			
		||||
                        --pf-c-page__sidebar--m-light--BackgroundColor
 | 
			
		||||
                    );
 | 
			
		||||
                }
 | 
			
		||||
                .bar {
 | 
			
		||||
 | 
			
		||||
                :host([theme="dark"]) {
 | 
			
		||||
                    --ak-brand-background-color: var(--pf-c-page__sidebar--BackgroundColor);
 | 
			
		||||
                    --pf-c-page__sidebar--BackgroundColor: var(--ak-dark-background-light);
 | 
			
		||||
                    color: var(--ak-dark-foreground);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                navbar {
 | 
			
		||||
                    border-bottom: var(--pf-global--BorderWidth--sm);
 | 
			
		||||
                    border-bottom-style: solid;
 | 
			
		||||
                    border-bottom-color: var(--pf-global--BorderColor--100);
 | 
			
		||||
                    background-color: var(--pf-c-page--BackgroundColor);
 | 
			
		||||
 | 
			
		||||
                    display: flex;
 | 
			
		||||
                    flex-direction: row;
 | 
			
		||||
                    min-height: 114px;
 | 
			
		||||
                    max-height: 114px;
 | 
			
		||||
                    background-color: var(--pf-c-page--BackgroundColor);
 | 
			
		||||
                    min-height: 6rem;
 | 
			
		||||
 | 
			
		||||
                    display: grid;
 | 
			
		||||
                    row-gap: var(--pf-global--spacer--sm);
 | 
			
		||||
                    column-gap: var(--pf-global--spacer--sm);
 | 
			
		||||
                    grid-template-columns: [brand] auto [toggle] auto [primary] 1fr [secondary] auto;
 | 
			
		||||
                    grid-template-rows: auto auto;
 | 
			
		||||
                    grid-template-areas:
 | 
			
		||||
                        "brand toggle primary secondary"
 | 
			
		||||
                        "brand toggle description secondary";
 | 
			
		||||
 | 
			
		||||
                    @media (max-width: 768px) {
 | 
			
		||||
                        row-gap: var(--pf-global--spacer--xs);
 | 
			
		||||
 | 
			
		||||
                        align-items: center;
 | 
			
		||||
                        grid-template-areas:
 | 
			
		||||
                            "toggle primary secondary"
 | 
			
		||||
                            "toggle description description";
 | 
			
		||||
                        justify-content: space-between;
 | 
			
		||||
                        width: 100%;
 | 
			
		||||
                    }
 | 
			
		||||
                .pf-c-page__main-section.pf-m-light {
 | 
			
		||||
                    background-color: transparent;
 | 
			
		||||
                }
 | 
			
		||||
                .pf-c-page__main-section {
 | 
			
		||||
                    flex-grow: 1;
 | 
			
		||||
                    flex-shrink: 1;
 | 
			
		||||
 | 
			
		||||
                .items {
 | 
			
		||||
                    display: block;
 | 
			
		||||
 | 
			
		||||
                    &.primary {
 | 
			
		||||
                        grid-column: primary;
 | 
			
		||||
                        grid-row: primary / description;
 | 
			
		||||
 | 
			
		||||
                        align-content: center;
 | 
			
		||||
                        padding-block: var(--pf-global--spacer--md);
 | 
			
		||||
 | 
			
		||||
                        @media (min-width: 426px) {
 | 
			
		||||
                            &.block-sibling {
 | 
			
		||||
                                padding-block-end: 0;
 | 
			
		||||
                                grid-row: primary;
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        @media (max-width: 768px) {
 | 
			
		||||
                            padding-block: var(--pf-global--spacer--sm);
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        .accent-icon {
 | 
			
		||||
                            height: 1em;
 | 
			
		||||
                            width: 1em;
 | 
			
		||||
 | 
			
		||||
                            @media (max-width: 768px) {
 | 
			
		||||
                                display: none;
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    &.page-description {
 | 
			
		||||
                        grid-area: description;
 | 
			
		||||
                        padding-block-end: var(--pf-global--spacer--md);
 | 
			
		||||
 | 
			
		||||
                        @media (max-width: 425px) {
 | 
			
		||||
                            display: none;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        @media (min-width: 769px) {
 | 
			
		||||
                            text-wrap: balance;
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    &.secondary {
 | 
			
		||||
                        grid-area: secondary;
 | 
			
		||||
                        flex: 0 0 auto;
 | 
			
		||||
                        justify-self: end;
 | 
			
		||||
                        padding-block: var(--pf-global--spacer--sm);
 | 
			
		||||
                        padding-inline-end: var(--pf-global--spacer--sm);
 | 
			
		||||
 | 
			
		||||
                        @media (min-width: 769px) {
 | 
			
		||||
                            align-content: center;
 | 
			
		||||
                            padding-block: var(--pf-global--spacer--md);
 | 
			
		||||
                            padding-inline-end: var(--pf-global--spacer--xl);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .brand {
 | 
			
		||||
                    grid-area: brand;
 | 
			
		||||
                    background-color: var(--ak-brand-background-color);
 | 
			
		||||
                    height: 100%;
 | 
			
		||||
                    width: var(--pf-c-page__sidebar--Width);
 | 
			
		||||
                    align-items: center;
 | 
			
		||||
                    padding-inline: var(--pf-global--spacer--sm);
 | 
			
		||||
 | 
			
		||||
                    display: flex;
 | 
			
		||||
                    flex-direction: column;
 | 
			
		||||
                    justify-content: center;
 | 
			
		||||
 | 
			
		||||
                    &.pf-m-collapsed {
 | 
			
		||||
                        display: none;
 | 
			
		||||
                    }
 | 
			
		||||
                img.pf-icon {
 | 
			
		||||
                    max-height: 24px;
 | 
			
		||||
 | 
			
		||||
                    @media (max-width: 1199px) {
 | 
			
		||||
                        display: none;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .sidebar-trigger {
 | 
			
		||||
                    grid-area: toggle;
 | 
			
		||||
                    height: 100%;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .logo {
 | 
			
		||||
                    flex: 0 0 auto;
 | 
			
		||||
                    height: var(--ak-brand-logo-height);
 | 
			
		||||
 | 
			
		||||
                    & img {
 | 
			
		||||
                        height: 100%;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .sidebar-trigger,
 | 
			
		||||
                .notification-trigger {
 | 
			
		||||
                    font-size: 24px;
 | 
			
		||||
                    font-size: 1.5rem;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .notification-trigger.has-notifications {
 | 
			
		||||
                    color: var(--pf-global--active-color--100);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                .page-title {
 | 
			
		||||
                    display: flex;
 | 
			
		||||
                    gap: var(--pf-global--spacer--xs);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                h1 {
 | 
			
		||||
                    display: flex;
 | 
			
		||||
                    flex-direction: row;
 | 
			
		||||
                    align-items: center !important;
 | 
			
		||||
                }
 | 
			
		||||
                .pf-c-page__header-tools {
 | 
			
		||||
                    flex-shrink: 0;
 | 
			
		||||
                }
 | 
			
		||||
                .pf-c-page__header-tools-group {
 | 
			
		||||
                    height: 100%;
 | 
			
		||||
                }
 | 
			
		||||
                :host([theme="dark"]) .pf-c-page__header-tools {
 | 
			
		||||
                    color: var(--ak-dark-foreground) !important;
 | 
			
		||||
                }
 | 
			
		||||
            `,
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        window.addEventListener(EVENT_WS_MESSAGE, () => {
 | 
			
		||||
            this.firstUpdated();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    //#endregion
 | 
			
		||||
 | 
			
		||||
    async firstUpdated() {
 | 
			
		||||
        this.me = await me();
 | 
			
		||||
        this.uiConfig = await uiConfig();
 | 
			
		||||
        this.uiConfig.navbar.userDisplay = UserDisplay.none;
 | 
			
		||||
    }
 | 
			
		||||
    //#region Properties
 | 
			
		||||
 | 
			
		||||
    setTitle(header?: string) {
 | 
			
		||||
    @property({ type: String })
 | 
			
		||||
    icon?: string;
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean })
 | 
			
		||||
    iconImage = false;
 | 
			
		||||
 | 
			
		||||
    @property({ type: String })
 | 
			
		||||
    header?: string;
 | 
			
		||||
 | 
			
		||||
    @property({ type: String })
 | 
			
		||||
    description?: string;
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean })
 | 
			
		||||
    hasIcon = true;
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean })
 | 
			
		||||
    open = true;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    session?: SessionUser;
 | 
			
		||||
 | 
			
		||||
    @state()
 | 
			
		||||
    uiConfig!: UIConfig;
 | 
			
		||||
 | 
			
		||||
    //#endregion
 | 
			
		||||
 | 
			
		||||
    //#region Private Methods
 | 
			
		||||
 | 
			
		||||
    #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 !== "") {
 | 
			
		||||
        if (header) {
 | 
			
		||||
            title = `${header} - ${title}`;
 | 
			
		||||
        }
 | 
			
		||||
        document.title = title;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    willUpdate() {
 | 
			
		||||
        // Always update title, even if there's no header value set,
 | 
			
		||||
        // as in that case we still need to return to the generic title
 | 
			
		||||
        this.setTitle(this.header);
 | 
			
		||||
    }
 | 
			
		||||
    #toggleSidebar() {
 | 
			
		||||
        this.open = !this.open;
 | 
			
		||||
 | 
			
		||||
    renderIcon() {
 | 
			
		||||
        if (this.icon) {
 | 
			
		||||
            if (this.iconImage && !this.icon.startsWith("fa://")) {
 | 
			
		||||
                return html`<img class="pf-icon" src="${this.icon}" alt="page icon" />`;
 | 
			
		||||
            }
 | 
			
		||||
            const icon = this.icon.replaceAll("fa://", "fa ");
 | 
			
		||||
            return html`<i class=${icon}></i>`;
 | 
			
		||||
        }
 | 
			
		||||
        return nothing;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(): TemplateResult {
 | 
			
		||||
        return html`<div class="bar">
 | 
			
		||||
            <button
 | 
			
		||||
                class="sidebar-trigger pf-c-button pf-m-plain"
 | 
			
		||||
                @click=${() => {
 | 
			
		||||
        this.dispatchEvent(
 | 
			
		||||
            new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
 | 
			
		||||
                bubbles: true,
 | 
			
		||||
                composed: true,
 | 
			
		||||
            }),
 | 
			
		||||
        );
 | 
			
		||||
                }}
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //#endregion
 | 
			
		||||
 | 
			
		||||
    //#region Lifecycle
 | 
			
		||||
 | 
			
		||||
    public connectedCallback(): void {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
        AKPageNavbar.elementRef = this;
 | 
			
		||||
 | 
			
		||||
        window.addEventListener(EVENT_WS_MESSAGE, () => {
 | 
			
		||||
            this.firstUpdated();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public disconnectedCallback(): void {
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
        AKPageNavbar.elementRef = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    public async firstUpdated() {
 | 
			
		||||
        this.session = await me();
 | 
			
		||||
        this.uiConfig = getConfigForUser(this.session.user);
 | 
			
		||||
        this.uiConfig.navbar.userDisplay = UserDisplay.none;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    willUpdate() {
 | 
			
		||||
        // Always update title, even if there's no header value set,
 | 
			
		||||
        // as in that case we still need to return to the generic title
 | 
			
		||||
        this.#setTitle(this.header);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //#endregion
 | 
			
		||||
 | 
			
		||||
    //#region Render
 | 
			
		||||
 | 
			
		||||
    renderIcon() {
 | 
			
		||||
        if (this.icon) {
 | 
			
		||||
            if (this.iconImage && !this.icon.startsWith("fa://")) {
 | 
			
		||||
                return html`<img class="accent-icon pf-icon" src="${this.icon}" alt="page icon" />`;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const icon = this.icon.replaceAll("fa://", "fa ");
 | 
			
		||||
 | 
			
		||||
            return html`<i class="accent-icon ${icon}"></i>`;
 | 
			
		||||
        }
 | 
			
		||||
        return nothing;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render(): TemplateResult {
 | 
			
		||||
        return html`<navbar aria-label="Main" class="navbar">
 | 
			
		||||
                <aside class="brand ${this.open ? "" : "pf-m-collapsed"}">
 | 
			
		||||
                    <a href="#/">
 | 
			
		||||
                        <div class="logo">
 | 
			
		||||
                            <img
 | 
			
		||||
                                src=${themeImage(
 | 
			
		||||
                                    this.brand?.brandingLogo ?? DefaultBrand.brandingLogo,
 | 
			
		||||
                                )}
 | 
			
		||||
                                alt="${msg("authentik Logo")}"
 | 
			
		||||
                                loading="lazy"
 | 
			
		||||
                            />
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </a>
 | 
			
		||||
                </aside>
 | 
			
		||||
                <button
 | 
			
		||||
                    class="sidebar-trigger pf-c-button pf-m-plain"
 | 
			
		||||
                    @click=${this.#toggleSidebar}
 | 
			
		||||
                    aria-label=${msg("Toggle sidebar")}
 | 
			
		||||
                    aria-expanded=${this.open ? "true" : "false"}
 | 
			
		||||
                >
 | 
			
		||||
                    <i class="fas fa-bars"></i>
 | 
			
		||||
                </button>
 | 
			
		||||
            <section class="pf-c-page__main-section pf-m-light">
 | 
			
		||||
                <div class="pf-c-content">
 | 
			
		||||
                    <h1>
 | 
			
		||||
 | 
			
		||||
                <section
 | 
			
		||||
                    class="items primary pf-c-content ${this.description ? "block-sibling" : ""}"
 | 
			
		||||
                >
 | 
			
		||||
                    <h1 class="page-title">
 | 
			
		||||
                        ${this.hasIcon
 | 
			
		||||
                            ? html`<slot name="icon">${this.renderIcon()}</slot> `
 | 
			
		||||
                            ? html`<slot name="icon">${this.renderIcon()}</slot>`
 | 
			
		||||
                            : nothing}
 | 
			
		||||
                        <slot name="header">${this.header}</slot>
 | 
			
		||||
                        ${this.header}
 | 
			
		||||
                    </h1>
 | 
			
		||||
                    ${this.description ? html`<p>${this.description}</p>` : html``}
 | 
			
		||||
                </div>
 | 
			
		||||
                </section>
 | 
			
		||||
            <div class="pf-c-page__header-tools">
 | 
			
		||||
                ${this.description
 | 
			
		||||
                    ? html`<section class="items page-description pf-c-content">
 | 
			
		||||
                          <p>${this.description}</p>
 | 
			
		||||
                      </section>`
 | 
			
		||||
                    : nothing}
 | 
			
		||||
 | 
			
		||||
                <section class="items secondary">
 | 
			
		||||
                    <div class="pf-c-page__header-tools-group">
 | 
			
		||||
                    <ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.me}>
 | 
			
		||||
                        <ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.session}>
 | 
			
		||||
                            <a
 | 
			
		||||
                                class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
 | 
			
		||||
                                href="${globalAK().api.base}if/user/"
 | 
			
		||||
@ -193,13 +400,76 @@ export class PageHeader extends WithBrandConfig(AKElement) {
 | 
			
		||||
                            </a>
 | 
			
		||||
                        </ak-nav-buttons>
 | 
			
		||||
                    </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>`;
 | 
			
		||||
                </section>
 | 
			
		||||
            </navbar>
 | 
			
		||||
            <slot></slot>`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    //#endregion
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
//#region Page Header
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * A page header component, used to display the page title and description.
 | 
			
		||||
 *
 | 
			
		||||
 * Internally, this component dispatches the `ak-page-header` event, which is
 | 
			
		||||
 * listened to by the `ak-page-navbar` component.
 | 
			
		||||
 *
 | 
			
		||||
 * @singleton
 | 
			
		||||
 */
 | 
			
		||||
@customElement("ak-page-header")
 | 
			
		||||
export class AKPageHeader extends LitElement implements PageNavbarDetails {
 | 
			
		||||
    @property({ type: String })
 | 
			
		||||
    header?: string;
 | 
			
		||||
 | 
			
		||||
    @property({ type: String })
 | 
			
		||||
    description?: string;
 | 
			
		||||
 | 
			
		||||
    @property({ type: String })
 | 
			
		||||
    icon?: string;
 | 
			
		||||
 | 
			
		||||
    @property({ type: Boolean })
 | 
			
		||||
    iconImage = false;
 | 
			
		||||
 | 
			
		||||
    static get styles(): CSSResult[] {
 | 
			
		||||
        return [
 | 
			
		||||
            css`
 | 
			
		||||
                :host {
 | 
			
		||||
                    display: none;
 | 
			
		||||
                }
 | 
			
		||||
            `,
 | 
			
		||||
        ];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    connectedCallback(): void {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
 | 
			
		||||
        AKPageNavbar.setNavbarDetails({
 | 
			
		||||
            header: this.header,
 | 
			
		||||
            description: this.description,
 | 
			
		||||
            icon: this.icon,
 | 
			
		||||
            iconImage: this.iconImage,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updated(): void {
 | 
			
		||||
        AKPageNavbar.setNavbarDetails({
 | 
			
		||||
            header: this.header,
 | 
			
		||||
            description: this.description,
 | 
			
		||||
            icon: this.icon,
 | 
			
		||||
            iconImage: this.iconImage,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//#endregion
 | 
			
		||||
 | 
			
		||||
declare global {
 | 
			
		||||
    interface HTMLElementTagNameMap {
 | 
			
		||||
        "ak-page-header": PageHeader;
 | 
			
		||||
        "ak-page-header": AKPageHeader;
 | 
			
		||||
        "ak-page-navbar": AKPageNavbar;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ import { AkControlElement } from "@goauthentik/elements/AkControlElement";
 | 
			
		||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
 | 
			
		||||
 | 
			
		||||
import { msg } from "@lit/localize";
 | 
			
		||||
import { PropertyValues } from "@lit/reactive-element/reactive-element";
 | 
			
		||||
import { PropertyValues } from "@lit/reactive-element";
 | 
			
		||||
import { TemplateResult, css, html } from "lit";
 | 
			
		||||
import { customElement, property, queryAll, state } from "lit/decorators.js";
 | 
			
		||||
import { map } from "lit/directives/map.js";
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user