Compare commits

..

4 Commits

Author SHA1 Message Date
Tana M Berry
b1cb339f3a tweak 2025-08-04 15:13:41 -05:00
Tana M Berry
e124e21119 Merge branch 'main' into docs-remove-phrase 2025-08-04 15:06:03 -05:00
Tana M Berry
dc0c7a858a tweak to bump build 2025-08-04 13:53:50 -05:00
Tana M Berry
3fddbb918e removed phrase 2025-08-04 13:32:08 -05:00
106 changed files with 1301 additions and 4514 deletions

View File

@@ -4,7 +4,7 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
GITHUB_OUTPUT=/dev/stdout \
GITHUB_REF=ref \
GITHUB_SHA=sha \
IMAGE_NAME=ghcr.io/goauthentik/server,authentik/server \
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
GITHUB_REPOSITORY=goauthentik/authentik \
python $SCRIPT_DIR/push_vars.py
@@ -12,7 +12,7 @@ GITHUB_OUTPUT=/dev/stdout \
GITHUB_OUTPUT=/dev/stdout \
GITHUB_REF=ref \
GITHUB_SHA=sha \
IMAGE_NAME=ghcr.io/goauthentik/server,authentik/server \
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
GITHUB_REPOSITORY=goauthentik/authentik \
DOCKER_USERNAME=foo \
python $SCRIPT_DIR/push_vars.py

View File

@@ -66,7 +66,7 @@ jobs:
- build
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v5
- uses: actions/download-artifact@v4
with:
name: api-docs
path: website/api/build

View File

@@ -16,7 +16,7 @@ jobs:
id-token: write
attestations: write
with:
image_name: ghcr.io/goauthentik/server,authentik/server
image_name: ghcr.io/goauthentik/server,beryju/authentik
release: true
registry_dockerhub: true
registry_ghcr: true
@@ -38,7 +38,7 @@ jobs:
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/docs
- name: Login to GitHub Container Registry
@@ -92,9 +92,9 @@ jobs:
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/${{ matrix.type }},authentik/${{ matrix.type }}
image-name: ghcr.io/goauthentik/${{ matrix.type }},beryju/authentik-${{ matrix.type }}
- name: make empty clients
run: |
mkdir -p ./gen-ts-api
@@ -102,8 +102,8 @@ jobs:
- name: Docker Login Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_CORP_USERNAME }}
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -220,7 +220,7 @@ jobs:
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/server
- name: Get static files from docker image

View File

@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.8.5 AS uv
FROM ghcr.io/astral-sh/uv:0.8.4 AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base

View File

@@ -9,8 +9,8 @@
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/goauthentik/authentik/ci-outpost.yml?branch=main&label=outpost%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/goauthentik/authentik/ci-web.yml?branch=main&label=web%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
[![Code Coverage](https://img.shields.io/codecov/c/gh/goauthentik/authentik?style=for-the-badge)](https://codecov.io/gh/goauthentik/authentik)
![Docker pulls](https://img.shields.io/docker/pulls/authentik/server.svg?style=for-the-badge)
![Latest version](https://img.shields.io/docker/v/authentik/server?sort=semver&style=for-the-badge)
![Docker pulls](https://img.shields.io/docker/pulls/beryju/authentik.svg?style=for-the-badge)
![Latest version](https://img.shields.io/docker/v/beryju/authentik?sort=semver&style=for-the-badge)
[![](https://img.shields.io/badge/Help%20translate-transifex-blue?style=for-the-badge)](https://www.transifex.com/authentik/authentik/)
## What is authentik?

View File

@@ -301,7 +301,6 @@ class SessionEndStage(ChallengeStageView):
"flow_slug": self.request.brand.flow_invalidation.slug,
},
)
return SessionEndChallenge(data=data)
# This can never be reached since this challenge is created on demand and only the

View File

@@ -70,7 +70,6 @@ class OAuth2ProviderSerializer(ProviderSerializer):
"signing_key",
"encryption_key",
"redirect_uris",
"backchannel_logout_uri",
"sub_mode",
"property_mappings",
"issuer_mode",

View File

@@ -1,8 +1,5 @@
"""OAuth/OpenID Constants"""
from django.db import models
from django.utils.translation import gettext_lazy as _
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
GRANT_TYPE_IMPLICIT = "implicit"
GRANT_TYPE_REFRESH_TOKEN = "refresh_token" # nosec
@@ -54,23 +51,3 @@ AMR_MFA = "mfa"
AMR_OTP = "otp"
AMR_WEBAUTHN = "user"
AMR_SMART_CARD = "sc"
class SubModes(models.TextChoices):
"""Mode after which 'sub' attribute is generated, for compatibility reasons"""
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
USER_ID = "user_id", _("Based on user ID")
USER_UUID = "user_uuid", _("Based on user UUID")
USER_USERNAME = "user_username", _("Based on the username")
USER_EMAIL = (
"user_email",
_("Based on the User's Email. This is recommended over the UPN method."),
)
USER_UPN = (
"user_upn",
_(
"Based on the User's UPN, only works if user has a 'upn' attribute set. "
"Use this method only if you have different UPN and Mail domains."
),
)

View File

@@ -4,8 +4,10 @@ from dataclasses import asdict, dataclass, field
from hashlib import sha256
from typing import TYPE_CHECKING, Any
from django.db import models
from django.http import HttpRequest
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from authentik.core.models import default_token_duration
from authentik.events.signals import get_login_event
@@ -16,7 +18,6 @@ from authentik.providers.oauth2.constants import (
AMR_PASSWORD,
AMR_SMART_CARD,
AMR_WEBAUTHN,
SubModes,
)
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
@@ -29,6 +30,26 @@ def hash_session_key(session_key: str) -> str:
return sha256(session_key.encode("ascii")).hexdigest()
class SubModes(models.TextChoices):
"""Mode after which 'sub' attribute is generated, for compatibility reasons"""
HASHED_USER_ID = "hashed_user_id", _("Based on the Hashed User ID")
USER_ID = "user_id", _("Based on user ID")
USER_UUID = "user_uuid", _("Based on user UUID")
USER_USERNAME = "user_username", _("Based on the username")
USER_EMAIL = (
"user_email",
_("Based on the User's Email. This is recommended over the UPN method."),
)
USER_UPN = (
"user_upn",
_(
"Based on the User's UPN, only works if user has a 'upn' attribute set. "
"Use this method only if you have different UPN and Mail domains."
),
)
@dataclass(slots=True)
class IDToken:
"""The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.1.11 on 2025-07-04 03:23
import authentik.lib.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_providers_oauth2", "0028_migrate_session"),
]
operations = [
migrations.AddField(
model_name="oauth2provider",
name="backchannel_logout_uri",
field=models.TextField(
blank=True,
validators=[authentik.lib.models.DomainlessURLValidator(schemes=("http", "https"))],
verbose_name="Back-Channel Logout URI",
),
),
migrations.AlterField(
model_name="oauth2provider",
name="_redirect_uris",
field=models.JSONField(default=list, verbose_name="Redirect URIs"),
),
]

View File

@@ -6,7 +6,7 @@ import json
from dataclasses import asdict, dataclass
from functools import cached_property
from hashlib import sha256
from typing import TYPE_CHECKING, Any
from typing import Any
from urllib.parse import urlparse, urlunparse
from cryptography.hazmat.primitives.asymmetric.ec import (
@@ -42,14 +42,11 @@ from authentik.core.models import (
)
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
from authentik.lib.models import DomainlessURLValidator, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
from authentik.providers.oauth2.constants import SubModes
from authentik.providers.oauth2.id_token import IDToken, SubModes
from authentik.sources.oauth.models import OAuthSource
if TYPE_CHECKING:
from authentik.providers.oauth2.id_token import IDToken
LOGGER = get_logger()
@@ -196,14 +193,9 @@ class OAuth2Provider(WebfingerProvider, Provider):
default=generate_client_secret,
)
_redirect_uris = models.JSONField(
default=list,
default=dict,
verbose_name=_("Redirect URIs"),
)
backchannel_logout_uri = models.TextField(
validators=[DomainlessURLValidator(schemes=("http", "https"))],
verbose_name=_("Back-Channel Logout URI"),
blank=True,
)
include_claims_in_id_token = models.BooleanField(
default=True,
@@ -488,15 +480,13 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
return f"Access Token for {self.provider_id} for user {self.user_id}"
@property
def id_token(self) -> "IDToken":
def id_token(self) -> IDToken:
"""Load ID Token from json"""
from authentik.providers.oauth2.id_token import IDToken
raw_token = json.loads(self._id_token)
return from_dict(IDToken, raw_token)
@id_token.setter
def id_token(self, value: "IDToken"):
def id_token(self, value: IDToken):
self.token = value.to_access_token(self.provider)
self._id_token = json.dumps(asdict(value))
@@ -541,15 +531,13 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
return f"Refresh Token for {self.provider_id} for user {self.user_id}"
@property
def id_token(self) -> "IDToken":
def id_token(self) -> IDToken:
"""Load ID Token from json"""
from authentik.providers.oauth2.id_token import IDToken
raw_token = json.loads(self._id_token)
return from_dict(IDToken, raw_token)
@id_token.setter
def id_token(self, value: "IDToken"):
def id_token(self, value: IDToken):
self._id_token = json.dumps(asdict(value))
@property

View File

@@ -1,34 +1,17 @@
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession, User
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
from authentik.providers.oauth2.tasks import backchannel_logout_notification_dispatch
LOGGER = get_logger()
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted_oauth_backchannel_logout_and_tokens_removal(
sender, instance: AuthenticatedSession, **_
):
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
"""Revoke tokens upon user logout"""
LOGGER.debug("Sending back-channel logout notifications signal!", session=instance)
access_tokens = AccessToken.objects.filter(
AccessToken.objects.filter(
user=instance.user,
session__session__session_key=instance.session.session_key,
)
backchannel_logout_notification_dispatch.send(
revocations=[
(token.provider_id, token.id_token.iss, token.session.user.uid)
for token in access_tokens
],
)
access_tokens.delete()
).delete()
@receiver(post_save, sender=User)

View File

@@ -1,68 +0,0 @@
"""OAuth2 Provider Tasks"""
from django.utils.translation import gettext_lazy as _
from django_dramatiq_postgres.middleware import CurrentTask
from dramatiq.actor import actor
from structlog.stdlib import get_logger
from authentik.lib.utils.http import get_http_session
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.oauth2.utils import create_logout_token
from authentik.tasks.models import Task
LOGGER = get_logger()
@actor(description=_("Send a back-channel logout request to the registered client"))
def send_backchannel_logout_request(provider_pk: int, iss: str, sub: str = None) -> bool:
"""Send a back-channel logout request to the registered client
Args:
provider_pk: The OAuth2 provider's primary key
iss: The issuer URL for the logout token
sub: The subject identifier to include in the logout token
Returns:
bool: True if the request was sent successfully, False otherwise
"""
self: Task = CurrentTask.get_task()
LOGGER.debug("Sending back-channel logout request", provider_pk=provider_pk, sub=sub)
provider = OAuth2Provider.objects.filter(pk=provider_pk).first()
if provider is None:
return
# Generate the logout token
logout_token = create_logout_token(iss, provider, None, sub)
# Get the back-channel logout URI from the provider's dedicated backchannel_logout_uri field
# Back-channel logout requires explicit configuration - no fallback to redirect URIs
backchannel_logout_uri = provider.backchannel_logout_uri
if not backchannel_logout_uri:
self.info("No back-channel logout URI found for provider")
return
# Send the back-channel logout request
response = get_http_session().post(
backchannel_logout_uri,
data={"logout_token": logout_token},
headers={"Content-Type": "application/x-www-form-urlencoded"},
allow_redirects=True,
)
response.raise_for_status()
self.info("Back-channel logout successful", sub=sub)
return True
@actor(description=_("Handle backchannel logout notifications dispatched via signal"))
def backchannel_logout_notification_dispatch(revocations: list, **kwargs):
"""Handle backchannel logout notifications dispatched via signal"""
for revocation in revocations:
provider_pk, iss, sub = revocation
provider = OAuth2Provider.objects.filter(pk=provider_pk).first()
send_backchannel_logout_request.send_with_options(
args=(provider_pk, iss, sub),
rel_obj=provider,
)

View File

@@ -81,46 +81,4 @@ class TestAPI(APITestCase):
},
)
self.assertJSONEqual(response.content, {"redirect_uris": ["Invalid Regex Pattern: **"]})
def test_backchannel_logout_uri_validation(self):
"""Test backchannel_logout_uri API validation"""
response = self.client.post(
reverse("authentik_api:oauth2provider-list"),
data={
"name": generate_id(),
"authorization_flow": create_test_flow().pk,
"invalidation_flow": create_test_flow().pk,
"redirect_uris": [
{"matching_mode": "strict", "url": "http://goauthentik.io"},
],
"backchannel_logout_uri": "invalid-url",
},
)
self.assertEqual(response.status_code, 400)
def test_backchannel_logout_uri_create_and_retrieve(self):
"""Test creating and retrieving backchannel logout URI"""
response = self.client.post(
reverse("authentik_api:oauth2provider-list"),
data={
"name": generate_id(),
"authorization_flow": create_test_flow().pk,
"invalidation_flow": create_test_flow().pk,
"redirect_uris": [
{"matching_mode": "strict", "url": "http://goauthentik.io"},
],
"backchannel_logout_uri": "http://goauthentik.io/logout",
},
)
self.assertEqual(response.status_code, 201)
provider_data = response.json()
self.assertEqual(provider_data["backchannel_logout_uri"], "http://goauthentik.io/logout")
# Test retrieving the provider
provider_pk = provider_data["pk"]
response = self.client.get(
reverse("authentik_api:oauth2provider-detail", kwargs={"pk": provider_pk})
)
self.assertEqual(response.status_code, 200)
retrieved_data = response.json()
self.assertEqual(retrieved_data["backchannel_logout_uri"], "http://goauthentik.io/logout")

View File

@@ -1,223 +0,0 @@
"""Test OAuth2 Back-Channel Logout implementation"""
from unittest.mock import Mock, patch
import jwt
from django.test import RequestFactory
from django.utils import timezone
from dramatiq.results.errors import ResultFailure
from requests import Response
from requests.exceptions import HTTPError, Timeout
from authentik.core.models import Application, AuthenticatedSession, Session
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import hash_session_key
from authentik.providers.oauth2.models import (
AccessToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,
RefreshToken,
)
from authentik.providers.oauth2.tasks import send_backchannel_logout_request
from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.utils import create_logout_token
class TestBackChannelLogout(OAuthTestCase):
"""Test Back-Channel Logout functionality"""
def setUp(self) -> None:
super().setUp()
self.factory = RequestFactory()
self.user = create_test_admin_user()
self.app = Application.objects.create(name=generate_id(), slug="test-app")
self.provider = OAuth2Provider.objects.create(
name=generate_id(),
authorization_flow=create_test_flow(),
redirect_uris=[
RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver/callback"),
],
signing_key=self.keypair,
)
self.app.provider = self.provider
self.app.save()
def _create_session(self, session_key=None):
"""Create a session with the given key or a generated one"""
session_key = session_key or f"session-{generate_id()}"
session = Session.objects.create(
session_key=session_key,
expires=timezone.now() + timezone.timedelta(hours=1),
last_ip="255.255.255.255",
)
auth_session = AuthenticatedSession.objects.create(
session=session,
user=self.user,
)
return auth_session
def _create_token(
self, provider, user, session=None, token_type="access", token_id=None
): # nosec
"""Create a token of the specified type"""
token_id = token_id or f"{token_type}-token-{generate_id()}"
kwargs = {
"provider": provider,
"user": user,
"session": session,
"token": token_id,
"_id_token": "{}",
"auth_time": timezone.now(),
}
if token_type == "access": # nosec
return AccessToken.objects.create(**kwargs)
else: # refresh
return RefreshToken.objects.create(**kwargs)
def _create_provider(self, name=None):
"""Create an OAuth2 provider"""
name = name or f"provider-{generate_id()}"
provider = OAuth2Provider.objects.create(
name=name,
authorization_flow=create_test_flow(),
redirect_uris=[
RedirectURI(RedirectURIMatchingMode.STRICT, f"http://{name}/callback"),
],
signing_key=self.keypair,
)
return provider
def _create_logout_token(
self,
provider: OAuth2Provider | None = None,
session_id: str | None = None,
sub: str | None = None,
):
"""Create a logout token with the given parameters"""
provider = provider or self.provider
# Create a token with the same issuer that the view will expect
# Use the same request object that will be used in the test
request = self.factory.post("/backchannel_logout")
return create_logout_token(
iss=provider.get_issuer(request),
provider=provider,
session_key=session_id,
sub=sub,
)
def _decode_token(self, token, provider=None):
"""Helper to decode and validate a JWT token"""
provider = provider or self.provider
key, alg = provider.jwt_key
if alg != "HS256":
key = provider.signing_key.public_key
return jwt.decode(
token, key, algorithms=[alg], options={"verify_exp": False, "verify_aud": False}
)
def test_create_logout_token_variants(self):
"""Test creating logout tokens with different combinations of parameters"""
# Test case 1: With session_id only
session_id = "test-session-123"
token1 = self._create_logout_token(session_id=session_id)
decoded1 = self._decode_token(token1)
self.assertIn("iss", decoded1)
self.assertEqual(decoded1["aud"], self.provider.client_id)
self.assertIn("iat", decoded1)
self.assertIn("jti", decoded1)
self.assertEqual(decoded1["sid"], hash_session_key(session_id))
self.assertIn("events", decoded1)
self.assertIn("http://schemas.openid.net/event/backchannel-logout", decoded1["events"])
self.assertNotIn("sub", decoded1)
# Test case 2: With sub only
sub = "user-123"
token2 = self._create_logout_token(sub=sub)
decoded2 = self._decode_token(token2)
self.assertEqual(decoded2["sub"], sub)
self.assertIn("events", decoded2)
self.assertIn("http://schemas.openid.net/event/backchannel-logout", decoded2["events"])
self.assertNotIn("sid", decoded2)
# Test case 3: With both session_id and sub
token3 = self._create_logout_token(session_id=session_id, sub=sub)
decoded3 = self._decode_token(token3)
self.assertEqual(decoded3["sid"], hash_session_key(session_id))
self.assertEqual(decoded3["sub"], sub)
self.assertIn("events", decoded3)
@patch("authentik.providers.oauth2.tasks.get_http_session")
def test_send_backchannel_logout_request_scenarios(self, mock_get_session):
"""Test various scenarios for backchannel logout request task"""
# Setup provider with backchannel logout URI
self.provider.backchannel_logout_uri = "http://testserver/backchannel_logout"
self.provider.save()
# Setup mock session and response
mock_session = Mock()
mock_get_session.return_value = mock_session
mock_response = Mock(spec=Response)
mock_response.status_code = 200
mock_response.raise_for_status.return_value = None # No exception for successful request
mock_session.post.return_value = mock_response
result = send_backchannel_logout_request.send(
self.provider.pk, "http://testserver", sub="test-user-uid"
)
self.assertTrue(result)
mock_session.post.assert_called_once()
call_args = mock_session.post.call_args
self.assertIn("logout_token", call_args[1]["data"])
self.assertEqual(
call_args[1]["headers"]["Content-Type"], "application/x-www-form-urlencoded"
)
# Scenario 2: Failed request (400 response) - should raise exception
mock_session.post.reset_mock()
error_response = Mock(spec=Response)
error_response.status_code = 400
error_response.raise_for_status.side_effect = HTTPError("HTTP 400")
mock_session.post.return_value = error_response
with self.assertRaises(ResultFailure):
send_backchannel_logout_request.send(
self.provider.pk, "http://testserver", sub="test-user-uid"
).get_result()
# Scenario 3: No URI configured
mock_session.post.reset_mock()
self.provider.backchannel_logout_uri = ""
self.provider.save()
result = send_backchannel_logout_request.send(
self.provider.pk, "http://testserver", sub="test-user-uid"
).get_result()
self.assertIsNone(result)
mock_session.post.assert_not_called()
# Scenario 4: No sub provided - should fail
result = send_backchannel_logout_request.send(
self.provider.pk, "http://testserver"
).get_result()
self.assertIsNone(result)
# Scenario 5: Non-existent provider
result = send_backchannel_logout_request.send(
99999, "http://testserver", sub="test-user-uid"
).get_result()
self.assertIsNone(result)
# Scenario 6: Request timeout
mock_session.post.side_effect = Timeout("Request timed out")
self.provider.backchannel_logout_uri = "http://testserver/backchannel_logout"
self.provider.save()
with self.assertRaises(ResultFailure):
send_backchannel_logout_request.send(
self.provider.pk, "http://testserver", sub="test-user-uid"
).get_result()

View File

@@ -11,9 +11,9 @@ from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
IDToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,

View File

@@ -10,11 +10,11 @@ from django.utils import timezone
from authentik.core.models import Application, AuthenticatedSession, Session
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
ClientTypes,
DeviceToken,
IDToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,

View File

@@ -11,9 +11,9 @@ from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import (
AccessToken,
IDToken,
OAuth2Provider,
RedirectURI,
RedirectURIMatchingMode,

View File

@@ -1,10 +1,8 @@
"""OAuth2/OpenID Utils"""
import re
import uuid
from base64 import b64decode
from binascii import Error
from time import time
from typing import Any
from urllib.parse import urlparse
@@ -16,7 +14,6 @@ from structlog.stdlib import get_logger
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
from authentik.events.models import Event, EventAction
from authentik.providers.oauth2.errors import BearerTokenError
from authentik.providers.oauth2.id_token import hash_session_key
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
LOGGER = get_logger()
@@ -214,36 +211,3 @@ class HttpResponseRedirectScheme(HttpResponseRedirect):
) -> None:
self.allowed_schemes = allowed_schemes or ["http", "https", "ftp"]
super().__init__(redirect_to, *args, **kwargs)
def create_logout_token(
iss: str,
provider: OAuth2Provider,
session_key: str | None = None,
sub: str | None = None,
) -> str:
"""Create a logout token for Back-Channel Logout
As per https://openid.net/specs/openid-connect-backchannel-1_0.html
"""
LOGGER.debug("Creating logout token", provider=provider, session_key=session_key, sub=sub)
# Create the logout token payload
payload = {
"iss": str(iss),
"aud": provider.client_id,
"iat": int(time()),
"jti": str(uuid.uuid4()),
"events": {
"http://schemas.openid.net/event/backchannel-logout": {},
},
}
# Add either sub or sid (or both)
if sub:
payload["sub"] = sub
if session_key:
payload["sid"] = hash_session_key(session_key)
# Encode the token
return provider.encode(payload)

View File

@@ -9,8 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.providers.oauth2.errors import TokenIntrospectionError
from authentik.providers.oauth2.id_token import IDToken
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.models import AccessToken, IDToken, OAuth2Provider, RefreshToken
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
LOGGER = get_logger()

View File

@@ -72,8 +72,6 @@ class ProviderInfoView(View):
"device_authorization_endpoint": self.request.build_absolute_uri(
reverse("authentik_providers_oauth2:device")
),
"backchannel_logout_supported": True,
"backchannel_logout_session_supported": True,
"response_types_supported": [
ResponseTypes.CODE,
ResponseTypes.ID_TOKEN,

View File

@@ -44,8 +44,6 @@ class EmailStageSerializer(StageSerializer):
"subject",
"template",
"activate_user_on_success",
"recovery_max_attempts",
"recovery_cache_timeout",
]
extra_kwargs = {"password": {"write_only": True}}

View File

@@ -1,28 +0,0 @@
# Generated by Django 5.1.11 on 2025-07-23 11:26
import authentik.lib.utils.time
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_email", "0005_alter_emailstage_token_expiry"),
]
operations = [
migrations.AddField(
model_name="emailstage",
name="recovery_cache_timeout",
field=models.TextField(
default="minutes=5",
help_text="The time window used to count recent account recovery attempts. If the number of attempts exceed recovery_max_attempts within this period, further attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3).",
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
migrations.AddField(
model_name="emailstage",
name="recovery_max_attempts",
field=models.PositiveIntegerField(default=5),
),
]

View File

@@ -16,8 +16,6 @@ from authentik.flows.models import Stage
from authentik.lib.config import CONFIG
from authentik.lib.utils.time import timedelta_string_validator
EMAIL_RECOVERY_MAX_ATTEMPTS = 5
LOGGER = get_logger()
@@ -72,17 +70,6 @@ class EmailStage(Stage):
use_ssl = models.BooleanField(default=False)
timeout = models.IntegerField(default=10)
from_address = models.EmailField(default="system@authentik.local")
recovery_max_attempts = models.PositiveIntegerField(default=EMAIL_RECOVERY_MAX_ATTEMPTS)
recovery_cache_timeout = models.TextField(
default="minutes=5",
validators=[timedelta_string_validator],
help_text=_(
"The time window used to count recent account recovery attempts. "
"If the number of attempts exceed recovery_max_attempts within "
"this period, further attempts will be rate-limited. "
"(Format: hours=1;minutes=2;seconds=3)."
),
)
activate_user_on_success = models.BooleanField(
default=False, help_text=_("Activate users upon completion of stage.")

View File

@@ -1,12 +1,9 @@
"""authentik multi-stage authentication engine"""
import math
from datetime import UTC, datetime, timedelta
from hashlib import sha256
from datetime import timedelta
from uuid import uuid4
from django.contrib import messages
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.template.exceptions import TemplateSyntaxError
@@ -30,8 +27,6 @@ from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
EMAIL_RECOVERY_CACHE_KEY = "goauthentik.io/stages/email/stage/"
PLAN_CONTEXT_EMAIL_SENT = "email_sent"
PLAN_CONTEXT_EMAIL_OVERRIDE = "email"
@@ -175,66 +170,10 @@ class EmailStageView(ChallengeStageView):
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return super().challenge_invalid(response)
def _get_cache_key(self) -> str:
"""Return the cache key used for rate limiting email recovery attempts."""
user = self.get_pending_user()
user_email_hashed = sha256(user.email.lower().encode("utf-8")).hexdigest()
return EMAIL_RECOVERY_CACHE_KEY + user_email_hashed
def _is_rate_limited(self) -> int | None:
"""Check whether the email recovery attempt should be rate limited.
If the request should be rate limited, update the cache and return the
remaining time in minutes before the user is allowed to try again.
Otherwise, return None."""
cache_key = self._get_cache_key()
attempts = cache.get(cache_key, [])
stage = self.executor.current_stage
stage.refresh_from_db()
max_attempts = stage.recovery_max_attempts
cache_timeout_delta = timedelta_from_string(stage.recovery_cache_timeout)
_now = now()
start_window = _now - cache_timeout_delta
# Convert unix timestamps to datetime objects for comparison
recent_attempts_in_window = [
datetime.fromtimestamp(attempt, UTC)
for attempt in attempts
if datetime.fromtimestamp(attempt, UTC) > start_window
]
if len(recent_attempts_in_window) >= max_attempts:
retry_after = (min(recent_attempts_in_window) + cache_timeout_delta) - _now
minutes_left = max(1, math.ceil(retry_after.total_seconds() / 60))
return minutes_left
recent_attempts_in_window.append(_now)
# Convert datetime objects back to unix timestamps to update cache
recent_attempts_in_window = [attempt.timestamp() for attempt in recent_attempts_in_window]
cache.set(
cache_key,
recent_attempts_in_window,
int(cache_timeout_delta.total_seconds()),
)
return None
def challenge_invalid(self, response: ChallengeResponse) -> HttpResponse:
if minutes_left := self._is_rate_limited():
error = _(
"Too many account verification attempts. Please try again after {minutes} minutes."
).format(minutes=minutes_left)
messages.error(self.request, error)
return super().challenge_invalid(response)
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
messages.error(self.request, _("No pending user."))
return super().challenge_invalid(response)
self.send_email()
messages.success(self.request, _("Email Successfully sent."))
# We can't call stage_ok yet, as we're still waiting

View File

@@ -1,9 +1,7 @@
"""email tests"""
from hashlib import sha256
from unittest.mock import MagicMock, PropertyMock, patch
from django.contrib import messages
from django.core import mail
from django.core.mail.backends.locmem import EmailBackend
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
@@ -11,7 +9,6 @@ from django.test import RequestFactory
from django.urls import reverse
from django.utils.http import urlencode
from authentik.brands.models import Brand
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
@@ -20,7 +17,6 @@ from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN, FlowExecutorView
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request
from authentik.stages.consent.stage import SESSION_KEY_CONSENT_TOKEN
from authentik.stages.email.models import EmailStage
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, EmailStageView
@@ -295,173 +291,3 @@ class TestEmailStage(FlowTestCase):
stage_view.get_full_url(**{QS_KEY_TOKEN: token}),
f"http://testserver/if/flow/{self.flow.slug}/?foo=bar&flow_token={token}",
)
def test_get_cache_key(self):
"""Test to ensure that the correct cache key is returned."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
request = self.factory.post(url)
request.user = self.user
request.session = session
executor = FlowExecutorView(request=request, flow=self.flow)
executor.plan = plan
stage_view = EmailStageView(executor, request=request)
cache_key = stage_view._get_cache_key()
expected_hash = sha256(self.user.email.lower().encode("utf-8")).hexdigest()
expected_cache_key = "goauthentik.io/stages/email/stage/" + expected_hash
self.assertEqual(cache_key, expected_cache_key)
def test_is_rate_limited_returns_none(self):
"""Test to ensure None is returned if the request shouldn't be rate limited."""
self.stage.recovery_max_attempts = 2
self.stage.recovery_cache_timeout = "minutes=10"
self.stage.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
request = self.factory.post(url)
request.user = self.user
request.session = session
executor = FlowExecutorView(request=request, flow=self.flow)
executor.current_stage = self.stage
executor.plan = plan
stage_view = EmailStageView(executor, request=request)
result = stage_view._is_rate_limited()
self.assertIsNone(result)
def test_is_rate_limited_returns_remaining_time(self):
"""Test to ensure the remaining time is returned if the request
should be rate limited."""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
request = self.factory.post(url)
request.user = self.user
request.session = session
executor = FlowExecutorView(request=request, flow=self.flow)
executor.current_stage = self.stage
executor.plan = plan
stage_view = EmailStageView(executor, request=request)
test_cases = [
# 2 attempts within 2 minutes
(2, "seconds=120", 2),
# 4 attempts within 5 minutes
(4, "minutes=5", 5),
# 6 attempts within 5 minutes. Although 299 seconds is less than
# 5 minutes, the user is intentionally shown "5 minutes". This is
# because an initial rate limiting message like "Try again after 4 minutes"
# can be confusing.
(6, "seconds=299", 5),
]
for test_case in test_cases:
max_attempts, cache_timeout, minutes_remaining = test_case
with self.subTest(
f"Test recovery with {max_attempts} max attempts and "
f"{cache_timeout} cache timeout seconds"
):
self.stage.recovery_max_attempts = max_attempts
self.stage.recovery_cache_timeout = cache_timeout
self.stage.save()
# Simulate multiple requests
for _ in range(max_attempts):
stage_view._is_rate_limited()
# The following request should be rate-limited
result = stage_view._is_rate_limited()
self.assertEqual(result, minutes_remaining)
def _challenge_invalid_helper(self):
"""Helper to test the challenge_invalid() method."""
self.stage.recovery_max_attempts = 1
self.stage.recovery_cache_timeout = "seconds=300"
self.stage.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
request = get_request(url, user=self.user)
request.session = session
request.brand = Brand.objects.create(domain="foo-domain.com", default=True)
executor = FlowExecutorView(request=request, flow=self.flow)
executor.current_stage = self.stage
executor.plan = plan
stage_view = EmailStageView(executor, request=request)
challenge_response = stage_view.get_response_instance(data={})
challenge_response.is_valid()
return challenge_response, stage_view, request
def test_challenge_invalid_not_rate_limited(self):
"""Tests that the request is not rate limited and email is sent."""
challenge_response, stage_view, request = self._challenge_invalid_helper()
with patch.object(stage_view, "send_email") as mock_send_email:
result = stage_view.challenge_invalid(challenge_response)
self.assertEqual(result.status_code, 200)
mock_send_email.assert_called_once()
message_list = list(messages.get_messages(request))
self.assertEqual(len(message_list), 1)
self.assertEqual(
"Email Successfully sent.",
message_list[-1].message,
)
def test_challenge_invalid_returns_error_if_rate_limited(self):
"""Tests that an error is returned if the request is rate limited. Ensure
that an email is not sent."""
challenge_response, stage_view, request = self._challenge_invalid_helper()
# Initial request that shouldn't be rate limited
stage_view.challenge_invalid(challenge_response)
with patch.object(stage_view, "send_email") as mock_send_email:
# This next request should be rate limited
result = stage_view.challenge_invalid(challenge_response)
self.assertEqual(result.status_code, 200)
mock_send_email.assert_not_called()
message_list = list(messages.get_messages(request))
self.assertEqual(len(message_list), 2)
self.assertEqual(
"Too many account verification attempts. Please try again after 5 minutes.",
message_list[-1].message,
)

View File

@@ -61,8 +61,6 @@ entries:
subject: authentik
template: email/password_reset.html
activate_user_on_success: true
recovery_max_attempts: 5
recovery_cache_timeout: minutes=5
- identifiers:
name: default-recovery-user-write
id: default-recovery-user-write

View File

@@ -8473,10 +8473,6 @@
},
"title": "Redirect uris"
},
"backchannel_logout_uri": {
"type": "string",
"title": "Back-Channel Logout URI"
},
"sub_mode": {
"type": "string",
"enum": [
@@ -14336,18 +14332,6 @@
"type": "boolean",
"title": "Activate user on success",
"description": "Activate users upon completion of stage."
},
"recovery_max_attempts": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"title": "Recovery max attempts"
},
"recovery_cache_timeout": {
"type": "string",
"minLength": 1,
"title": "Recovery cache timeout",
"description": "The time window used to count recent account recovery attempts. If the number of attempts exceed recovery_max_attempts within this period, further attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3)."
}
},
"required": []

2
go.mod
View File

@@ -29,7 +29,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025064.6
goauthentik.io/api/v3 v3.2025064.3
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.16.0

4
go.sum
View File

@@ -185,8 +185,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025064.6 h1:s9DaQ8x93T9IjDBqSX69VTuB5kBH3nHyI3/2Mlhlf08=
goauthentik.io/api/v3 v3.2025064.6/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
goauthentik.io/api/v3 v3.2025064.3 h1:REfDBEjswP2id2WRRDUajRxX+6u+XZ7e/smYq7jw5Z0=
goauthentik.io/api/v3 v3.2025064.3/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-08-06 00:11+0000\n"
"POT-Creation-Date: 2025-07-28 16:09+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"
@@ -1483,27 +1483,27 @@ msgstr ""
msgid "Invalid Regex Pattern: {url}"
msgstr ""
#: authentik/providers/oauth2/constants.py
#: authentik/providers/oauth2/id_token.py
msgid "Based on the Hashed User ID"
msgstr ""
#: authentik/providers/oauth2/constants.py
#: authentik/providers/oauth2/id_token.py
msgid "Based on user ID"
msgstr ""
#: authentik/providers/oauth2/constants.py
#: authentik/providers/oauth2/id_token.py
msgid "Based on user UUID"
msgstr ""
#: authentik/providers/oauth2/constants.py
#: authentik/providers/oauth2/id_token.py
msgid "Based on the username"
msgstr ""
#: authentik/providers/oauth2/constants.py
#: authentik/providers/oauth2/id_token.py
msgid "Based on the User's Email. This is recommended over the UPN method."
msgstr ""
#: authentik/providers/oauth2/constants.py
#: authentik/providers/oauth2/id_token.py
msgid ""
"Based on the User's UPN, only works if user has a 'upn' attribute set. Use "
"this method only if you have different UPN and Mail domains."
@@ -1617,10 +1617,6 @@ msgstr ""
msgid "Redirect URIs"
msgstr ""
#: authentik/providers/oauth2/models.py
msgid "Back-Channel Logout URI"
msgstr ""
#: authentik/providers/oauth2/models.py
msgid "Include claims in id_token"
msgstr ""
@@ -1736,14 +1732,6 @@ msgstr ""
msgid "Device Tokens"
msgstr ""
#: authentik/providers/oauth2/tasks.py
msgid "Send a back-channel logout request to the registered client"
msgstr ""
#: authentik/providers/oauth2/tasks.py
msgid "Handle backchannel logout notifications dispatched via signal"
msgstr ""
#: authentik/providers/oauth2/views/authorize.py
#: authentik/providers/saml/views/flows.py
#, python-brace-format

Binary file not shown.

View File

@@ -19,7 +19,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-28 16:09+0000\n"
"POT-Creation-Date: 2025-06-04 00:12+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"
@@ -33,10 +33,6 @@ msgstr ""
msgid "Version history"
msgstr "Historique des versions"
#: authentik/admin/tasks.py
msgid "Update latest version info."
msgstr "Mettre à jour les dernières informations de version."
#: authentik/admin/tasks.py
#, python-brace-format
msgid "New version {version} available!"
@@ -92,25 +88,10 @@ msgstr "Instances du plan"
msgid "authentik Export - {date}"
msgstr "Export authentik - {date}"
#: authentik/blueprints/v1/tasks.py
msgid "Find blueprints as `blueprints_find` does, but return a safe dict."
msgstr ""
"Cherche les plans comme le fait `blueprints_find`, mais renvoie un safe "
"dict."
#: authentik/blueprints/v1/tasks.py
msgid "Find blueprints and check if they need to be created in the database."
msgstr ""
"Cherche les plans et vérifie s'ils doivent être créés dans la base de "
"données."
#: authentik/blueprints/v1/tasks.py
msgid "Apply single blueprint."
msgstr "Applique un seul plan."
#: authentik/blueprints/v1/tasks.py
msgid "Remove blueprints which couldn't be fetched."
msgstr "Supprime les plans qui n'ont pas pu être récupérés."
#: authentik/blueprints/v1/tasks.py authentik/crypto/tasks.py
#, python-brace-format
msgid "Successfully imported {count} files."
msgstr "{count} fichiers importés avec succès."
#: authentik/brands/models.py
msgid ""
@@ -148,6 +129,10 @@ msgstr "Marques"
msgid "User does not have access to application."
msgstr "L'utilisateur n'a pas accès à l'application."
#: authentik/core/api/devices.py
msgid "Extra description not available"
msgstr "Description supplémentaire indisponible"
#: authentik/core/api/groups.py
msgid "Cannot set group as parent of itself."
msgstr "Impossible de définir le groupe en tant que parent de lui-même."
@@ -394,10 +379,6 @@ msgstr "Jetons"
msgid "View token's key"
msgstr "Voir la clé du jeton"
#: authentik/core/models.py
msgid "Set a token's key"
msgstr "Définir la clé d'un jeton"
#: authentik/core/models.py
msgid "Property Mapping"
msgstr "Mappage de propriété"
@@ -453,14 +434,6 @@ msgstr "{source} liée avec succès !"
msgid "Source is not configured for enrollment."
msgstr "La source n'est pas configurée pour l'inscription."
#: authentik/core/tasks.py
msgid "Remove expired objects."
msgstr "Supprime les objets expirés"
#: authentik/core/tasks.py
msgid "Remove temporary users created by SAML Sources."
msgstr "Supprime les utilisateurs temporaires créés par les sources SAML."
#: authentik/core/templates/if/error.html
msgid "Go home"
msgstr "Retourner à l'accueil"
@@ -513,12 +486,6 @@ msgstr "Paire de clé/certificat"
msgid "Certificate-Key Pairs"
msgstr "Paires de clé/certificat"
#: authentik/crypto/tasks.py
msgid "Discover, import and update certificates from the filesystem."
msgstr ""
"Découvre, importe et met à jour les certificats depuis le système de "
"fichiers."
#: authentik/enterprise/api.py
msgid "Enterprise is required to create/update this object."
msgstr "Entreprise est requis pour créer/mettre à jour cet objet."
@@ -571,18 +538,6 @@ msgstr "Politiques d'unicité des mots de passe"
msgid "User Password History"
msgstr "Historique des mots de passe utilisateur"
#: authentik/enterprise/policies/unique_password/tasks.py
msgid ""
"Check if any UniquePasswordPolicy exists, and if not, purge the password "
"history table."
msgstr ""
"Vérifie si une politique de mot de passe unique existe et, si ce n'est pas "
"le cas, purge la table de l'historique des mots de passe."
#: authentik/enterprise/policies/unique_password/tasks.py
msgid "Remove user password history that are too old."
msgstr "Supprime l'historique des mots de passe utilisateur trop anciens."
#: authentik/enterprise/policy.py
msgid "Enterprise required to access this feature."
msgstr "Entreprise est requis pour accéder à cette fonctionnalité."
@@ -631,42 +586,6 @@ msgstr "Mappage de propriété Google Workspace"
msgid "Google Workspace Provider Mappings"
msgstr "Mappages de propriété Google Workspace"
#: authentik/enterprise/providers/google_workspace/tasks.py
msgid "Sync Google Workspace provider objects."
msgstr "Synchronise les objets du fournisseur Google Workspace."
#: authentik/enterprise/providers/google_workspace/tasks.py
msgid "Full sync for Google Workspace provider."
msgstr "Synchronisation complète pour le fournisseur Google Workspace."
#: authentik/enterprise/providers/google_workspace/tasks.py
msgid "Sync a direct object (user, group) for Google Workspace provider."
msgstr ""
"Synchronise un objet direct (utilisateur, groupe) pour le fournisseur Google"
" Workspace."
#: authentik/enterprise/providers/google_workspace/tasks.py
msgid ""
"Dispatch syncs for a direct object (user, group) for Google Workspace "
"providers."
msgstr ""
"Déclenche des synchronisations pour un objet direct (utilisateur, groupe) "
"pour les fournisseurs Google Workspace."
#: authentik/enterprise/providers/google_workspace/tasks.py
msgid "Sync a related object (memberships) for Google Workspace provider."
msgstr ""
"Synchronise un objet lié (appartenances) pour le fournisseur Google "
"Workspace."
#: authentik/enterprise/providers/google_workspace/tasks.py
msgid ""
"Dispatch syncs for a related object (memberships) for Google Workspace "
"providers."
msgstr ""
"Déclenche des synchronisations pour un objet lié (appartenances) pour les "
"fournisseurs Google Workspace."
#: authentik/enterprise/providers/microsoft_entra/models.py
msgid "Microsoft Entra Provider User"
msgstr "Utilisateur du fournisseur Microsoft Entra"
@@ -695,42 +614,6 @@ msgstr "Mappage de propriété Microsoft Entra"
msgid "Microsoft Entra Provider Mappings"
msgstr "Mappages de propriété Microsoft Entra"
#: authentik/enterprise/providers/microsoft_entra/tasks.py
msgid "Sync Microsoft Entra provider objects."
msgstr "Synchronise les objets du fournisseur Microsoft Entra."
#: authentik/enterprise/providers/microsoft_entra/tasks.py
msgid "Full sync for Microsoft Entra provider."
msgstr "Synchronisation complète pour le fournisseur Microsoft Entra."
#: authentik/enterprise/providers/microsoft_entra/tasks.py
msgid "Sync a direct object (user, group) for Microsoft Entra provider."
msgstr ""
"Synchronise un objet direct (utilisateur, groupe) pour le fournisseur "
"Microsoft Entra."
#: authentik/enterprise/providers/microsoft_entra/tasks.py
msgid ""
"Dispatch syncs for a direct object (user, group) for Microsoft Entra "
"providers."
msgstr ""
"Déclenche les synchronisations pour un objet direct (utilisateur, groupe) "
"pour les fournisseurs Microsoft Entra."
#: authentik/enterprise/providers/microsoft_entra/tasks.py
msgid "Sync a related object (memberships) for Microsoft Entra provider."
msgstr ""
"Synchronise un objet lié (appartenances) pour le fournisseur Microsoft "
"Entra."
#: authentik/enterprise/providers/microsoft_entra/tasks.py
msgid ""
"Dispatch syncs for a related object (memberships) for Microsoft Entra "
"providers."
msgstr ""
"Déclenche des synchronisations pour un objet lié (appartenances) pour les "
"fournisseurs Microsoft Entra."
#: authentik/enterprise/providers/ssf/models.py
#: authentik/providers/oauth2/models.py
msgid "Signing Key"
@@ -769,12 +652,8 @@ msgid "SSF Stream Events"
msgstr "Évènements du flux SSF"
#: authentik/enterprise/providers/ssf/tasks.py
msgid "Dispatch SSF events."
msgstr "Distribue les événements SSF."
#: authentik/enterprise/providers/ssf/tasks.py
msgid "Send an SSF event."
msgstr "Envoye un événement SSF."
msgid "Failed to send request"
msgstr "Échec de l'envoi de la requête"
#: authentik/enterprise/stages/authenticator_endpoint_gdtc/models.py
msgid "Endpoint Authenticator Google Device Trust Connector Stage"
@@ -846,9 +725,10 @@ msgstr "Étape Source"
msgid "Source Stages"
msgstr "Étapes Source"
#: authentik/enterprise/tasks.py
msgid "Update enterprise license status."
msgstr "Mettre à jour le statut de licence entreprise."
#: authentik/events/api/tasks.py
#, python-brace-format
msgid "Successfully started task {name}."
msgstr "La tâche {name} a été démarrée avec succès."
#: authentik/events/models.py
msgid "Event"
@@ -960,15 +840,6 @@ msgstr ""
"Définir à quel groupe d'utilisateur cette notification doit être envoyée et "
"affichée. Si laissé vide, les notifications ne seront pas envoyées."
#: authentik/events/models.py
msgid ""
"When enabled, notification will be sent to user the user that triggered the "
"event.When destination_group is configured, notification is sent to both."
msgstr ""
"Lorsque cette option est activée, une notification est envoyée à "
"l'utilisateur qui a déclenché l'événement. Si destination_group est "
"configuré, la notification est envoyée aux deux."
#: authentik/events/models.py
msgid "Notification Rule"
msgstr "Règle de Notification"
@@ -985,6 +856,10 @@ msgstr "Mappage de Webhook"
msgid "Webhook Mappings"
msgstr "Mappages de Webhook"
#: authentik/events/models.py
msgid "Run task"
msgstr "Lancer la tâche"
#: authentik/events/models.py
msgid "System Task"
msgstr "Tâches du système"
@@ -993,31 +868,9 @@ msgstr "Tâches du système"
msgid "System Tasks"
msgstr "Tâches du système"
#: authentik/events/tasks.py
msgid "Dispatch new event notifications."
msgstr "Envoye les notifications d'un nouvel événement."
#: authentik/events/tasks.py
msgid ""
"Check if policies attached to NotificationRule match event and dispatch "
"notification tasks."
msgstr ""
"Vérifier si les politiques attachées à une règle de notifications "
"correspondent à l'événement et déclenche les tâches de notification."
#: authentik/events/tasks.py
msgid "Send notification."
msgstr "Envoye une notification."
#: authentik/events/tasks.py
msgid "Cleanup events for GDPR compliance."
msgstr "Nettoye les événements pour la conformité au RGPD."
#: authentik/events/tasks.py
msgid "Cleanup seen notifications and notifications whose event expired."
msgstr ""
"Nettoye les notifications vues et les notifications dont l'événement a "
"expiré."
#: authentik/events/system_tasks.py
msgid "Task has not been run yet."
msgstr "Tâche pas encore exécutée."
#: authentik/flows/api/flows.py
#, python-brace-format
@@ -1198,6 +1051,32 @@ msgstr ""
"Si activé, le fournisseur ne changera ou ne créera pas d'objets auprès du "
"système distant."
#: authentik/lib/sync/outgoing/tasks.py
msgid "Starting full provider sync"
msgstr "Démarrage d'une synchronisation complète du fournisseur"
#: authentik/lib/sync/outgoing/tasks.py
msgid "Syncing users"
msgstr "Synchronisation des utilisateurs"
#: authentik/lib/sync/outgoing/tasks.py
msgid "Syncing groups"
msgstr "Synchronisation des groupes"
#: authentik/lib/sync/outgoing/tasks.py
#, python-brace-format
msgid "Syncing page {page} of {object_type}"
msgstr "Synchronisation de la page {page} de {object_type}"
#: authentik/lib/sync/outgoing/tasks.py
msgid "Dropping mutating request due to dry run"
msgstr "Abandon de la requête de mutation en raison d'une simulation"
#: authentik/lib/sync/outgoing/tasks.py
#, python-brace-format
msgid "Stopping sync due to error: {error}"
msgstr "Arrêt de la synchronisation due à l'erreur : {error}"
#: authentik/lib/utils/time.py
#, python-format
msgid "%(value)s is not in the correct format of 'hours=3;minutes=1'."
@@ -1304,32 +1183,6 @@ msgstr "Avant-poste"
msgid "Outposts"
msgstr "Avant-postes"
#: authentik/outposts/tasks.py
msgid "Update cached state of service connection."
msgstr "Met à jour l'état mis en cache de la connexion de service."
#: authentik/outposts/tasks.py
msgid "Create/update/monitor/delete the deployment of an Outpost."
msgstr "Crée/met à jour/surveille/supprime le déploiement d'un avant-poste."
#: authentik/outposts/tasks.py
msgid "Ensure that all Outposts have valid Service Accounts and Tokens."
msgstr ""
"S'assure que tous les avant-postes ont des comptes de service et des jetons "
"valides."
#: authentik/outposts/tasks.py
msgid "Send update to outpost"
msgstr "Envoye une mise à jour à un avant-poste"
#: authentik/outposts/tasks.py
msgid "Checks the local environment and create Service connections."
msgstr "Vérifie l'environnement local et crée les connexions de service."
#: authentik/outposts/tasks.py
msgid "Terminate session on all outposts."
msgstr "Met fin à la session sur tous les avant-postes."
#: authentik/policies/denied.py
msgid "Access denied"
msgstr "Accès refusé"
@@ -2048,10 +1901,6 @@ msgstr "Fournisseur Proxy"
msgid "Proxy Providers"
msgstr "Fournisseur de Proxy"
#: authentik/providers/proxy/tasks.py
msgid "Terminate session on Proxy outpost."
msgstr "Met fin à la session sur l'avant-poste Proxy."
#: authentik/providers/rac/models.py authentik/stages/user_login/models.py
msgid ""
"Determines how long a session lasts. Default of 0 means that the sessions "
@@ -2396,35 +2245,6 @@ msgstr "Mappage fournisseur SCIM"
msgid "SCIM Provider Mappings"
msgstr "Mappages fournisseur SCIM"
#: authentik/providers/scim/tasks.py
msgid "Sync SCIM provider objects."
msgstr "Synchronise les objets du fournisseur SCIM."
#: authentik/providers/scim/tasks.py
msgid "Full sync for SCIM provider."
msgstr "Synchronisation complète pour le fournisseur SCIM."
#: authentik/providers/scim/tasks.py
msgid "Sync a direct object (user, group) for SCIM provider."
msgstr ""
"Synchronise un objet direct (utilisateur, groupe) pour le fournisseur SCIM."
#: authentik/providers/scim/tasks.py
msgid "Dispatch syncs for a direct object (user, group) for SCIM providers."
msgstr ""
"Déclenche les synchronisations pour un objet direct (utilisateur, groupe) "
"pour les fournisseurs SCIM."
#: authentik/providers/scim/tasks.py
msgid "Sync a related object (memberships) for SCIM provider."
msgstr "Synchronise un objet lié (appartenances) pour le fournisseur SCIM."
#: authentik/providers/scim/tasks.py
msgid "Dispatch syncs for a related object (memberships) for SCIM providers."
msgstr ""
"Déclenche des synchronisations pour un objet lié (appartenances) pour les "
"fournisseurs SCIM."
#: authentik/rbac/models.py
msgid "Role"
msgstr "Rôle"
@@ -2579,14 +2399,6 @@ msgstr "Connexion du groupe à la source Kerberos"
msgid "Group Kerberos Source Connections"
msgstr "Connexions du groupe à la source Kerberos"
#: authentik/sources/kerberos/tasks.py
msgid "Check connectivity for Kerberos sources."
msgstr "Vérifie la connectivité des sources Kerberos."
#: authentik/sources/kerberos/tasks.py
msgid "Sync Kerberos source."
msgstr "Synchronise la source Kerberos."
#: authentik/sources/kerberos/views.py
msgid "SPNEGO authentication required"
msgstr "Authentification SPNEGO requise"
@@ -2754,18 +2566,6 @@ msgstr "Connexions du groupe à la source LDAP"
msgid "Password does not match Active Directory Complexity."
msgstr "Le mot de passe ne correspond pas à la complexité d'Active Directory."
#: authentik/sources/ldap/tasks.py
msgid "Check connectivity for LDAP source."
msgstr "Vérifie la connectivité des sources LDAP."
#: authentik/sources/ldap/tasks.py
msgid "Sync LDAP source."
msgstr "Synchronise la source LDAP."
#: authentik/sources/ldap/tasks.py
msgid "Sync page for LDAP source."
msgstr "Synchronise une page pour la source LDAP."
#: authentik/sources/oauth/clients/oauth2.py
msgid "No token received."
msgstr "Pas de jeton reçu."
@@ -2915,14 +2715,6 @@ msgstr "Source d'OAuth Azure AD"
msgid "Azure AD OAuth Sources"
msgstr "Source d'OAuth Azure AD"
#: authentik/sources/oauth/models.py
msgid "Entra ID OAuth Source"
msgstr "Source d'OAuth Entra ID"
#: authentik/sources/oauth/models.py
msgid "Entra ID OAuth Sources"
msgstr "Sources d'OAuth Entra ID"
#: authentik/sources/oauth/models.py
msgid "OpenID OAuth Source"
msgstr "Source d'OAuth OpenID"
@@ -2979,14 +2771,6 @@ msgstr "Connexion du groupe à la source OAuth"
msgid "Group OAuth Source Connections"
msgstr "Connexions du groupe à la source OAuth"
#: authentik/sources/oauth/tasks.py
msgid ""
"Update OAuth sources' config from well_known, and JWKS info from the "
"configured URL."
msgstr ""
"Met à jour la configuration des sources OAuth à partir de well_known, et les"
" informations JWKS à partir de l'URL configurée."
#: authentik/sources/oauth/views/callback.py
#, python-brace-format
msgid "Authentication failed: {reason}"
@@ -3045,10 +2829,6 @@ msgstr "Connexion du groupe à la source Plex"
msgid "Group Plex Source Connections"
msgstr "Connexions du groupe à la source OAuth"
#: authentik/sources/plex/tasks.py
msgid "Check the validity of a Plex source."
msgstr "Vérifie la validité d'une source Plex."
#: authentik/sources/saml/models.py
msgid "Redirect Binding"
msgstr "Liaison de Redirection"
@@ -3493,13 +3273,6 @@ msgstr "Type d'appareil WebAuthn"
msgid "WebAuthn Device types"
msgstr "Types d'appareil WebAuthn"
#: authentik/stages/authenticator_webauthn/tasks.py
msgid ""
"Background task to import FIDO Alliance MDS blob and AAGUIDs into database."
msgstr ""
"Tâche de fond pour importer le blob MDS de la FIDO Alliance et les AAGUID "
"dans la base de données."
#: authentik/stages/captcha/models.py
msgid "Public key, acquired your captcha Provider."
msgstr "Clé publique, acquise auprès de votre fournisseur captcha."
@@ -3626,10 +3399,6 @@ msgstr "Email envoyé."
msgid "Email Successfully sent."
msgstr "Couriel envoyé avec succès."
#: authentik/stages/email/tasks.py
msgid "Send email."
msgstr "Envoye un courriel."
#: authentik/stages/email/templates/email/account_confirmation.html
#: authentik/stages/email/templates/email/account_confirmation.txt
msgid "Welcome!"
@@ -4098,16 +3867,6 @@ msgstr ""
"souvenir de moi ne sera pas proposée. (Format: "
"hours=-1;minutes=-2;seconds=-3)"
#: authentik/stages/user_login/models.py
msgid ""
"When set to a non-zero value, authentik will save a cookie with a longer "
"expiry,to remember the device the user is logging in from. (Format: "
"hours=-1;minutes=-2;seconds=-3)"
msgstr ""
"Si cette valeur est différente de zéro, authentik enregistrera un cookie "
"avec une expiration plus longue, afin de se souvenir de l'appareil à partir "
"duquel l'utilisateur se connecte. (Format : hours=-1;minutes=-2;seconds=-3)"
#: authentik/stages/user_login/models.py
msgid "User Login Stage"
msgstr "Étape de connexion utlisateur"
@@ -4159,38 +3918,6 @@ msgid "Failed to update user. Please try again later."
msgstr ""
"Échec de mise à jour de l'utilisateur. Merci de réessayer ultérieurement,"
#: authentik/tasks/models.py
msgid "Tenant this task belongs to"
msgstr "Tenant auquel cette tâche appartient"
#: authentik/tasks/models.py
msgid "Retry failed task"
msgstr "Relancer la tâche échouée"
#: authentik/tasks/models.py
msgid "Worker status"
msgstr "État du worker"
#: authentik/tasks/models.py
msgid "Worker statuses"
msgstr "États du worker"
#: authentik/tasks/schedules/models.py
msgid "Unique schedule identifier"
msgstr "Identifiant unique des planifications"
#: authentik/tasks/schedules/models.py
msgid "User schedule identifier"
msgstr "Identifiant utilisateur des planifications"
#: authentik/tasks/schedules/models.py
msgid "Manually trigger a schedule"
msgstr "Déclencher manuellement une planification"
#: authentik/tasks/tasks.py
msgid "Remove old worker statuses."
msgstr "Supprime les anciens statuts des workers."
#: authentik/tenants/models.py
msgid ""
"Schema name must start with t_, only contain lowercase letters and numbers "
@@ -4283,76 +4010,3 @@ msgstr "Domaine"
#: authentik/tenants/models.py
msgid "Domains"
msgstr "Domaines"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Queue name"
msgstr "Nom de la file"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Dramatiq actor name"
msgstr "Nom de l'acteur Dramatiq"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Message body"
msgstr "Corps du message"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Task status"
msgstr "État de la tâche"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Task last modified time"
msgstr "Heure de dernière modification de la tâche"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Task result"
msgstr "Résultat de la tâche"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Result expiry time"
msgstr "Délai d'expiration du résultat"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Task"
msgstr "Tâche"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Tasks"
msgstr "Tâches"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
#, python-format
msgid "%(value)s is not a valid crontab"
msgstr "%(value)s n'est pas un crontab valide"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Dramatiq actor to call"
msgstr "Acteur Dramatiq à invoquer"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Args to send to the actor"
msgstr "Args à passer à l'acteur"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Kwargs to send to the actor"
msgstr "Kwargs à passer à l'acteur"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Options to send to the actor"
msgstr "Options à passer à l'acteur"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "When to schedule tasks"
msgstr "Quand planifier les tâches"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Pause this schedule"
msgstr "Mettre cette planification en pause"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Schedule"
msgstr "Planification"
#: packages/django-dramatiq-postgres/django_dramatiq_postgres/models.py
msgid "Schedules"
msgstr "Planifications"

View File

@@ -44845,15 +44845,6 @@ components:
activate_user_on_success:
type: boolean
description: Activate users upon completion of stage.
recovery_max_attempts:
type: integer
maximum: 2147483647
minimum: 0
recovery_cache_timeout:
type: string
description: 'The time window used to count recent account recovery attempts.
If the number of attempts exceed recovery_max_attempts within this period,
further attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3).'
required:
- component
- meta_model_name
@@ -44914,16 +44905,6 @@ components:
activate_user_on_success:
type: boolean
description: Activate users upon completion of stage.
recovery_max_attempts:
type: integer
maximum: 2147483647
minimum: 0
recovery_cache_timeout:
type: string
minLength: 1
description: 'The time window used to count recent account recovery attempts.
If the number of attempts exceed recovery_max_attempts within this period,
further attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3).'
required:
- name
Endpoint:
@@ -49522,10 +49503,6 @@ components:
type: array
items:
$ref: '#/components/schemas/RedirectURI'
backchannel_logout_uri:
type: string
title: Back-Channel Logout URI
format: uri
sub_mode:
allOf:
- $ref: '#/components/schemas/SubModeEnum'
@@ -49633,10 +49610,6 @@ components:
type: array
items:
$ref: '#/components/schemas/RedirectURIRequest'
backchannel_logout_uri:
type: string
title: Back-Channel Logout URI
format: uri
sub_mode:
allOf:
- $ref: '#/components/schemas/SubModeEnum'
@@ -53413,16 +53386,6 @@ components:
activate_user_on_success:
type: boolean
description: Activate users upon completion of stage.
recovery_max_attempts:
type: integer
maximum: 2147483647
minimum: 0
recovery_cache_timeout:
type: string
minLength: 1
description: 'The time window used to count recent account recovery attempts.
If the number of attempts exceed recovery_max_attempts within this period,
further attempts will be rate-limited. (Format: hours=1;minutes=2;seconds=3).'
PatchedEndpointDeviceRequest:
type: object
description: Serializer for Endpoint authenticator devices
@@ -54556,10 +54519,6 @@ components:
type: array
items:
$ref: '#/components/schemas/RedirectURIRequest'
backchannel_logout_uri:
type: string
title: Back-Channel Logout URI
format: uri
sub_mode:
allOf:
- $ref: '#/components/schemas/SubModeEnum'

31
uv.lock generated
View File

@@ -86,15 +86,15 @@ wheels = [
[[package]]
name = "anyio"
version = "4.10.0"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
[[package]]
@@ -557,30 +557,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.40.2"
version = "1.40.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/c0/9ceff05d2243f169765ae9db08fa6f085d026af71a778cd083dc972f0f2b/boto3-1.40.2.tar.gz", hash = "sha256:2dfbc214fdbf94abfd61eec687ea39089d05af43bb00be792c76f3a6c1393f7b", size = 111826, upload-time = "2025-08-04T19:31:51.959Z" }
sdist = { url = "https://files.pythonhosted.org/packages/48/4d/70d209fdebf0377db233f80dfdf26ca2bc25d2b2e89d4882e0edccd2227f/boto3-1.40.1.tar.gz", hash = "sha256:985ed4bf64729807f870eadbc46ad98baf93096917f7194ec39d743ff75b3f1d", size = 111817, upload-time = "2025-08-01T19:24:18.017Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/66/01bccaaebcd1365ce1334be042765e49ccf23787887afb8e43c6d4bc2f6e/boto3-1.40.2-py3-none-any.whl", hash = "sha256:3d99325ee874190e8f3bfd38823987327c826cdfbab943420851bdb7684d727c", size = 139882, upload-time = "2025-08-04T19:31:50.493Z" },
{ url = "https://files.pythonhosted.org/packages/97/0e/f0cb4f71c40ba07e6ed5b47699a737a080d3c4f4b7b26657d5671de48621/boto3-1.40.1-py3-none-any.whl", hash = "sha256:7c007d5c8ee549e9fcad0927536502da199b27891006ef515330f429aca9671f", size = 139880, upload-time = "2025-08-01T19:24:16.581Z" },
]
[[package]]
name = "botocore"
version = "1.40.2"
version = "1.40.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/e5/e7d68381042a6d50510c8d4629f39922ce27ff32f45baf852ba6534342c5/botocore-1.40.2.tar.gz", hash = "sha256:77c4710bf37b28e897833b5b1f47d6a83e45a29985cd01a560dfdb8b6ad524e5", size = 14284599, upload-time = "2025-08-04T19:31:42.064Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/d2/d914999f4a128f0f840f2a9cc8327cd98aa661d6b33b331a81a8111ab970/botocore-1.40.1.tar.gz", hash = "sha256:bdf30e2c0e8cdb939d81fc243182a6d1dd39c416694b406c5f2ea079b1c2f3f5", size = 14280398, upload-time = "2025-08-01T19:24:08.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/56/dd25fb9e47060e8f7e353208678fefb65d1b06704ea30983cad8bdd81370/botocore-1.40.2-py3-none-any.whl", hash = "sha256:a31e6269af05498f8dc1c7f2b3f34448a0f16c79a8601c0389ecddab51b2c2ab", size = 13944886, upload-time = "2025-08-04T19:31:37.027Z" },
{ url = "https://files.pythonhosted.org/packages/d4/c1/aa7922c9bf74b6d6594d2430af6f854d234faff23187e269aaba89c326c8/botocore-1.40.1-py3-none-any.whl", hash = "sha256:e039774b55fbd6fe59f0f4fea51d156a2433bd4d8faa64fc1b87aee9a03f415d", size = 13940950, upload-time = "2025-08-01T19:24:03.889Z" },
]
[[package]]
@@ -603,15 +603,14 @@ wheels = [
[[package]]
name = "cattrs"
version = "25.1.1"
version = "24.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/2b/561d78f488dcc303da4639e02021311728fb7fda8006dd2835550cddd9ed/cattrs-25.1.1.tar.gz", hash = "sha256:c914b734e0f2d59e5b720d145ee010f1fd9a13ee93900922a2f3f9d593b8382c", size = 435016, upload-time = "2025-06-04T20:27:15.44Z" }
sdist = { url = "https://files.pythonhosted.org/packages/29/7b/da4aa2f95afb2f28010453d03d6eedf018f9e085bd001f039e15731aba89/cattrs-24.1.3.tar.gz", hash = "sha256:981a6ef05875b5bb0c7fb68885546186d306f10f0f6718fe9b96c226e68821ff", size = 426684, upload-time = "2025-03-25T15:01:00.325Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/b0/215274ef0d835bbc1056392a367646648b6084e39d489099959aefcca2af/cattrs-25.1.1-py3-none-any.whl", hash = "sha256:1b40b2d3402af7be79a7e7e097a9b4cd16d4c06e6d526644b0b26a063a1cc064", size = 69386, upload-time = "2025-06-04T20:27:13.969Z" },
{ url = "https://files.pythonhosted.org/packages/3c/ee/d68a3de23867a9156bab7e0a22fb9a0305067ee639032a22982cf7f725e7/cattrs-24.1.3-py3-none-any.whl", hash = "sha256:adf957dddd26840f27ffbd060a6c4dd3b2192c5b7c2c0525ef1bd8131d8a83f5", size = 66462, upload-time = "2025-03-25T15:00:58.663Z" },
]
[[package]]
@@ -632,11 +631,11 @@ wheels = [
[[package]]
name = "certifi"
version = "2025.8.3"
version = "2025.7.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
{ url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
]
[[package]]

2
web/.gitignore vendored
View File

@@ -25,8 +25,6 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
playwright-report
test-results
*.lcov
# nyc test coverage

View File

@@ -1,175 +0,0 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import type { LocatorContext } from "#e2e/selectors/types";
import { expect, Page } from "@playwright/test";
export class FormFixture extends PageFixture {
static fixtureName = "Form";
//#region Selector Methods
//#endregion
//#region Field Methods
/**
* Set the value of a text input.
*
* @param fieldName The name of the form element.
* @param value the value to set.
*/
public fill = async (
fieldName: string,
value: string,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent
.getByRole("textbox", {
name: fieldName,
})
.or(
parent.getByRole("spinbutton", {
name: fieldName,
}),
)
.first();
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
await control.fill(value);
};
/**
* Set the value of a radio or checkbox input.
*
* @param fieldName The name of the form element.
* @param value the value to set.
*/
public setInputCheck = async (
fieldName: string,
value: boolean = true,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent.locator("ak-switch-input", {
hasText: fieldName,
});
await control.scrollIntoViewIfNeeded();
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
const currentChecked = await control
.getAttribute("checked")
.then((value) => value !== null);
if (currentChecked === value) {
return;
}
await control.click();
};
/**
* Set the value of a radio or checkbox input.
*
* @param fieldName The name of the form element.
* @param pattern the value to set.
*/
public setRadio = async (
groupName: string,
fieldName: string,
parent: LocatorContext = this.page,
): Promise<void> => {
const group = parent.getByRole("group", { name: groupName });
await expect(group, `Field "${groupName}" should be visible`).toBeVisible();
const control = parent.getByRole("radio", { name: fieldName });
await control.setChecked(true, {
force: true,
});
};
/**
* Set the value of a search select input.
*
* @param fieldLabel The name of the search select element.
* @param pattern The text to match against the search select entry.
*/
public selectSearchValue = async (
fieldLabel: string,
pattern: string | RegExp,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent.getByRole("textbox", { name: fieldLabel });
await expect(
control,
`Search select control (${fieldLabel}) should be visible`,
).toBeVisible();
const fieldName = await control.getAttribute("name");
if (!fieldName) {
throw new Error(`Unable to find name attribute on search select (${fieldLabel})`);
}
// Find the search select input control and activate it.
await control.click();
const button = this.page
// ---
.locator(`div[data-managed-for*="${fieldName}"] button`, {
hasText: pattern,
});
if (!button) {
throw new Error(
`Unable to find an ak-search-select entry matching ${fieldLabel}:${pattern.toString()}`,
);
}
await button.click();
await this.page.keyboard.press("Tab");
await control.blur();
};
public setFormGroup = async (
pattern: string | RegExp,
value: boolean = true,
parent: LocatorContext = this.page,
) => {
const control = parent
.locator("ak-form-group", {
hasText: pattern,
})
.first();
const currentOpen = await control.getAttribute("open").then((value) => value !== null);
if (currentOpen === value) {
this.logger.debug(`Form group ${pattern} is already ${value ? "open" : "closed"}`);
return;
}
this.logger.debug(`Toggling form group ${pattern} to ${value ? "open" : "closed"}`);
await control.click();
if (value) {
await expect(control).toHaveAttribute("open");
} else {
await expect(control).not.toHaveAttribute("open");
}
};
//#endregion
//#region Lifecycle
constructor(page: Page, testName: string) {
super({ page, testName });
}
//#endregion
}

View File

@@ -1,30 +0,0 @@
import { ConsoleLogger, FixtureLogger } from "#logger/node";
import { Page } from "@playwright/test";
export interface PageFixtureOptions {
page: Page;
testName: string;
}
export abstract class PageFixture {
/**
* The name of the fixture.
*
* Used for logging.
*/
static fixtureName: string;
protected readonly logger: FixtureLogger;
protected readonly page: Page;
protected readonly testName: string;
constructor({ page, testName }: PageFixtureOptions) {
this.page = page;
this.testName = testName;
const Constructor = this.constructor as typeof PageFixture;
this.logger = ConsoleLogger.fixture(Constructor.fixtureName, this.testName);
}
}

View File

@@ -1,42 +0,0 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import type { LocatorContext } from "#e2e/selectors/types";
import { Page } from "@playwright/test";
export type GetByRoleParameters = Parameters<Page["getByRole"]>;
export type ARIARole = GetByRoleParameters[0];
export type ARIAOptions = GetByRoleParameters[1];
export type ClickByName = (name: string) => Promise<void>;
export type ClickByRole = (
role: ARIARole,
options?: ARIAOptions,
context?: LocatorContext,
) => Promise<void>;
export class PointerFixture extends PageFixture {
public static fixtureName = "Pointer";
public click = (
name: string,
optionsOrRole?: ARIAOptions | ARIARole,
context: LocatorContext = this.page,
): Promise<void> => {
if (typeof optionsOrRole === "string") {
return context.getByRole(optionsOrRole, { name }).click();
}
const options = {
...optionsOrRole,
name,
};
return (
context
// ---
.getByRole("button", options)
.or(context.getByRole("link", options))
.click()
);
};
}

View File

@@ -1,102 +0,0 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import { Page } from "@playwright/test";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
export const BAD_USERNAME = "bad-username@bad-login.io";
export const BAD_PASSWORD = "-this-is-a-bad-password-";
export interface LoginInit {
username?: string;
password?: string;
to?: URL | string;
}
export class SessionFixture extends PageFixture {
static fixtureName = "Session";
public static readonly pathname = "/if/flow/default-authentication-flow/";
//#region Selectors
public $identificationStage = this.page.locator("ak-stage-identification");
/**
* The username field on the login page.
*/
public $usernameField = this.page.getByLabel("Username");
public $passwordStage = this.page.locator("ak-stage-password");
public $passwordField = this.page.getByLabel("Password");
/**
* The button to submit the the login flow,
* typically redirecting to the authenticated interface.
*/
public $submitButton = this.page.locator('button[type="submit"]');
/**
* A possible authentication failure message.
*/
public $authFailureMessage = this.page.locator(".pf-m-error");
//#endregion
constructor(page: Page, testName: string) {
super({ page, testName });
}
//#region Specific interactions
public checkAuthenticated = async (): Promise<boolean> => {
// TODO: Check if the user is authenticated via API
return true;
};
/**
* Log into the application.
*/
public async login({
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
}: LoginInit = {}) {
this.logger.info("Logging in...");
const initialURL = new URL(this.page.url());
if (initialURL.pathname === SessionFixture.pathname) {
this.logger.info("Skipping navigation because we're already in a authentication flow");
} else {
await this.page.goto(to.toString());
}
await this.$usernameField.fill(username);
const passwordFieldVisible = await this.$passwordField.isVisible();
if (!passwordFieldVisible) {
await this.$submitButton.click();
await this.$passwordField.waitFor({ state: "visible" });
}
await this.$passwordField.fill(password);
await this.$submitButton.click();
const expectedPathname = typeof to === "string" ? to : to.pathname;
await this.page.waitForURL(`**${expectedPathname}`);
}
//#endregion
//#region Navigation
public async toLoginPage() {
await this.page.goto(SessionFixture.pathname);
}
}

View File

@@ -1,37 +0,0 @@
/**
* @file Playwright e2e test helpers.
*/
import { FormFixture } from "#e2e/fixtures/FormFixture";
import { PointerFixture } from "#e2e/fixtures/PointerFixture";
import { SessionFixture } from "#e2e/fixtures/SessionFixture";
import { test as base } from "@playwright/test";
export { expect } from "@playwright/test";
/* eslint-disable react-hooks/rules-of-hooks */
interface E2EFixturesTestScope {
session: SessionFixture;
pointer: PointerFixture;
form: FormFixture;
}
interface E2EWorkerScope {
selectorRegistration: void;
}
export const test = base.extend<E2EFixturesTestScope, E2EWorkerScope>({
session: async ({ page }, use, { title }) => {
await use(new SessionFixture(page, title));
},
form: async ({ page }, use, { title }) => {
await use(new FormFixture(page, title));
},
pointer: async ({ page }, use, { title }) => {
await use(new PointerFixture({ page, testName: title }));
},
});

View File

@@ -1,13 +0,0 @@
import type { Locator } from "@playwright/test";
export type LocatorContext = Pick<
Locator,
| "locator"
| "getByRole"
| "getByTestId"
| "getByText"
| "getByLabel"
| "getByAltText"
| "getByTitle"
| "getByPlaceholder"
>;

View File

@@ -1,60 +0,0 @@
import { IDGenerator } from "@goauthentik/core/id";
import {
adjectives,
colors,
Config as NameConfig,
uniqueNamesGenerator,
} from "unique-names-generator";
/**
* Given a dictionary of words, slice the dictionary to only include words that start with the given letter.
*/
export function alliterate(dictionary: string[], letter: string): string[] {
let firstIndex = 0;
for (let i = 0; i < dictionary.length; i++) {
if (dictionary[i][0] === letter) {
firstIndex = i;
break;
}
}
let lastIndex = firstIndex;
for (let i = firstIndex; i < dictionary.length; i++) {
if (dictionary[i][0] !== letter) {
lastIndex = i;
break;
}
}
return dictionary.slice(firstIndex, lastIndex);
}
export function createRandomName({
seed = IDGenerator.randomID(),
...config
}: Partial<NameConfig> = {}) {
const randomLetterIndex =
typeof seed === "number"
? seed
: Array.from(seed).reduce((acc, char) => acc + char.charCodeAt(0), 0);
const letter = adjectives[randomLetterIndex % adjectives.length][0];
const availableAdjectives = alliterate(adjectives, letter);
const availableColors = alliterate(colors, letter);
const name = uniqueNamesGenerator({
dictionaries: [availableAdjectives, availableAdjectives, availableColors],
style: "capital",
separator: " ",
length: 3,
seed,
...config,
});
return name;
}

View File

@@ -1,102 +0,0 @@
/**
* Application logger.
*
* @import { LoggerOptions, Logger, Level, ChildLoggerOptions } from "pino"
* @import { PrettyOptions } from "pino-pretty"
*/
import { pino } from "pino";
//#region Constants
/**
* Default options for creating a Pino logger.
*
* @category Logger
* @satisfies {LoggerOptions<never, false>}
*/
export const DEFAULT_PINO_LOGGER_OPTIONS = {
enabled: true,
level: "info",
transport: {
target: "./transport.js",
options: /** @satisfies {PrettyOptions} */ ({
colorize: true,
}),
},
};
//#endregion
//#region Functions
/**
* Read the log level from the environment.
* @return {Level}
*/
export function readLogLevel() {
return process.env.AK_LOG_LEVEL || DEFAULT_PINO_LOGGER_OPTIONS.level;
}
/**
* @typedef {Logger} FixtureLogger
*/
/**
* @this {Logger}
* @param {string} fixtureName
* @param {string} [testName]
* @param {ChildLoggerOptions} [options]
* @returns {FixtureLogger}
*/
function createFixtureLogger(fixtureName, testName, options) {
return this.child(
{ name: fixtureName },
{
msgPrefix: `[${testName}] `,
...options,
},
);
}
/**
* @typedef {object} CustomLoggerMethods
* @property {typeof createFixtureLogger} fixture
*/
/**
* @typedef {Logger & CustomLoggerMethods} ConsoleLogger
*/
/**
* A singleton logger instance for Node.js.
*
* ```js
* import { ConsoleLogger } from "#logger/node";
*
* ConsoleLogger.info("Hello, world!");
* ```
*
* @runtime node
* @type {ConsoleLogger}
*/
export const ConsoleLogger = Object.assign(
pino({
...DEFAULT_PINO_LOGGER_OPTIONS,
level: readLogLevel(),
}),
{ fixture: createFixtureLogger },
);
/**
* @typedef {ReturnType<ConsoleLogger['child']>} ChildConsoleLogger
*/
//#region Aliases
export const info = ConsoleLogger.info.bind(ConsoleLogger);
export const debug = ConsoleLogger.debug.bind(ConsoleLogger);
export const warn = ConsoleLogger.warn.bind(ConsoleLogger);
export const error = ConsoleLogger.error.bind(ConsoleLogger);
//#endregion

View File

@@ -1,22 +0,0 @@
/**
* @file Pretty transport for Pino
*
* @import { PrettyOptions } from "pino-pretty"
*/
import PinoPretty from "pino-pretty";
/**
* @param {PrettyOptions} options
*/
function prettyTransporter(options) {
const pretty = PinoPretty({
...options,
ignore: "pid,hostname",
translateTime: "SYS:HH:MM:ss",
});
return pretty;
}
export default prettyTransporter;

1877
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,8 +24,8 @@
"pseudolocalize": "node ./scripts/pseudolocalize.mjs",
"storybook": "storybook dev -p 6006",
"storybook:build": "wireit",
"test": "vitest",
"test:e2e": "playwright test",
"test": "wireit",
"test:e2e": "wireit",
"test:e2e:watch": "wireit",
"test:watch": "wireit",
"tsc": "wireit",
@@ -69,9 +69,6 @@
"#flow/*": "./src/flow/*.js",
"#locales/*": "./src/locales/*.js",
"#stories/*": "./src/stories/*.js",
"#tests/*": "./tests/*.js",
"#e2e": "./e2e/index.ts",
"#e2e/*": "./e2e/*.ts",
"#*/browser": {
"types": "./out/*/browser.d.ts",
"import": "./*/browser.js"
@@ -97,7 +94,7 @@
"@floating-ui/dom": "^1.7.3",
"@formatjs/intl-listformat": "^7.7.11",
"@fortawesome/fontawesome-free": "^7.0.0",
"@goauthentik/api": "^2025.6.4-1754491498",
"@goauthentik/api": "^2025.6.4-1754241870",
"@goauthentik/core": "^1.0.0",
"@goauthentik/esbuild-plugin-live-reload": "^1.1.0",
"@goauthentik/eslint-config": "^1.0.5",
@@ -116,7 +113,6 @@
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.1.0",
"@patternfly/patternfly": "^4.224.2",
"@playwright/test": "^1.54.1",
"@sentry/browser": "^10.0.0",
"@spotlightjs/spotlight": "^3.0.1",
"@storybook/addon-docs": "^9.1.0",
@@ -132,7 +128,6 @@
"@types/react-dom": "^19.1.6",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"@vitest/browser": "^3.2.4",
"@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1",
"change-case": "^5.4.4",
@@ -163,9 +158,6 @@
"md-front-matter": "^1.0.4",
"mermaid": "^11.9.0",
"npm-run-all": "^4.1.5",
"pino": "^9.7.0",
"pino-pretty": "^13.0.0",
"playwright": "^1.54.1",
"prettier": "^3.6.2",
"pseudolocale": "^2.1.0",
"rapidoc": "^9.3.8",
@@ -186,10 +178,7 @@
"turnstile-types": "^1.2.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"unique-names-generator": "^4.7.1",
"unist-util-visit": "^5.0.0",
"vite": "^7.0.6",
"vitest": "^3.2.4",
"webcomponent-qr-code": "^1.3.0",
"wireit": "^0.14.12",
"yaml": "^2.8.0"
@@ -277,7 +266,7 @@
"command": "lit-analyzer src"
},
"lint:types:tests": {
"command": "tsc --noEmit -p tsconfig.test.json"
"command": "tsc --noEmit -p ./tests"
},
"lint:types": {
"command": "tsc -p .",
@@ -326,7 +315,7 @@
],
"env": {
"CI": "true",
"TS_NODE_PROJECT": "tsconfig.test.json"
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
}
},
"test:e2e:watch": {
@@ -335,7 +324,7 @@
"build"
],
"env": {
"TS_NODE_PROJECT": "tsconfig.test.json"
"TS_NODE_PROJECT": "./tests/tsconfig.test.json"
}
},
"test:watch": {

View File

@@ -1,94 +0,0 @@
/**
* @file Playwright configuration.
*
* @see https://playwright.dev/docs/test-configuration
*
* @import { LogFn, Logger } from "pino"
*/
import { ConsoleLogger } from "#logger/node";
import { defineConfig, devices } from "@playwright/test";
const CI = !!process.env.CI;
/**
* @type {Map<string, Logger>}
*/
const LoggerCache = new Map();
const baseURL = process.env.AK_TEST_RUNNER_PAGE_URL ?? "http://localhost:9000";
export default defineConfig({
testDir: "./test/browser",
fullyParallel: true,
forbidOnly: CI,
retries: CI ? 2 : 0,
workers: CI ? 1 : undefined,
reporter: CI
? "github"
: [
// ---
["list", { printSteps: true }],
["html", { open: "never" }],
],
use: {
testIdAttribute: "data-test-id",
baseURL,
trace: "on-first-retry",
launchOptions: {
logger: {
isEnabled() {
return true;
},
log: (name, severity, message, args) => {
let logger = LoggerCache.get(name);
if (!logger) {
logger = ConsoleLogger.child({
name: `Playwright ${name.toUpperCase()}`,
});
LoggerCache.set(name, logger);
}
/**
* @type {LogFn}
*/
let log;
switch (severity) {
case "verbose":
log = logger.debug;
break;
case "warning":
log = logger.warn;
break;
case "error":
log = logger.error;
break;
default:
log = logger.info;
break;
}
if (name === "api") {
log = logger.debug;
}
log.call(logger, message.toString(), args);
},
},
},
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
],
});

View File

@@ -20,7 +20,6 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { ModelForm } from "#elements/forms/ModelForm";
import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { navigate } from "#elements/router/RouterOutlet";
import { iconHelperText } from "#admin/helperText";
import { policyEngineModes } from "#admin/policies/PolicyEngineModes";
@@ -34,90 +33,83 @@ import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-application-form")
export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Application, string>) {
#api = new CoreApi(DEFAULT_CONFIG);
constructor() {
super();
this.handleConfirmBackchannelProviders = this.handleConfirmBackchannelProviders.bind(this);
this.makeRemoveBackchannelProviderHandler =
this.makeRemoveBackchannelProviderHandler.bind(this);
}
protected override async loadInstance(pk: string): Promise<Application> {
const app = await this.#api.coreApplicationsRetrieve({
async loadInstance(pk: string): Promise<Application> {
const app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsRetrieve({
slug: pk,
});
this.clearIcon = false;
this.backchannelProviders = app.backchannelProvidersObj || [];
return app;
}
@property({ attribute: false })
public provider?: number;
provider?: number;
@state()
protected backchannelProviders: Provider[] = [];
backchannelProviders: Provider[] = [];
@property({ type: Boolean })
public clearIcon = false;
clearIcon = false;
protected override getSuccessMessage(): string {
getSuccessMessage(): string {
return this.instance
? msg("Successfully updated application.")
: msg("Successfully created application.");
}
public override async send(applicationRequest: Application): Promise<Application | void> {
applicationRequest.backchannelProviders = this.backchannelProviders.map((p) => p.pk);
const currentSlug = this.instance?.slug;
const app = await (currentSlug
? this.#api.coreApplicationsUpdate({
applicationRequest,
slug: currentSlug,
})
: this.#api.coreApplicationsCreate({ applicationRequest }));
const nextSlug = app.slug;
async send(data: Application): Promise<Application | void> {
let app: Application;
data.backchannelProviders = this.backchannelProviders.map((p) => p.pk);
if (this.instance) {
app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsUpdate({
slug: this.instance.slug,
applicationRequest: data,
});
} else {
app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsCreate({
applicationRequest: data,
});
}
if (this.can(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.files().get("metaIcon");
if (icon || this.clearIcon) {
await this.#api.coreApplicationsSetIconCreate({
slug: nextSlug,
await new CoreApi(DEFAULT_CONFIG).coreApplicationsSetIconCreate({
slug: app.slug,
file: icon,
clear: this.clearIcon,
});
}
} else {
await this.#api.coreApplicationsSetIconUrlCreate({
slug: nextSlug,
await new CoreApi(DEFAULT_CONFIG).coreApplicationsSetIconUrlCreate({
slug: app.slug,
filePathRequest: {
url: applicationRequest.metaIcon || "",
url: data.metaIcon || "",
},
});
}
if (currentSlug && currentSlug !== nextSlug) {
// TODO: This needs refining.
this.instancePk = nextSlug;
navigate(`/core/applications/${nextSlug}`);
}
return app;
}
#handleConfirmBackchannelProviders = (items: Provider[]) => {
handleConfirmBackchannelProviders(items: Provider[]) {
this.backchannelProviders = items;
this.requestUpdate();
return Promise.resolve();
};
}
#makeRemoveBackchannelProviderHandler = (provider: Provider) => {
makeRemoveBackchannelProviderHandler(provider: Provider) {
return () => {
const idx = this.backchannelProviders.indexOf(provider);
this.backchannelProviders.splice(idx, 1);
this.requestUpdate();
};
};
}
handleClearIcon(ev: Event) {
ev.stopPropagation();
@@ -127,25 +119,22 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
this.clearIcon = !!(ev.target as HTMLInputElement).checked;
}
public override renderForm(): TemplateResult {
renderForm(): TemplateResult {
const alertMsg = msg(
"Using this form will only create an Application. In order to authenticate with the application, you will have to manually pair it with a Provider.",
);
return html`
return html`<form class="pf-c-form pf-m-horizontal">
${this.instance ? nothing : html`<ak-alert level="pf-m-info">${alertMsg}</ak-alert>`}
<ak-text-input
name="name"
autocomplete="off"
placeholder=${msg("Application name")}
value=${ifDefined(this.instance?.name)}
label=${msg("Name")}
required
help=${msg("The name displayed in the application library.")}
help=${msg("Application's display Name.")}
></ak-text-input>
<ak-slug-input
name="slug"
autocomplete="off"
value=${ifDefined(this.instance?.slug)}
label=${msg("Slug")}
required
@@ -156,7 +145,6 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
name="group"
value=${ifDefined(this.instance?.group)}
label=${msg("Group")}
placeholder=${msg("e.g. Collaboration, Communication, Internal, etc.")}
help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
)}
@@ -176,8 +164,8 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
"Select backchannel providers which augment the functionality of the main provider.",
)}
.providers=${this.backchannelProviders}
.confirm=${this.#handleConfirmBackchannelProviders}
.remover=${this.#makeRemoveBackchannelProviderHandler}
.confirm=${this.handleConfirmBackchannelProviders}
.remover=${this.makeRemoveBackchannelProviderHandler}
.tooltip=${html`<pf-tooltip
position="top"
content=${msg("Add provider")}
@@ -196,7 +184,6 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
<ak-text-input
name="metaLaunchUrl"
label=${msg("Launch URL")}
placeholder="https://..."
value=${ifDefined(this.instance?.metaLaunchUrl)}
help=${msg(
"If left empty, authentik will try to extract the launch URL based on the selected provider.",
@@ -248,7 +235,7 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
></ak-textarea-input>
</div>
</ak-form-group>
`;
</form>`;
}
}

View File

@@ -13,7 +13,6 @@ import "#elements/buttons/SpinnerButton/ak-spinner-button";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PFSize } from "#common/enums";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { AKElement } from "#elements/Base";
@@ -24,7 +23,7 @@ import {
RbacPermissionsAssignedByUsersListModelEnum,
} from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { msg } from "@lit/localize";
import { CSSResult, html, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -41,6 +40,15 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-application-view")
export class ApplicationViewPage extends AKElement {
@property({ type: String })
applicationSlug?: string;
@state()
application?: Application;
@state()
missingOutpost = false;
static styles: CSSResult[] = [
PFBase,
PFList,
@@ -53,28 +61,7 @@ export class ApplicationViewPage extends AKElement {
PFCard,
];
//#region Properties
@property({ type: String })
public applicationSlug?: string;
//#endregion
//#region State
@state()
protected application?: Application;
@state()
protected error?: APIError;
@state()
protected missingOutpost = false;
//#endregion
//#region Lifecycle
protected fetchIsMissingOutpost(providersByPk: Array<number>) {
fetchIsMissingOutpost(providersByPk: Array<number>) {
new OutpostsApi(DEFAULT_CONFIG)
.outpostsInstancesList({
providersByPk,
@@ -87,34 +74,27 @@ export class ApplicationViewPage extends AKElement {
});
}
protected fetchApplication(slug: string) {
new CoreApi(DEFAULT_CONFIG)
.coreApplicationsRetrieve({ slug })
.then((app) => {
this.application = app;
if (
app.providerObj &&
[
RbacPermissionsAssignedByUsersListModelEnum.AuthentikProvidersProxyProxyprovider.toString(),
RbacPermissionsAssignedByUsersListModelEnum.AuthentikProvidersLdapLdapprovider.toString(),
].includes(app.providerObj.metaModelName)
) {
this.fetchIsMissingOutpost([app.provider || 0]);
}
})
.catch(async (error) => {
this.error = await parseAPIResponseError(error);
});
fetchApplication(slug: string) {
new CoreApi(DEFAULT_CONFIG).coreApplicationsRetrieve({ slug }).then((app) => {
this.application = app;
if (
app.providerObj &&
[
RbacPermissionsAssignedByUsersListModelEnum.AuthentikProvidersProxyProxyprovider.toString(),
RbacPermissionsAssignedByUsersListModelEnum.AuthentikProvidersLdapLdapprovider.toString(),
].includes(app.providerObj.metaModelName)
) {
this.fetchIsMissingOutpost([app.provider || 0]);
}
});
}
public override willUpdate(changedProperties: PropertyValues<this>) {
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("applicationSlug") && this.applicationSlug) {
this.fetchApplication(this.applicationSlug);
}
}
//#region Render
render(): TemplateResult {
return html`<ak-page-header
header=${this.application?.name || msg("Loading")}
@@ -131,17 +111,9 @@ export class ApplicationViewPage extends AKElement {
}
renderApp(): TemplateResult {
if (this.error) {
return html`<ak-empty-state icon="fa-ban"
><span>${msg(str`Failed to fetch application "${this.applicationSlug}".`)}</span>
<div slot="body">${pluckErrorDetail(this.error)}</div>
</ak-empty-state>`;
}
if (!this.application) {
return html`<ak-empty-state default-label></ak-empty-state>`;
}
return html`<ak-tabs>
${this.missingOutpost
? html`<div slot="header" class="pf-c-banner pf-m-warning">
@@ -216,7 +188,7 @@ export class ApplicationViewPage extends AKElement {
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text pf-m-monospace">
<div class="pf-c-description-list__text">
${this.application.policyEngineMode?.toUpperCase()}
</div>
</dd>

View File

@@ -114,14 +114,12 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
<form id="applicationform" class="pf-c-form pf-m-horizontal" slot="form">
<ak-text-input
name="name"
autocomplete="off"
placeholder=${msg("Application name")}
value=${ifDefined(app.name)}
label=${msg("Name")}
required
?invalid=${this.errors.has("name")}
.errorMessages=${errors.name ?? this.errorMessages("name")}
help=${msg("The name displayed in the application library.")}
help=${msg("Application's display Name.")}
></ak-text-input>
<ak-slug-input
name="slug"
@@ -156,7 +154,6 @@ export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
<ak-text-input
name="metaLaunchUrl"
label=${msg("Launch URL")}
placeholder="https://..."
value=${ifDefined(app.metaLaunchUrl)}
?invalid=${this.errors.has("metaLaunchUrl")}
.errorMessages=${errors.metaLaunchUrl ??

View File

@@ -7,13 +7,12 @@ import { groupBy } from "#common/utils";
import { ModelForm } from "#elements/forms/ModelForm";
import { policyEngineModes } from "#admin/policies/PolicyEngineModes";
import {
FlowsApi,
FlowsInstancesListDesignationEnum,
FlowStageBinding,
InvalidResponseActionEnum,
PolicyEngineMode,
Stage,
StagesAllListRequest,
StagesApi,
@@ -203,7 +202,22 @@ export class StageBindingForm extends ModelForm<FlowStageBinding, string> {
required
name="policyEngineMode"
>
<ak-radio .options=${policyEngineModes} .value=${this.instance?.policyEngineMode}>
<ak-radio
.options=${[
{
label: "any",
value: PolicyEngineMode.Any,
default: true,
description: html`${msg("Any policy must match to grant access")}`,
},
{
label: "all",
value: PolicyEngineMode.All,
description: html`${msg("All policies must match to grant access")}`,
},
]}
.value=${this.instance?.policyEngineMode}
>
</ak-radio>
</ak-form-element-horizontal>`;
}

View File

@@ -1,21 +1,17 @@
import type { RadioOption } from "#elements/forms/Radio";
import { PolicyEngineMode } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html } from "lit";
export const policyEngineModes: RadioOption<PolicyEngineMode>[] = [
export const policyEngineModes = [
{
label: "ANY",
className: "pf-m-monospace",
label: "any",
value: PolicyEngineMode.Any,
default: true,
description: html`${msg("Any policy must match to grant access")}`,
},
{
label: "ALL",
className: "pf-m-monospace",
label: "all",
value: PolicyEngineMode.All,
description: html`${msg("All policies must match to grant access")}`,
},

View File

@@ -113,19 +113,6 @@ export const redirectUriHelp = html`${redirectUriHelpMessages.map(
(m) => html`<p class="pf-c-form__helper-text">${m}</p>`,
)}`;
const backchannelLogoutUriHelpMessages = [
msg(
"URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.",
),
msg(
"These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.",
),
];
export const backchannelLogoutUriHelp = html`${backchannelLogoutUriHelpMessages.map(
(m) => html`<p class="pf-c-form__helper-text">${m}</p>`,
)}`;
type ShowClientSecret = (show: boolean) => void;
const defaultShowClientSecret: ShowClientSecret = (_show) => undefined;
@@ -206,17 +193,6 @@ export function renderForm(
</ak-array-input>
${redirectUriHelp}
</ak-form-element-horizontal>
<ak-form-element-horizontal
flow-direction="row"
label=${msg("Back-Channel Logout URI")}
>
<ak-text-input
name="backchannelLogoutUri"
value="${provider?.backchannelLogoutUri ?? ""}"
placeholder=${msg("URL")}
></ak-text-input>
${backchannelLogoutUriHelp}
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
<!-- NOTE: 'null' cast to 'undefined' on signingKey to satisfy Lit requirements -->

View File

@@ -4,7 +4,6 @@ import "#components/events/ObjectChangelog";
import "#elements/CodeMirror";
import "#elements/EmptyState";
import "#elements/Tabs";
import "#elements/tasks/TaskList";
import "#elements/ak-mdx/index";
import "#elements/buttons/ModalButton";
import "#elements/buttons/SpinnerButton/index";
@@ -20,7 +19,6 @@ import {
ClientTypeEnum,
CoreApi,
CoreUsersListRequest,
ModelEnum,
OAuth2Provider,
OAuth2ProviderSetupURLs,
PropertyMappingPreview,
@@ -172,7 +170,6 @@ export class OAuth2ProviderViewPage extends AKElement {
if (!this.provider) {
return html``;
}
const [appLabel, modelName] = ModelEnum.AuthentikProvidersOauth2Oauth2provider.split(".");
return html` ${this.provider?.assignedApplicationName
? html``
: html`<div slot="header" class="pf-c-banner pf-m-warning">
@@ -249,18 +246,6 @@ export class OAuth2ProviderViewPage extends AKElement {
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Back-Channel Logout URI")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text pf-m-monospace">
${this.provider.backchannelLogoutUri}
</div>
</dd>
</div>
</dl>
</div>
<div class="pf-c-card__footer">
@@ -370,18 +355,6 @@ export class OAuth2ProviderViewPage extends AKElement {
</form>
</div>
</div>
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-12-col-on-2xl"
>
<div class="pf-c-card pf-l-grid__item pf-m-12-col-on-2xl">
<div class="pf-c-card__title">${msg("Tasks")}</div>
<ak-task-list
.relObjAppLabel=${appLabel}
.relObjModel=${modelName}
.relObjId="${this.provider.pk}"
></ak-task-list>
</div>
</div>
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-12-col-on-2xl"
>

View File

@@ -232,36 +232,6 @@ export class EmailStageForm extends BaseStageForm<EmailStage> {
})}
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Account Recovery Max Attempts")}
required
name="recoveryMaxAttempts"
>
<input
type="number"
value="${this.instance?.recoveryMaxAttempts ?? 5}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Account Recovery Cache Timeout")}
required
name="recoveryCacheTimeout"
>
<input
type="text"
value="${ifDefined(this.instance?.recoveryCacheTimeout || "minutes=5")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
"The time window used to count recent account recovery attempts.",
)}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
</div>
</ak-form-group>
${this.renderConnectionSettings()}`;

View File

@@ -292,7 +292,7 @@ export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "au
* @todo Can this be handled with a Lit Mixin?
*/
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
const element = document.body.querySelector<T>("[data-test-id=interface-root]");
const element = document.body.querySelector<T>("[data-ak-interface-root]");
if (!element) {
throw new Error(

View File

@@ -35,7 +35,7 @@ export class AkToggleGroup extends CustomEmitterElement(AKElement) {
`,
];
/**
/*
* The value (causes highlighting, value is returned)
*
* @attr

View File

@@ -28,6 +28,6 @@ export abstract class Interface extends AKElement {
public connectedCallback(): void {
super.connectedCallback();
this.dataset.testId = "interface-root";
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
}
}

View File

@@ -103,7 +103,7 @@ type ContentValue = SlottedTemplateResult | undefined;
*/
export function akLoadingOverlay(
properties: ILoadingOverlay = {},
content: string | ILoadingOverlayContent = {},
content: ILoadingOverlayContent = {},
) {
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
// slot-name.

View File

@@ -46,7 +46,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
/* The array of key/value pairs this pane is currently showing */
@property({ type: Array })
public readonly options?: DualSelectPair[];
readonly options: DualSelectPair[] = [];
/**
* A set (set being easy for lookups) of keys with all the pairs selected,
@@ -54,7 +54,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
* can be marked and their clicks ignored.
*/
@property({ type: Object })
public readonly selected: Set<string> = new Set();
readonly selected: Set<string> = new Set();
//#endregion
@@ -75,17 +75,11 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
//#region Refs
#listRef = createRef<HTMLDivElement>();
#scrollAnimationFrame = -1;
#scrollIntoView = (): void => {
this.#listRef.value?.scrollTo(0, 0);
};
protected listRef = createRef<HTMLDivElement>();
//#region Lifecycle
public overrideconnectedCallback() {
connectedCallback() {
super.connectedCallback();
for (const [attr, value] of hostAttributes) {
@@ -95,11 +89,9 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
}
}
protected override updated(changed: PropertyValues<this>) {
if (changed.has("options") && this.options?.length) {
cancelAnimationFrame(this.#scrollAnimationFrame);
this.#scrollAnimationFrame = requestAnimationFrame(this.#scrollIntoView);
protected updated(changed: PropertyValues<this>) {
if (changed.has("options")) {
this.listRef.value?.scrollTo(0, 0);
}
}
@@ -126,9 +118,10 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
this.toMove.add(key);
}
const moved = [...this.toMove].sort();
this.dispatchCustomEvent(DualSelectEventType.MoveChanged, moved);
this.dispatchCustomEvent(
DualSelectEventType.MoveChanged,
Array.from(this.toMove.values()).sort(),
);
this.dispatchCustomEvent(DualSelectEventType.Move);
@@ -152,7 +145,7 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
render() {
return html`
<div ${ref(this.#listRef)} class="pf-c-dual-list-selector__menu">
<div ${ref(this.listRef)} class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => {
const selected = classMap({

View File

@@ -100,11 +100,9 @@ export const globalVariables = css`
--pf-c-dual-list-selector__list-item-row--BackgroundColor: var(
--ak-dark-background-light-ish
);
--pf-c-dual-list-selector__list-item-row--focus-within--BackgroundColor: var(
--ak-dark-background-darker
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
--ak-dark-background-lighter;
);
--pf-c-dual-list-selector__list-item-row--hover--BackgroundColor: var(
--pf-global--BackgroundColor--400
);

View File

@@ -220,10 +220,7 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
return rect.x + rect.y + rect.width + rect.height !== 0;
}
/**
* An overridable method for returning a success message after a successful submission.
*/
protected getSuccessMessage(): string {
public getSuccessMessage(): string {
return this.successMessage;
}
@@ -368,9 +365,6 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
</form>`;
}
/**
* An overridable method for rendering the form content.
*/
public renderForm(): SlottedTemplateResult | null {
return null;
}

View File

@@ -1,7 +1,7 @@
import { AKElement } from "#elements/Base";
import { msg } from "@lit/localize";
import { css, CSSResult, html, PropertyValues, TemplateResult } from "lit";
import { css, CSSResult, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
@@ -20,6 +20,15 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
*/
@customElement("ak-form-group")
export class AKFormGroup extends AKElement {
@property({ type: Boolean, reflect: true })
public open = false;
@property({ type: String, reflect: true })
public label = msg("Details");
@property({ type: String, reflect: true })
public description?: string;
static styles: CSSResult[] = [
PFBase,
PFForm,
@@ -37,6 +46,27 @@ export class AKFormGroup extends AKElement {
}
details {
&::details-content {
height: 0;
overflow: clip;
transition-behavior: normal, allow-discrete;
transition-duration: var(--pf-global--TransitionDuration);
transition-timing-function: var(--pf-global--TimingFunction);
transition-property: height, content-visibility;
@media (prefers-reduced-motion) {
transition-duration: 0;
}
}
@supports (interpolate-size: allow-keywords) {
interpolate-size: allow-keywords;
&[open]::details-content {
height: auto;
}
}
&::details-content {
padding-inline-start: var(
--pf-c-form__field-group--GridTemplateColumns--toggle
@@ -72,39 +102,12 @@ export class AKFormGroup extends AKElement {
`,
];
//region Properties
formRef = createRef<HTMLFormElement>();
@property({ type: Boolean, reflect: true })
public open = false;
scrollAnimationFrame = -1;
@property({ type: String, reflect: true })
public label = msg("Details");
@property({ type: String, reflect: true })
public description?: string;
//#endregion
//#region Lifecycle
public override updated(changedProperties: PropertyValues<this>): void {
const previousOpen = changedProperties.get("open");
if (typeof previousOpen !== "boolean") return;
if (this.open && this.open !== previousOpen) {
cancelAnimationFrame(this.#scrollAnimationFrame);
this.#scrollAnimationFrame = requestAnimationFrame(this.#scrollIntoView);
}
}
#detailsRef = createRef<HTMLDetailsElement>();
#scrollAnimationFrame = -1;
#scrollIntoView = (): void => {
this.#detailsRef.value?.scrollIntoView({
scrollIntoView = (): void => {
this.formRef.value?.scrollIntoView({
behavior: "smooth",
});
};
@@ -114,16 +117,19 @@ export class AKFormGroup extends AKElement {
*/
public toggle = (event: Event): void => {
event.preventDefault();
cancelAnimationFrame(this.scrollAnimationFrame);
this.open = !this.open;
};
//#region Render
if (this.open) {
this.scrollAnimationFrame = requestAnimationFrame(this.scrollIntoView);
}
};
public render(): TemplateResult {
return html`
<details
${ref(this.#detailsRef)}
${ref(this.formRef)}
?open=${this.open}
?aria-expanded="${this.open}"
role="group"
@@ -161,8 +167,6 @@ export class AKFormGroup extends AKElement {
</details>
`;
}
//#endregion
}
declare global {

View File

@@ -9,24 +9,15 @@ import { html, TemplateResult } from "lit";
import { property } from "lit/decorators.js";
/**
* Model form
*
* A base form that automatically tracks the server-side object (instance)
* that we're interested in. Handles loading and tracking of the instance.
*/
export abstract class ModelForm<T, PKT extends string | number> extends Form<T> {
/**
* An overridable method for loading an instance.
*
* @param pk The primary key of the instance to load.
* @returns A promise that resolves to the loaded instance.
*/
protected abstract loadInstance(pk: PKT): Promise<T>;
/**
* An overridable method for loading any data, beyond the instance.
*
* @see {@linkcode loadInstance}
* @returns A promise that resolves when the data has been loaded.
*/
export abstract class ModelForm<T, PKT extends string | number> extends Form<T> {
abstract loadInstance(pk: PKT): Promise<T>;
async load(): Promise<void> {
return Promise.resolve();
}

View File

@@ -14,7 +14,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface RadioOption<T> {
label: string;
description?: TemplateResult;
className?: string;
default?: boolean;
value: T;
disabled?: boolean;
@@ -101,9 +100,7 @@ export class Radio<T> extends CustomEmitterElement(AKElement) {
.checked=${option.value === this.value}
.disabled=${option.disabled}
/>
<label class="pf-c-radio__label ${option.className ?? ""}" for=${id}
>${option.label}</label
>
<label class="pf-c-radio__label" for=${id}>${option.label}</label>
${option.description
? html`<span class="pf-c-radio__description">${option.description}</span>`
: nothing}

View File

@@ -623,7 +623,7 @@ export abstract class Table<T extends object>
<ak-table-pagination
class="pf-c-toolbar__item pf-m-pagination"
.pages=${this.data?.pagination}
.onPageChange=${handler}
.pageChangeHandler=${handler}
>
</ak-table-pagination>
`;

View File

@@ -15,13 +15,13 @@ export type TablePageChangeListener = (page: number) => void;
@customElement("ak-table-pagination")
export class TablePagination extends AKElement {
@property({ type: String })
public label?: string;
label?: string;
@property({ attribute: false })
public pages?: Pagination;
pages?: Pagination;
@property({ attribute: false })
public onPageChange?: TablePageChangeListener;
onPageChange?: TablePageChangeListener;
static styles: CSSResult[] = [
PFBase,

View File

@@ -1,37 +0,0 @@
import { expect, test } from "#e2e";
import {
BAD_PASSWORD,
BAD_USERNAME,
GOOD_PASSWORD,
GOOD_USERNAME,
} from "#e2e/fixtures/SessionFixture";
test.beforeEach(async ({ session }) => {
await session.toLoginPage();
});
test.describe("Session management", () => {
test("Login with valid credentials", async ({ session, page }) => {
await session.login({ username: GOOD_USERNAME, password: GOOD_PASSWORD });
await expect(
page.getByRole("heading", {
level: 1,
}),
).toHaveText("My applications");
});
test("Reject bad username", async ({ session }) => {
await session.login({ username: BAD_USERNAME, password: GOOD_PASSWORD });
await expect(session.$authFailureMessage).toBeVisible();
await expect(session.$authFailureMessage).toHaveText("Invalid password");
});
test("Reject bad password", async ({ session }) => {
await session.login({ username: GOOD_USERNAME, password: BAD_PASSWORD });
await expect(session.$authFailureMessage).toBeVisible();
await expect(session.$authFailureMessage).toHaveText("Invalid password");
});
});

View File

@@ -1,87 +0,0 @@
/**
* @file Vitest browser utilities for Lit.
*
* @import { LocatorSelectors } from '@vitest/browser/context'
* @import { PrettyDOMOptions } from '@vitest/browser/utils'
* @import { RenderOptions as LitRenderOptions } from 'lit'
*/
import { debug, getElementLocatorSelectors } from "@vitest/browser/utils";
import { render as renderLit } from "lit";
/**
* @implements {Disposable}
*/
export class LitViteContext {
/**
* @type {Set<Disposable>}
*/
static #resources = new Set();
/**
* @param {unknown} template
* @param {HTMLElement} [container]
* @param {LitRenderOptions} [options]
*
* @returns {LitViteContext}
*/
static render = (template, container = document.createElement("div"), options) => {
const context = new LitViteContext(container);
context.render(template, options);
return context;
};
static [Symbol.dispose] = () => {
this.#resources.forEach((resource) => resource[Symbol.dispose]());
this.#resources.clear();
};
static cleanup = () => {
return this[Symbol.dispose]();
};
/**
* @param {unknown} template
* @param {LitRenderOptions} [options]
*/
render(template, options) {
return renderLit(template, this.container, options);
}
/**
* @type {HTMLElement} container
*/
container;
/**
* @type {LocatorSelectors}
*/
$;
/**
* @param {HTMLElement} container
*/
constructor(container) {
this.container = container;
this.$ = getElementLocatorSelectors(container);
}
toFragment() {
return document.createRange().createContextualFragment(this.container.innerHTML);
}
/**
* @param {number} [maxLength]
* @param {PrettyDOMOptions} [options]
*/
debug(maxLength, options) {
return debug(this.container, maxLength, options);
}
[Symbol.dispose] = () => {
this.container.remove();
LitViteContext.#resources.delete(this);
};
}

View File

@@ -1,12 +0,0 @@
import { LitViteContext } from "./rendering.js";
import { page } from "@vitest/browser/context";
import { beforeEach } from "vitest";
page.extend({
// @ts-ignore
renderLit: LitViteContext.render,
[Symbol.for("vitest:component-cleanup")]: LitViteContext.cleanup,
});
beforeEach(() => LitViteContext.cleanup());

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"moduleResolution": "node",
"module": "ESNext",

View File

@@ -2,11 +2,5 @@
{
"extends": "./tsconfig.json",
"exclude": [
// ---
"src/**/*.test.ts",
"src/**/*.comp.ts",
"./**/*.stories.ts",
"./tests"
]
"exclude": ["src/**/*.test.ts", "./tests"]
}

View File

@@ -1,5 +1,24 @@
// @file TSConfig used by the web package during build.
// @file TSConfig used during tests.
{
"extends": "./tsconfig.json"
"compilerOptions": {
"baseUrl": ".",
"types": ["node", "webdriverio/async", "@wdio/cucumber-framework", "expect-webdriverio"],
"target": "esnext",
"module": "esnext",
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"lib": [
"ES5",
"ES2015",
"ES2016",
"ES2017",
"ES2018",
"ES2019",
"ES2020",
"ESNext",
"DOM",
"DOM.Iterable",
"WebWorker"
]
}
}

23
web/types/node.d.ts vendored
View File

@@ -14,13 +14,12 @@ declare module "module" {
* const relativeDirname = dirname(fileURLToPath(import.meta.url));
* ```
*/
var __dirname: string;
}
}
declare module "process" {
import { Level } from "pino";
global {
namespace NodeJS {
interface ProcessEnv {
@@ -31,26 +30,6 @@ declare module "process" {
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
*/
readonly NODE_ENV?: "development" | "production";
/**
* Whether or not we are running on a CI server.
*/
readonly CI?: string;
/**
* The application log level.
*/
readonly AK_LOG_LEVEL?: Level;
/**
* The base URL of web server to run the tests against.
*
* Typically this is `http://localhost:9000`.
*
* @format url
*/
readonly AK_TEST_RUNNER_PAGE_URL?: string;
/**
* @todo Determine where this is used and if it is needed,
* give it a better name.

View File

@@ -1,5 +1,3 @@
/// <reference types="vitest/config" />
import { createBundleDefinitions } from "#bundler/utils/node";
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
@@ -11,41 +9,4 @@ export default defineConfig({
// ---
inlineCSSPlugin(),
],
test: {
dir: "./test",
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/out/**",
"**/.{idea,git,cache,output,temp}/**",
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
],
projects: [
{
test: {
include: ["./unit/**/*.{test,spec}.ts", "**/*.unit.{test,spec}.ts"],
name: "unit",
environment: "node",
},
},
{
test: {
setupFiles: ["./test/lit/setup.js"],
include: ["./browser/**/*.{test,spec}.ts", "**/*.browser.{test,spec}.ts"],
name: "browser",
browser: {
enabled: true,
provider: "playwright",
instances: [
{
browser: "chromium",
},
],
},
},
},
],
},
});

View File

@@ -1711,6 +1711,11 @@
<source>Successfully created application.</source>
<target>Anwendung erfolgreich erstellt.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Anzeigename der Anwendung</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -10009,27 +10014,6 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1374,6 +1374,10 @@
<source>Successfully created application.</source>
<target>Successfully created application.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Application's display Name.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
<target>Slug</target>
@@ -7888,27 +7892,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1711,6 +1711,11 @@
<source>Successfully created application.</source>
<target>Aplicación creada correctamente.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Nombre para mostrar de la aplicación.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -10057,27 +10062,6 @@ El valor de este campo se compara con el atributo de pertenencia del usuario.</t
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1711,6 +1711,11 @@
<source>Successfully created application.</source>
<target>Application créée avec succès</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Nom d'affichage de l'application</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -9904,192 +9909,129 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
</trans-unit>
<trans-unit id="s334b3924d2bd5d55">
<source>Kerberos Source</source>
<target>Source Kerberos</target>
</trans-unit>
<trans-unit id="sbcdf61483337948a">
<source>Successfully updated schedule.</source>
<target>Planification mise à jour avec succès.</target>
</trans-unit>
<trans-unit id="sd372fe5b712f6b30">
<source>Crontab</source>
<target>Crontab</target>
</trans-unit>
<trans-unit id="s95bc8722b2708f8b">
<source>Paused</source>
<target>Mis en pause</target>
</trans-unit>
<trans-unit id="se41af830054194bc">
<source>Pause this schedule</source>
<target>Mettre cette planification en pause</target>
</trans-unit>
<trans-unit id="sd091e43d5e99dea4">
<source>Waiting to run</source>
<target>En attente de lancement</target>
</trans-unit>
<trans-unit id="s85994a70cd39166c">
<source>Running</source>
<target>En cours d'exécution</target>
</trans-unit>
<trans-unit id="sc7637275f670c938">
<source>Queue</source>
<target>File</target>
</trans-unit>
<trans-unit id="sb91d1be10eb2c7da">
<source>Last updated</source>
<target>Mis à jour pour la dernière fois</target>
</trans-unit>
<trans-unit id="s911c2c952e64b223">
<source>Show only standalone tasks</source>
<target>Afficher uniquement les tâches non liées</target>
</trans-unit>
<trans-unit id="s14053a4609676b8b">
<source>Exclude successful tasks</source>
<target>Exclure les tâches réussies</target>
</trans-unit>
<trans-unit id="s8ff646d0515aab2a">
<source>Retry task</source>
<target>Réessayer la tâche</target>
</trans-unit>
<trans-unit id="s9b8dccb514a0e34c">
<source>Schedule</source>
<target>Planification</target>
</trans-unit>
<trans-unit id="sf92789320708efed">
<source>Next run</source>
<target>Prochaine exécution</target>
</trans-unit>
<trans-unit id="s1aecc6c0d6cbf4ed">
<source>Last status</source>
<target>Dernier état</target>
</trans-unit>
<trans-unit id="s6089c283e28012fb">
<source>Show only standalone schedules</source>
<target>Afficher uniquement les tâches non liées</target>
</trans-unit>
<trans-unit id="s48006fb6e0b1860a">
<source>Run scheduled task now</source>
<target>Exécuter la tâche planifiée maintenant</target>
</trans-unit>
<trans-unit id="s6492593d534108e1">
<source>Update Schedule</source>
<target>Mettre à jour la planification</target>
</trans-unit>
<trans-unit id="sf2d616b20d62240d">
<source>Schedules</source>
<target>Planifications</target>
</trans-unit>
<trans-unit id="sc797fd9076cc136d">
<source>Tasks</source>
<target>Tâches</target>
</trans-unit>
<trans-unit id="s3f63421908094590">
<source>Current status</source>
<target>État actuel</target>
</trans-unit>
<trans-unit id="s4412853e1655ebb3">
<source>Sync is currently running.</source>
<target>La synchronisation est en cours d'exécution.</target>
</trans-unit>
<trans-unit id="s08661004803c26b0">
<source>Sync is not currently running.</source>
<target>La synchronisation n'est pas en cours d'exécution.</target>
</trans-unit>
<trans-unit id="s81d16aa9ec62262c">
<source>Last successful sync</source>
<target>Dernière synchronisation réussie</target>
</trans-unit>
<trans-unit id="sd1eb8f8acdbcd1d3">
<source>No successful sync found.</source>
<target>Pas de synchronisation réussie trouvée.</target>
</trans-unit>
<trans-unit id="s6b0eeb3de1789c6e">
<source>Last sync status</source>
<target>Dernier état de synchronisation</target>
</trans-unit>
<trans-unit id="sac44b0f4dc14c227">
<source>Current execution logs</source>
<target>Journaux d'exécution courant</target>
</trans-unit>
<trans-unit id="s5e13dff03b580216">
<source>Previous executions logs</source>
<target>Journaux d'exécution précédents</target>
</trans-unit>
<trans-unit id="s6abb1cd87fe0114e">
<source>Home</source>
<target>Accueil</target>
</trans-unit>
<trans-unit id="se58e6ed983bf34b0">
<source>Collapse navigation</source>
<target>Réduire la navigation</target>
</trans-unit>
<trans-unit id="sc6ef25894ed00175">
<source>Expand navigation</source>
<target>Développer la navigation</target>
</trans-unit>
<trans-unit id="s148b5e365440a7c1">
<source>Table pagination</source>
<target>Pagination du tableau</target>
</trans-unit>
<trans-unit id="s5d929ff1619ac0c9">
<source>Search</source>
<target>Rechercher</target>
</trans-unit>
<trans-unit id="sd2c2366d13599d8c">
<source>Table actions</source>
<target>Actions du tableau</target>
</trans-unit>
<trans-unit id="s3d195621e562d805">
<source>Select row</source>
<target>Sélectionner la ligne</target>
</trans-unit>
<trans-unit id="s572d21b6a41e24fa">
<source>Table of <x id="0" equiv-text="${this.label}"/></source>
<target>Tableau de <x id="0" equiv-text="${this.label}"/></target>
</trans-unit>
<trans-unit id="sa25b60b4fac481aa">
<source>Table content</source>
<target>Contenu du tableau</target>
</trans-unit>
<trans-unit id="s5eba8fa19126f70a">
<source>Learn more about the enterprise license.</source>
<target>En apprendre plus sur les licences entreprise.</target>
</trans-unit>
<trans-unit id="s9db1679f3b234d4e">
<source>Search for providers…</source>
<target>Rechercher des fournisseurs…</target>
</trans-unit>
<trans-unit id="s76790480b7b28ad2">
<source>Edit provider</source>
<target>Modifier le fournisseur</target>
</trans-unit>
<trans-unit id="s9839619155ed2cf6">
<source>Default NameID Policy</source>
<target>Politique NameID par défaut</target>
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
<target>Configure la politique NameID par défaut utilisée pour les connexions initiées par l'IDP et lorsqu'une assertion entrante ne spécifie pas de politique NameID (s'applique également lors de l'utilisation d'un mappage NameID personnalisé).</target>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1711,6 +1711,11 @@
<source>Successfully created application.</source>
<target>Applicazione creata con successo.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Nome visualizzato dell'applicazione.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -10013,27 +10018,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1682,6 +1682,11 @@
<source>Successfully created application.</source>
<target>애플리케이션을 성공적으로 만들었습니다.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>애플리케이션의 표시 이름입니다.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -9335,27 +9340,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1692,6 +1692,11 @@
<source>Successfully created application.</source>
<target>Applicatie succesvol aangemaakt.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Weergavenaam van de applicatie.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -9239,27 +9244,6 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1711,6 +1711,11 @@
<source>Successfully created application.</source>
<target>Pomyślnie utworzono aplikacje.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Wyświetlana nazwa aplikacji.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -9656,27 +9661,6 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1694,6 +1694,11 @@
<source>Successfully created application.</source>
<target>Śũććēśśƒũĺĺŷ ćŕēàţēď àƥƥĺĩćàţĩōń.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Àƥƥĺĩćàţĩōń'ś ďĩśƥĺàŷ Ńàḿē.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -9665,25 +9670,4 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body></file></xliff>

View File

@@ -1711,6 +1711,11 @@
<source>Successfully created application.</source>
<target>Приложение успешно создано.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Отображаемое имя приложения.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -9747,27 +9752,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1693,6 +1693,11 @@
<source>Successfully created application.</source>
<target>Uygulama başarıyla oluşturuldu.</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>Uygulamanın görünen Adı.</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -9720,27 +9725,6 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -533,6 +533,9 @@
<trans-unit id="s9222ca30ae7786e4">
<source>Successfully created application.</source>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
</trans-unit>
@@ -6514,27 +6517,6 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1711,6 +1711,11 @@
<source>Successfully created application.</source>
<target>已成功创建应用程序。</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>应用的显示名称。</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -10014,27 +10019,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1303,6 +1303,10 @@
<source>Successfully created application.</source>
<target>已成功创建应用程序。</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>应用的显示名称。</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
<target>Slug</target>
@@ -7594,27 +7598,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -1681,6 +1681,11 @@
<source>Successfully created application.</source>
<target>成功建立應用程式。</target>
</trans-unit>
<trans-unit id="s03907d7a66c6164e">
<source>Application's display Name.</source>
<target>應用程式的顯示名稱。</target>
</trans-unit>
<trans-unit id="s91f70424f5d5d23e">
<source>Slug</source>
@@ -9315,27 +9320,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s0c48c5f754275796">
<source>Configure the default NameID Policy used by IDP-initiated logins and when an incoming assertion doesn't specify a NameID Policy (also applies when using a custom NameID Mapping).</source>
</trans-unit>
<trans-unit id="s6c973fe9014080fc">
<source>Application name</source>
</trans-unit>
<trans-unit id="s704091f8e3dbd721">
<source>The name displayed in the application library.</source>
</trans-unit>
<trans-unit id="s418c59028ef6bc2a">
<source>URIs to send back-channel logout notifications to when users log out. Required for OpenID Connect Back-Channel Logout functionality.</source>
</trans-unit>
<trans-unit id="sa091b3064afa00f5">
<source>These URIs are called server-to-server when a user logs out to notify OAuth2/OpenID clients about the logout event.</source>
</trans-unit>
<trans-unit id="s6d364031a996b540">
<source>Back-Channel Logout URI</source>
</trans-unit>
<trans-unit id="sc22c7703fd074f5f">
<source>e.g. Collaboration, Communication, Internal, etc.</source>
</trans-unit>
<trans-unit id="sb6fcdabf769208a1">
<source>Failed to fetch application "<x id="0" equiv-text="${this.applicationSlug}"/>".</source>
</trans-unit>
</body>
</file>

View File

@@ -43,20 +43,11 @@ See https://docs.hcaptcha.com/switch
### Cloudflare Turnstile
See https://developers.cloudflare.com/turnstile/get-started/migrating-from-recaptcha.
See https://developers.cloudflare.com/turnstile/get-started/migrating-from-recaptcha
#### Configuration options
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Flows and Stages** > **Stages** and click **Create**.
3. Select **Captcha Stage** and click **Next**.
4. Provide a descriptive name for the stage (e.g. `authentication-captcha`) and configure the following required settings based on the values of your [Cloudflare Turnstile Widget](https://developers.cloudflare.com/turnstile/concepts/widget/):
- Under **Stage-specific settings**:
- **Public Key**: set to the **Turnstile Site Key** value from the widget.
- **Private Key**: set to the **Turnstile Secret Key** value from the widget.
- **Enable Interactive**: Enable this option if the Turnstile instance is configured as **Invisible** or **Managed**.
- Leave both score thresholds at their default, as they are not supported for Turnstile.
- Interactive: Enabled if the Turnstile instance is configured as visible or managed
- JS URL: `https://challenges.cloudflare.com/turnstile/v0/api.js`
- API URL: `https://challenges.cloudflare.com/turnstile/v0/siteverify`

View File

@@ -5,6 +5,6 @@ slug: /branding
You can configure several differently "branded" options depending on the associated domain, even though objects such as applications, providers, etc, are still global. This can be handy to use the same authentik instance, but branded differently for different domains.
The main settings that control your instance's appearance and behaviour are the [_branding settings_](../sys-mgmt/brands.md#branding-settings) and the the [_default flows_](../sys-mgmt/brands.md#default-flows). Review our tips for using images and icons in the [Image optimization](../sys-mgmt/brands.md#image-optimization) section.
The main settings that control your instance's appearance and behaviour are the _default flows_ and the _branding settings_.
To create or modify a brand, open the Admin interface and navigate to **System** > **Brands**. For complete instructions refer to our [Brands documentation](../sys-mgmt/brands.md).

View File

@@ -22,8 +22,6 @@ To simplify translation you can use https://www.transifex.com/authentik/authenti
- Make (again, any recent version should work)
- Docker
### Frontend
Run `npm i` in the `/web` folder to install all dependencies.
Ensure the language code is in the `lit-localize.json` file in `web/`:
@@ -44,17 +42,3 @@ Afterwards, run `make web-i18n-extract` to generate a base .xlf file.
The .xlf files can be edited by any text editor, or using a tool such as [POEdit](https://poedit.net/).
To see the change, run `make web-watch` in the root directory of the repository.
### Backend
Backend translations are handled by `core-i18n-extract`.
Use Django's translation utility to declare the string, e.g.:
```python
from django.utils.translation import gettext as _
_("New text to be translated.")
```
Afterwards, run `make core-i18n-extract` to generate the updated translation files.

View File

@@ -100,7 +100,7 @@ See [Configuration](../configuration/configuration.mdx) to change the internal p
## Startup
:::warning
All internal operations use UTC. Times displayed in the UI are automatically localized for the user. Do not update or mount `/etc/timezone` or `/etc/localtime` in the authentik containers; it will cause problems with OAuth and SAML authentication, as seen this [GitHub issue](https://github.com/goauthentik/authentik/issues/3005).
The server assumes to have local timezone as UTC. All internal operations use UTC; When displaying times in the UI, they are automatically localized for the user. Do not update or mount `/etc/timezone` or `/etc/localtime` in the authentik containers; it will cause problems with OAuth and SAML authentication, as seen this [GitHub issue](https://github.com/goauthentik/authentik/issues/3005).
:::
Afterward, run these commands to finish:

View File

@@ -54,21 +54,6 @@ Instead, the following metrics are now available:
- `authentik_tasks_delayed_in_progress`
- `authentik_tasks_duration_milliseconds`
### Docker image deprecation notice for `beryju/authentik` and `beryju/authentik-*`
The `beryju/authentik` and `beryju/authentik-*` Docker images are no longer being updated. Users are now encouraged to use the following images:
- **Server image:**
- `ghcr.io/goauthentik/server` or `authentik/server`
- **Outpost images:**
- `ghcr.io/goauthentik/ldap` or `authentik/ldap`
- `ghcr.io/goauthentik/proxy` or `authentik/proxy`
- `ghcr.io/goauthentik/rac` or `authentik/rac`
- `ghcr.io/goauthentik/radius` or `authentik/radius`
We recommend updating your Docker Compose files or other container configurations to use these new image paths.
## New features
## Upgrading

View File

@@ -551,7 +551,7 @@ const items = [
},
items: [
"users-sources/sources/social-logins/apple/index",
"users-sources/sources/social-logins/entra-id/index",
"users-sources/sources/social-logins/azure-ad/index",
"users-sources/sources/social-logins/discord/index",
"users-sources/sources/social-logins/facebook/index",
"users-sources/sources/social-logins/github/index",

View File

@@ -72,23 +72,3 @@ When using the [Mutual TLS Stage](../add-secure-apps/flows-stages/stages/mtls/in
#### Attributes
Attributes such as locale, theme settings (light/dark mode), and custom attributes can be set to a per-brand default value here. Any custom attributes can be retrieved via [`group_attributes()`](../users-sources/user/user_ref.mdx#object-properties).
## Image optimization
When you use images and icons for a brand's logo, favicon, etc., be aware of the following optimization tips:
- Use an SVG version of the image.
- Trim excess whitespace from around the logo. You can use an SVG editor such as Inkscape, Sketch, or Adobe Illustrator.
- Adjust the viewBox: Ensure the SVGs `viewBox` attribute tightly wraps the actual logo content. This helps in scaling the logo appropriately.
- Remove fixed dimensions: delete any fixed width and height attributes from the SVG. This allows the logo to scale responsively within its container.
- Check if your SVG needs `preserveAspectRatio` to retain its shape when resized.
- Wordmark logos: aim for an aspect ratio of approximately 7:1 (width to height).
- Icon logos: use a 1:1 aspect ratio, ensuring the icon fills the entire viewBox and is centered.
- The SVG tool [SVGOMG](https://svgomg.net/) is useful for trimming any excess metadata that might affect how the browser rasterizes the image.

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

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