Compare commits

..

5 Commits

Author SHA1 Message Date
Marcelo Elizeche Landó
42afef2bb2 remove placeholder section 2025-08-14 15:10:38 -04:00
Marcelo Elizeche Landó
b3ba6f9134 fix linting 2025-08-14 15:08:23 -04:00
Marcelo Elizeche Landó
22971942c1 Merge branch 'main' into pr/16059 2025-08-14 14:52:35 -04:00
Brian Begun
907d311339 Update website/integrations/media/tautulli/index.md
Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Brian Begun <begunfx@usa.net>
2025-08-10 12:32:17 -07:00
Brian Begun
9890ea8275 Update index.md
Revised tutorial using new template.  Sorry for the delay on this.  

Signed-off-by: Brian Begun <begunfx@usa.net>
2025-08-10 07:31:51 -07:00
60 changed files with 405 additions and 1668 deletions

View File

@@ -76,12 +76,12 @@ 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.11 AS uv
FROM ghcr.io/astral-sh/uv:0.8.10 AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.6-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/ak-root/lifecycle:/ak-root/venv/bin:$PATH" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_NATIVE_TLS=1 \
@@ -145,6 +145,8 @@ LABEL org.opencontainers.image.authors="Authentik Security Inc." \
org.opencontainers.image.vendor="Authentik Security Inc." \
org.opencontainers.image.version=${VERSION}
WORKDIR /
# We cannot cache this layer otherwise we'll end up with a bigger image
RUN apt-get update && \
apt-get upgrade -y && \
@@ -155,26 +157,28 @@ RUN apt-get update && \
pip3 install --no-cache-dir --upgrade pip && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /ak-root authentik && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
mkdir -p /certs /media /blueprints && \
mkdir -p /ak-root/authentik/.ssh && \
chown authentik:authentik /certs /media /ak-root/authentik/.ssh /ak-root
mkdir -p /authentik/.ssh && \
mkdir -p /ak-root && \
chown authentik:authentik /certs /media /authentik/.ssh /ak-root
COPY ./authentik/ /ak-root/authentik
COPY ./pyproject.toml /ak-root/
COPY ./uv.lock /ak-root/
COPY ./schemas /ak-root/schemas
COPY ./locale /ak-root/locale
COPY ./tests /ak-root/tests
COPY ./manage.py /ak-root/
COPY ./authentik/ /authentik
COPY ./pyproject.toml /
COPY ./uv.lock /
COPY ./schemas /schemas
COPY ./locale /locale
COPY ./tests /tests
COPY ./manage.py /
COPY ./blueprints /blueprints
COPY ./lifecycle/ /ak-root/lifecycle
COPY ./lifecycle/ /lifecycle
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
COPY --from=go-builder /go/authentik /bin/authentik
COPY ./packages/ /ak-root/packages
RUN ln -s /ak-root/packages /packages
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
COPY --from=node-builder /work/web/dist/ /ak-root/web/dist/
COPY --from=node-builder /work/web/authentik/ /ak-root/web/authentik/
COPY --from=node-builder /work/web/dist/ /web/dist/
COPY --from=node-builder /work/web/authentik/ /web/authentik/
COPY --from=geoip /usr/share/GeoIP /geoip
USER 1000
@@ -186,6 +190,4 @@ ENV TMPDIR=/dev/shm/ \
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]
WORKDIR /ak-root
ENTRYPOINT [ "dumb-init", "--", "ak" ]

View File

@@ -63,6 +63,28 @@ class TestBrands(APITestCase):
},
)
def test_brand_subdomain_same_suffix(self):
"""Test Current brand API"""
Brand.objects.all().delete()
Brand.objects.create(domain="bar.baz", branding_title="custom")
Brand.objects.create(domain="foo.bar.baz", branding_title="custom")
self.assertJSONEqual(
self.client.get(
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
).content.decode(),
{
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "foo.bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
"flags": self.default_flags,
},
)
def test_fallback(self):
"""Test fallback brand"""
Brand.objects.all().delete()

View File

@@ -4,6 +4,7 @@ from typing import Any
from django.db.models import F, Q
from django.db.models import Value as V
from django.db.models.functions import Length
from django.http.request import HttpRequest
from django.utils.html import _json_script_escapes
from django.utils.safestring import mark_safe
@@ -20,9 +21,9 @@ DEFAULT_BRAND = Brand(domain="fallback")
def get_brand_for_request(request: HttpRequest) -> Brand:
"""Get brand object for current request"""
db_brands = (
Brand.objects.annotate(host_domain=V(request.get_host()))
Brand.objects.annotate(host_domain=V(request.get_host()), match_length=Length("domain"))
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
.order_by("default")
.order_by("-match_length", "default")
)
brands = list(db_brands.all())
if len(brands) < 1:

View File

@@ -21,6 +21,8 @@ from rest_framework.serializers import (
raise_errors_on_nested_writes,
)
from authentik.rbac.permissions import assign_initial_permissions
def is_dict(value: Any):
"""Ensure a value is a dictionary, useful for JSONFields"""
@@ -50,6 +52,15 @@ class ModelSerializer(BaseModelSerializer):
serializer_field_mapping = BaseModelSerializer.serializer_field_mapping.copy()
serializer_field_mapping[models.JSONField] = JSONDictField
def create(self, validated_data):
instance = super().create(validated_data)
request = self.context.get("request")
if request and hasattr(request, "user") and not request.user.is_anonymous:
assign_initial_permissions(request.user, instance)
return instance
def update(self, instance: Model, validated_data):
raise_errors_on_nested_writes("update", self, validated_data)
info = model_meta.get_field_info(instance)

View File

@@ -23,7 +23,6 @@ from authentik.events.models import (
)
from authentik.events.utils import get_user
from authentik.rbac.decorators import permission_required
from authentik.stages.email.models import get_template_choices
class NotificationTransportSerializer(ModelSerializer):
@@ -31,18 +30,6 @@ class NotificationTransportSerializer(ModelSerializer):
mode_verbose = SerializerMethodField()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["email_template"].choices = get_template_choices()
def validate_email_template(self, value: str) -> str:
"""Check validity of email template"""
choices = get_template_choices()
for path, _ in choices:
if path == value:
return value
raise ValidationError(f"Invalid template '{value}' specified.")
def get_mode_verbose(self, instance: NotificationTransport) -> str:
"""Return selected mode with a UI Label"""
return TransportMode(instance.mode).label
@@ -65,8 +52,6 @@ class NotificationTransportSerializer(ModelSerializer):
"webhook_url",
"webhook_mapping_body",
"webhook_mapping_headers",
"email_subject_prefix",
"email_template",
"send_once",
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.1.11 on 2025-08-14 13:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0011_alter_systemtask_options"),
]
operations = [
migrations.AddField(
model_name="notificationtransport",
name="email_subject_prefix",
field=models.TextField(blank=True, default="authentik Notification: "),
),
migrations.AddField(
model_name="notificationtransport",
name="email_template",
field=models.TextField(default="email/event_notification.html"),
),
]

View File

@@ -41,7 +41,6 @@ from authentik.lib.utils.http import get_http_session
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.email.models import EmailTemplates
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tasks.models import TasksModel
from authentik.tenants.models import Tenant
@@ -296,15 +295,6 @@ class NotificationTransport(TasksModel, SerializerModel):
name = models.TextField(unique=True)
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
send_once = models.BooleanField(
default=False,
help_text=_(
"Only send notification once, for example when sending a webhook into a chat channel."
),
)
email_subject_prefix = models.TextField(default="authentik Notification: ", blank=True)
email_template = models.TextField(default=EmailTemplates.EVENT_NOTIFICATION)
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
webhook_mapping_body = models.ForeignKey(
@@ -329,6 +319,12 @@ class NotificationTransport(TasksModel, SerializerModel):
"Mapping should return a dictionary of key-value pairs"
),
)
send_once = models.BooleanField(
default=False,
help_text=_(
"Only send notification once, for example when sending a webhook into a chat channel."
),
)
def send(self, notification: "Notification") -> list[str]:
"""Send notification to user, called from async task"""
@@ -466,6 +462,7 @@ class NotificationTransport(TasksModel, SerializerModel):
notification=notification,
)
return None
subject_prefix = "authentik Notification: "
context = {
"key_value": {
"user_email": notification.user.email,
@@ -493,10 +490,10 @@ class NotificationTransport(TasksModel, SerializerModel):
"from": self.name,
}
mail = TemplateEmailMessage(
subject=self.email_subject_prefix + context["title"],
subject=subject_prefix + context["title"],
to=[(notification.user.name, notification.user.email)],
language=notification.user.locale(),
template_name=self.email_template,
template_name="email/event_notification.html",
template_context=context,
)
send_mail.send_with_options(args=(mail.__dict__,), rel_obj=self)

View File

@@ -5,12 +5,10 @@ from unittest.mock import PropertyMock, patch
from django.core import mail
from django.core.mail.backends.locmem import EmailBackend
from django.test import TestCase
from django.urls import reverse
from requests_mock import Mocker
from authentik import authentik_full_version
from authentik.core.tests.utils import create_test_admin_user
from authentik.events.api.notification_transports import NotificationTransportSerializer
from authentik.events.models import (
Event,
Notification,
@@ -20,7 +18,6 @@ from authentik.events.models import (
TransportMode,
)
from authentik.lib.generators import generate_id
from authentik.stages.email.models import get_template_choices
class TestEventTransports(TestCase):
@@ -141,76 +138,3 @@ class TestEventTransports(TestCase):
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik Notification: custom_foo")
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
def test_transport_email_custom_template(self):
"""Test email transport with custom template"""
transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(),
mode=TransportMode.EMAIL,
email_template="email/event_notification.html",
)
with patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
):
transport.send(self.notification)
self.assertEqual(len(mail.outbox), 1)
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
def test_transport_email_custom_subject_prefix(self):
"""Test email transport with custom subject prefix"""
transport: NotificationTransport = NotificationTransport.objects.create(
name=generate_id(),
mode=TransportMode.EMAIL,
email_subject_prefix="[CUSTOM] ",
)
with patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
):
transport.send(self.notification)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "[CUSTOM] custom_foo")
def test_transport_email_validation(self):
"""Test email transport template validation"""
# Test valid template
serializer = NotificationTransportSerializer(
data={
"name": generate_id(),
"mode": TransportMode.EMAIL,
"email_template": "email/event_notification.html",
}
)
self.assertTrue(serializer.is_valid())
# Test invalid template - should fail due to choices validation
serializer = NotificationTransportSerializer(
data={
"name": generate_id(),
"mode": TransportMode.EMAIL,
"email_template": "invalid/template.html",
}
)
self.assertFalse(serializer.is_valid())
self.assertIn("email_template", serializer.errors)
def test_templates_api_endpoint(self):
"""Test templates API endpoint returns valid templates"""
self.client.force_login(self.user)
response = self.client.get(reverse("authentik_api:emailstage-templates"))
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIsInstance(data, list)
# Check that we have at least the default templates
template_names = [item["name"] for item in data]
self.assertIn("email/event_notification.html", template_names)
# Verify all templates are valid choices
valid_choices = dict(get_template_choices())
for template in data:
self.assertIn(template["name"], valid_choices)
self.assertEqual(template["description"], valid_choices[template["name"]])

View File

@@ -103,7 +103,7 @@ class PasswordPolicy(Policy):
if self.amount_lowercase > 0 and len(RE_LOWER.findall(password)) < self.amount_lowercase:
LOGGER.debug("password failed", check="static", reason="amount_lowercase")
return PolicyResult(False, self.error_message)
if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_uppercase:
if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_lowercase:
LOGGER.debug("password failed", check="static", reason="amount_uppercase")
return PolicyResult(False, self.error_message)
if self.amount_symbols > 0:

View File

@@ -1,69 +0,0 @@
"""InitialPermissions middleware"""
from collections.abc import Callable
from contextvars import ContextVar
from functools import partial
from django.db.models import Model
from django.db.models.signals import post_save
from django.http import HttpRequest, HttpResponse
from authentik.core.models import User
from authentik.rbac.permissions import assign_initial_permissions
_CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_initial_permissions_request", default=None)
class InitialPermissionsMiddleware:
"""Register a handler for duration of request-response that assigns InitialPermissions"""
get_response: Callable[[HttpRequest], HttpResponse]
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
self.get_response = get_response
def get_uid(self, request_id: str) -> str:
return f"InitialPermissionMiddleware-{request_id}"
def connect(self, request: HttpRequest):
if not hasattr(request, "request_id"):
return
post_save.connect(
partial(self.post_save_handler, request=request),
dispatch_uid=self.get_uid(request.request_id),
weak=False,
)
def disconnect(self, request: HttpRequest):
if not hasattr(request, "request_id"):
return
post_save.disconnect(dispatch_uid=self.get_uid(request.request_id))
def __call__(self, request: HttpRequest) -> HttpResponse:
_CTX_REQUEST.set(request)
self.connect(request)
response = self.get_response(request)
self.disconnect(request)
_CTX_REQUEST.set(None)
return response
def process_exception(self, request: HttpRequest, exception: Exception):
self.disconnect(request)
def post_save_handler(
self,
request: HttpRequest,
instance: Model,
created: bool,
**_,
):
if not created:
return
if request.request_id != _CTX_REQUEST.get().request_id:
return
user: User = request.user
if not user or user.is_anonymous:
return
assign_initial_permissions(user, instance)

View File

@@ -5,12 +5,9 @@ from django.db.models import Model
from guardian.shortcuts import assign_perm
from rest_framework.permissions import BasePermission, DjangoObjectPermissions
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.rbac.models import InitialPermissions, InitialPermissionsMode
LOGGER = get_logger()
class ObjectPermissions(DjangoObjectPermissions):
"""RBAC Permissions"""
@@ -74,10 +71,4 @@ def assign_initial_permissions(user, instance: Model):
if initial_permissions.mode == InitialPermissionsMode.USER
else initial_permissions.role.group
)
LOGGER.debug(
"Adding initial permission",
initial_permission=permission,
subject=assign_to,
object=instance,
)
assign_perm(permission, assign_to, instance)

View File

@@ -266,7 +266,6 @@ MIDDLEWARE = [
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"authentik.core.middleware.ImpersonateMiddleware",
"authentik.rbac.middleware.InitialPermissionsMiddleware",
]
MIDDLEWARE_LAST = [
"django_prometheus.middleware.PrometheusAfterMiddleware",

View File

@@ -15,10 +15,18 @@ from authentik.lib.config import CONFIG
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
from authentik.stages.authenticator.models import SideChannelDevice
from authentik.stages.email.models import EmailTemplates
from authentik.stages.email.utils import TemplateEmailMessage
class EmailTemplates(models.TextChoices):
"""Templates used for rendering the Email"""
EMAIL_OTP = (
"email/email_otp.html",
_("Email OTP"),
) # nosec
class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
"""Use Email-based authentication instead of authenticator-based."""

File diff suppressed because one or more lines are too long

View File

@@ -32,14 +32,6 @@ class EmailTemplates(models.TextChoices):
"email/account_confirmation.html",
_("Account Confirmation"),
)
EMAIL_OTP = (
"email/email_otp.html",
_("Email OTP"),
) # nosec
EVENT_NOTIFICATION = (
"email/event_notification.html",
_("Event Notification"),
)
def get_template_choices():

View File

@@ -54,7 +54,7 @@ class TestEmailStageTemplates(FlowTestCase):
chmod(file2, 0o000) # Remove all permissions so we can't read the file
choices = get_template_choices()
self.assertEqual(choices[-1][0], Path(file).name)
self.assertEqual(len(choices), 5)
self.assertEqual(len(choices), 3)
unlink(file)
unlink(file2)

View File

@@ -6753,15 +6753,6 @@
"title": "Webhook mapping headers",
"description": "Configure additional headers to be sent. Mapping should return a dictionary of key-value pairs"
},
"email_subject_prefix": {
"type": "string",
"title": "Email subject prefix"
},
"email_template": {
"type": "string",
"minLength": 1,
"title": "Email template"
},
"send_once": {
"type": "boolean",
"title": "Send once",

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.2025100.2
goauthentik.io/api/v3 v3.2025100.1
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.2025100.2 h1:OF8qEpn6PzZFlB16RzL51RSIyFOY234gAWfd8/kjzhc=
goauthentik.io/api/v3 v3.2025100.2/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
goauthentik.io/api/v3 v3.2025100.1 h1:xOMnQ2j1MtrYJlO+8bHJ8MdFPyymqTZcLQ+5PTspdqA=
goauthentik.io/api/v3 v3.2025100.1/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

@@ -68,33 +68,10 @@ function prepare_debug {
chown authentik:authentik /unittest.xml
}
function migrate_container_change_root_dir {
# With authentik 2025.10 we're moving the root directory of the authentik app
# into /ak-root, mainly to not clutter the root filesystem of the container
# and to make it possible to use devcontainers in the future.
# In most installs this migration isn't required as no files are mounted into
# these directories, however it is used if scripts are overwritten from the outside
# or more commonly the flow background image is overwritten in `/web`
# Check if we're in a container
if [ ! -d /ak-root ]; then
return
fi
if [ -d /authentik ]; then
log "Legacy /authentik folder exist, migrating files"
cp -rp /authentik/* /ak-root/authentik
fi
if [ ! -d /web ]; then
log "Legacy /web folder exist, migrating files"
cp -rp /web/* /ak-root/web
fi
}
if [[ "$(python -m authentik.lib.config debugger 2>/dev/null)" == "True" ]]; then
prepare_debug
fi
migrate_container_change_root_dir
if [[ "$1" == "server" ]]; then
set_mode "server"
run_authentik

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-08-18 00:11+0000\n"
"POT-Creation-Date: 2025-08-11 00:12+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"
@@ -790,12 +790,6 @@ msgstr ""
msgid "Email"
msgstr ""
#: authentik/events/models.py
msgid ""
"Only send notification once, for example when sending a webhook into a chat "
"channel."
msgstr ""
#: authentik/events/models.py
msgid ""
"Customize the body of the request. Mapping should return data that is JSON-"
@@ -808,6 +802,12 @@ msgid ""
"of key-value pairs"
msgstr ""
#: authentik/events/models.py
msgid ""
"Only send notification once, for example when sending a webhook into a chat "
"channel."
msgstr ""
#: authentik/events/models.py
msgid "Severity"
msgstr ""
@@ -2905,6 +2905,10 @@ msgstr ""
msgid "Duo Devices"
msgstr ""
#: authentik/stages/authenticator_email/models.py
msgid "Email OTP"
msgstr ""
#: authentik/stages/authenticator_email/models.py
#: authentik/stages/email/models.py
msgid ""
@@ -3265,14 +3269,6 @@ msgstr ""
msgid "Account Confirmation"
msgstr ""
#: authentik/stages/email/models.py
msgid "Email OTP"
msgstr ""
#: authentik/stages/email/models.py
msgid "Event Notification"
msgstr ""
#: authentik/stages/email/models.py
msgid ""
"The time window used to count recent account recovery attempts. If the "

View File

@@ -904,9 +904,9 @@
}
},
"node_modules/@types/node": {
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -385,9 +385,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -49345,10 +49345,6 @@ components:
nullable: true
description: Configure additional headers to be sent. Mapping should return
a dictionary of key-value pairs
email_subject_prefix:
type: string
email_template:
type: string
send_once:
type: boolean
description: Only send notification once, for example when sending a webhook
@@ -49388,11 +49384,6 @@ components:
nullable: true
description: Configure additional headers to be sent. Mapping should return
a dictionary of key-value pairs
email_subject_prefix:
type: string
email_template:
type: string
minLength: 1
send_once:
type: boolean
description: Only send notification once, for example when sending a webhook
@@ -54490,11 +54481,6 @@ components:
nullable: true
description: Configure additional headers to be sent. Mapping should return
a dictionary of key-value pairs
email_subject_prefix:
type: string
email_template:
type: string
minLength: 1
send_once:
type: boolean
description: Only send notification once, for example when sending a webhook

View File

@@ -10,7 +10,6 @@ from docker.types.healthcheck import Healthcheck
from authentik.core.tests.utils import create_test_flow
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.config import CONFIG
from authentik.outposts.models import (
DockerServiceConnection,
Outpost,
@@ -89,7 +88,6 @@ class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
pass
@pytest.mark.timeout(120)
@CONFIG.patch("outposts.container_image_base", "ghcr.io/goauthentik/dev-proxy:gh-main")
def test_docker_controller(self):
"""test that deployment requires update"""
controller = DockerController(self.outpost, self.service_connection)

88
web/package-lock.json generated
View File

@@ -23,7 +23,7 @@
"@floating-ui/dom": "^1.7.3",
"@formatjs/intl-listformat": "^7.7.11",
"@fortawesome/fontawesome-free": "^7.0.0",
"@goauthentik/api": "^2025.10.0-rc1-1755254677",
"@goauthentik/api": "^2025.10.0-rc1-1755033451",
"@goauthentik/core": "^1.0.0",
"@goauthentik/esbuild-plugin-live-reload": "^1.1.0",
"@goauthentik/eslint-config": "^1.0.5",
@@ -42,7 +42,7 @@
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.1.0",
"@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^10.5.0",
"@sentry/browser": "^10.3.0",
"@spotlightjs/spotlight": "^3.0.2",
"@storybook/addon-docs": "^9.1.2",
"@storybook/addon-links": "^9.1.2",
@@ -52,7 +52,7 @@
"@types/grecaptcha": "^3.0.9",
"@types/guacamole-common-js": "^1.5.3",
"@types/mocha": "^10.0.10",
"@types/node": "^24.3.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@typescript-eslint/eslint-plugin": "^8.38.0",
@@ -126,7 +126,7 @@
"@wdio/cli": "^9.19.1",
"@wdio/spec-reporter": "^9.19.1",
"@web/test-runner": "^0.20.2",
"chromedriver": "^139.0.1"
"chromedriver": "^139.0.0"
}
},
"../packages/core": {
@@ -1509,9 +1509,9 @@
}
},
"node_modules/@goauthentik/api": {
"version": "2025.10.0-rc1-1755254677",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.10.0-rc1-1755254677.tgz",
"integrity": "sha512-hq+xGPtwaeptEDn92Y40Yb4e7yL2KVvuqy2kWAZLPtr/r9ML82vzNYCfW6bFNPnopDRizjOBIzlD3gNP/2rs8Q=="
"version": "2025.10.0-rc1-1755033451",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.10.0-rc1-1755033451.tgz",
"integrity": "sha512-Opp94OCa15N8WG69NonHsHqsSeewIvqmgRasxWgCVtABzqv+LWPrFB3ChzW3W+W3to7DrgpsS/cBABYNY8W/eA=="
},
"node_modules/@goauthentik/core": {
"resolved": "packages/core",
@@ -4587,75 +4587,75 @@
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.5.0.tgz",
"integrity": "sha512-4KIJdEj/8Ip9yqJleVSFe68r/U5bn5o/lYUwnFNEnDNxmpUbOlr7x3DXYuRFi1sfoMUxK9K1DrjnBkR7YYF00g==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.3.0.tgz",
"integrity": "sha512-jKBoNMmxMgojzcpIsUqVk6XL6YiW0i8jtNdD9UdBKd8ExFpVkXhPuMdWB9f/5mVNK/9BnfI74eTiEVHZEkeZ6Q==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.5.0"
"@sentry/core": "10.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.5.0.tgz",
"integrity": "sha512-x79P4VZwUxb1EGZb9OQ5EEgrDWFCUlrbzHBwV/oocQA5Ss1SFz5u6cP5Ak7yJtILiJtdGzAyAoQOy4GKD13D4Q==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.3.0.tgz",
"integrity": "sha512-HGvBoUwbj164I/66vrtUjHICuqwcY5RIGAAutD+H+EwhUROpFuzaIe9utIalhyU9CrTN/vFs4UYPWmeOpqg2lQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.5.0"
"@sentry/core": "10.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.5.0.tgz",
"integrity": "sha512-Dp4coE/nPzhFrYH3iVrpVKmhNJ1m/jGXMEDBCNg3wJZRszI41Hrj0jCAM0Y2S3Q4IxYOmFYaFbGtVpAznRyOHg==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.3.0.tgz",
"integrity": "sha512-SVF7mMDW++LaeaONyxFUQ2Na3aMv6vyhv9V5Yb6yHWgPXI8NCW83mJ/MidHDD3yI0bccgTJUEmB4S0vBioafzg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.5.0",
"@sentry/core": "10.5.0"
"@sentry-internal/browser-utils": "10.3.0",
"@sentry/core": "10.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.5.0.tgz",
"integrity": "sha512-5nrRKd5swefd9+sFXFZ/NeL3bz/VxBls3ubAQ3afak15FikkSyHq3oKRKpMOtDsiYKXE3Bc0y3rF5A+y3OXjIA==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.3.0.tgz",
"integrity": "sha512-JGE1YmWb5LYhnaEgaYVMKj03FCQsuvALF2RXJx+Qe8pPwWtEWWBXMFEIt714mv4mO3YQxZnnxQhxFRuSJqXQfQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.5.0",
"@sentry/core": "10.5.0"
"@sentry-internal/replay": "10.3.0",
"@sentry/core": "10.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.5.0.tgz",
"integrity": "sha512-o5pEJeZ/iZ7Fmaz2sIirThfnmSVNiP5ZYhacvcDi0qc288TmBbikCX3fXxq3xiSkhXfe1o5QIbNyovzfutyuVw==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.3.0.tgz",
"integrity": "sha512-n0jROCST6XJhU7okSn04uRGFK4FjJZNjVR8nDSi/A6gU7VxVAs3iva5SUykXGFQKSVaXVE8kKjS6BtKfllulQA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.5.0",
"@sentry-internal/feedback": "10.5.0",
"@sentry-internal/replay": "10.5.0",
"@sentry-internal/replay-canvas": "10.5.0",
"@sentry/core": "10.5.0"
"@sentry-internal/browser-utils": "10.3.0",
"@sentry-internal/feedback": "10.3.0",
"@sentry-internal/replay": "10.3.0",
"@sentry-internal/replay-canvas": "10.3.0",
"@sentry/core": "10.3.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.5.0.tgz",
"integrity": "sha512-jTJ8NhZSKB2yj3QTVRXfCCngQzAOLThQUxCl9A7Mv+XF10tP7xbH/88MVQ5WiOr2IzcmrB9r2nmUe36BnMlLjA==",
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.3.0.tgz",
"integrity": "sha512-FEFCqiGkzJrm6TNJvhyjhc4rpC1Kmo/abYOACRd6MLvm8GBz41eFFKxsNxGZAUA3Fk1tR2mPfXIHOJzS0ulVww==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -6633,9 +6633,9 @@
}
},
"node_modules/@types/node": {
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
@@ -10711,9 +10711,9 @@
}
},
"node_modules/chromedriver": {
"version": "139.0.1",
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-139.0.1.tgz",
"integrity": "sha512-K16mpBWhVMY/85k+1pf2ZuCOCDNJxSfr/OuIh7YbWoVIT+baNlyB6OvVh2WQw+MYQK2fg7CS0rMUE8GvMY6oCA==",
"version": "139.0.0",
"resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-139.0.0.tgz",
"integrity": "sha512-4hzJhx2B2WbGP1160KaDkAIWMHmEBabldes1SrU75JwrzviYevopcVCxQw9B9Ykx+500ij06fv2retL5mK7DbA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
@@ -27835,7 +27835,7 @@
"dependencies": {
"@goauthentik/prettier-config": "^3.1.0",
"@goauthentik/tsconfig": "^1.0.4",
"@types/node": "^24.3.0",
"@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.8.3"
},
@@ -27915,7 +27915,7 @@
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@goauthentik/api": "^2025.10.0-rc1-1755254677",
"@goauthentik/api": "^2024.6.0-1719577139",
"@goauthentik/core": "^1.0.0",
"@rollup/plugin-commonjs": "^28.0.6",
"@rollup/plugin-node-resolve": "^16.0.1",

View File

@@ -94,7 +94,7 @@
"@floating-ui/dom": "^1.7.3",
"@formatjs/intl-listformat": "^7.7.11",
"@fortawesome/fontawesome-free": "^7.0.0",
"@goauthentik/api": "^2025.10.0-rc1-1755254677",
"@goauthentik/api": "^2025.10.0-rc1-1755033451",
"@goauthentik/core": "^1.0.0",
"@goauthentik/esbuild-plugin-live-reload": "^1.1.0",
"@goauthentik/eslint-config": "^1.0.5",
@@ -113,7 +113,7 @@
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.1.0",
"@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^10.5.0",
"@sentry/browser": "^10.3.0",
"@spotlightjs/spotlight": "^3.0.2",
"@storybook/addon-docs": "^9.1.2",
"@storybook/addon-links": "^9.1.2",
@@ -123,7 +123,7 @@
"@types/grecaptcha": "^3.0.9",
"@types/guacamole-common-js": "^1.5.3",
"@types/mocha": "^10.0.10",
"@types/node": "^24.3.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@typescript-eslint/eslint-plugin": "^8.38.0",
@@ -194,7 +194,7 @@
"@wdio/cli": "^9.19.1",
"@wdio/spec-reporter": "^9.19.1",
"@web/test-runner": "^0.20.2",
"chromedriver": "^139.0.1"
"chromedriver": "^139.0.0"
},
"wireit": {
"build": {

View File

@@ -47,7 +47,7 @@
"dependencies": {
"@goauthentik/prettier-config": "^3.1.0",
"@goauthentik/tsconfig": "^1.0.4",
"@types/node": "^24.3.0",
"@types/node": "^24.2.1",
"prettier": "^3.6.2",
"typescript": "^5.8.3"
},

View File

@@ -10,7 +10,7 @@
"watch": "rollup -w -c rollup.config.mjs --bundleConfigAsCjs"
},
"dependencies": {
"@goauthentik/api": "^2025.10.0-rc1-1755254677",
"@goauthentik/api": "^2024.6.0-1719577139",
"@goauthentik/core": "^1.0.0",
"@rollup/plugin-commonjs": "^28.0.6",
"@rollup/plugin-node-resolve": "^16.0.1",

View File

@@ -14,8 +14,6 @@ import {
NotificationWebhookMapping,
PropertymappingsApi,
PropertymappingsNotificationListRequest,
StagesApi,
TypeCreate,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -35,18 +33,10 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
return transport;
});
}
async load(): Promise<void> {
this.templates = await new StagesApi(DEFAULT_CONFIG).stagesEmailTemplatesList();
}
templates?: TypeCreate[];
@property({ type: Boolean })
showWebhook = false;
@property({ type: Boolean })
showEmail = false;
getSuccessMessage(): string {
return this.instance
? msg("Successfully updated transport.")
@@ -66,28 +56,18 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
}
onModeChange(mode: string | undefined): void {
// Reset all flags
this.showWebhook = false;
this.showEmail = false;
switch (mode) {
case NotificationTransportModeEnum.Webhook:
case NotificationTransportModeEnum.WebhookSlack:
this.showWebhook = true;
break;
case NotificationTransportModeEnum.Email:
this.showEmail = true;
break;
case NotificationTransportModeEnum.Local:
default:
// Both flags remain false
break;
if (
mode === NotificationTransportModeEnum.Webhook ||
mode === NotificationTransportModeEnum.WebhookSlack
) {
this.showWebhook = true;
} else {
this.showWebhook = false;
}
}
renderForm(): TemplateResult {
return html`
<ak-form-element-horizontal label=${msg("Name")} required name="name">
return html` <ak-form-element-horizontal label=${msg("Name")} required name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
@@ -95,26 +75,6 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="sendOnce">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.sendOnce ?? false}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Send once")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"Only send notification once, for example when sending a webhook into a chat channel.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Mode")} required name="mode">
<ak-radio
@change=${(ev: CustomEvent<{ value: NotificationTransportModeEnum }>) => {
@@ -149,7 +109,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
value="${this.instance?.webhookUrl || ""}"
input-hint="code"
?hidden=${!this.showWebhook}
?required=${this.showWebhook}
required
>
</ak-hidden-text-input>
<ak-form-element-horizontal
@@ -218,39 +178,26 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
>
</ak-search-select>
</ak-form-element-horizontal>
<ak-form-element-horizontal
?hidden=${!this.showEmail}
?required=${this.showEmail}
label=${msg("Email Subject Prefix")}
name="emailSubjectPrefix"
>
<input
type="text"
value="${this.instance?.emailSubjectPrefix || "authentik Notification: "}"
class="pf-c-form-control"
?hidden=${!this.showEmail}
?required=${this.showEmail}
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
?hidden=${!this.showEmail}
?required=${this.showEmail}
label=${msg("Email Template")}
name="emailTemplate"
>
<select name="users" class="pf-c-form-control">
${this.templates?.map((template) => {
const selected =
this.instance?.emailTemplate === template.name ||
(!this.instance?.emailTemplate &&
template.name === "email/event_notification.html");
return html`<option value=${ifDefined(template.name)} ?selected=${selected}>
${template.description}
</option>`;
})}
</select>
</ak-form-element-horizontal>
`;
<ak-form-element-horizontal name="sendOnce">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.sendOnce ?? false}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Send once")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"Only send notification once, for example when sending a webhook into a chat channel.",
)}
</p>
</ak-form-element-horizontal>`;
}
}

View File

@@ -16,7 +16,6 @@ import {
FlowsInstancesListDesignationEnum,
FlowsInstancesListRequest,
StagesApi,
TypeCreate,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -34,12 +33,6 @@ export class AuthenticatorEmailStageForm extends BaseStageForm<AuthenticatorEmai
return stage;
}
async load(): Promise<void> {
this.templates = await new StagesApi(DEFAULT_CONFIG).stagesEmailTemplatesList();
}
templates?: TypeCreate[];
@property({ type: Boolean })
showConnectionSettings = false;
@@ -269,28 +262,6 @@ export class AuthenticatorEmailStageForm extends BaseStageForm<AuthenticatorEmai
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Template")} name="template">
<select
class="pf-c-form-control"
?disabled=${!this.templates || this.templates.length === 0}
>
${this.templates && this.templates.length > 0
? this.templates.map((template: TypeCreate) => {
return html`<option
value="${template.name}"
?selected=${this.instance?.template === template.name ||
(!this.instance?.template &&
template.name === "email/email_otp.html")}
>
${template.description}
</option>`;
})
: html`<option value="">${msg("Loading templates...")}</option>`}
</select>
<p class="pf-c-form__helper-text">
${msg("Template used for the verification email.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;
}

View File

@@ -20,22 +20,6 @@ export const EscapeTrustPolicy = trustedTypes.createPolicy("authentik-escape", {
},
});
/**
* Trusted types policy that removes all HTML content.
*
*
* @returns {TrustedHTML} All remaining text content.
*/
export const StripHTMLTrustPolicy = trustedTypes.createPolicy("authentik-strip-html", {
createHTML: (untrustedHTML: string) => {
return DOMPurify.sanitize(untrustedHTML, {
RETURN_TRUSTED_TYPE: false,
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
});
},
});
/**
* Trusted types policy, stripping all HTML content.
*
@@ -121,9 +105,7 @@ export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string {
render(untrustedHTML, container);
const result = container.innerHTML
// Remove all comments as they can interfere with the styles.
.replaceAll("<!---->", "")
.replaceAll(/<!--\?lit\$\d+\$-->/g, "");
const result = container.innerHTML;
return result;
}

View File

@@ -1,18 +1,14 @@
import "#elements/buttons/Dropdown";
import { StripHTMLTrustPolicy } from "#common/purify";
import { rootInterface } from "#common/theme";
import { FormAssociated, FormAssociatedElement } from "#elements/forms/form-associated-element";
import { AKElement } from "#elements/Base";
import { PaginatedResponse } from "#elements/table/Table";
import DjangoQL, { Introspections } from "@mrmarble/djangoql-completion";
import { msg } from "@lit/localize";
import { css, CSSResult, html, LitElement, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref, Ref } from "lit/directives/ref.js";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFSearchInput from "@patternfly/patternfly/components/SearchInput/search-input.css";
@@ -29,26 +25,40 @@ export class QL extends DjangoQL {
textareaResize() {}
}
/**
* Given an array or length, return logical index of the element at the given delta.
* This is effectively a modulo loop, allowing for positive and negative deltas.
*/
function torusIndex(lengthLike: number | ArrayLike<number>, delta: number): number {
const length = typeof lengthLike === "number" ? lengthLike : lengthLike.length;
@customElement("ak-search-ql")
export class QLSearch extends AKElement {
@property()
value?: string;
if (delta < 0) {
return (length + delta) % length;
@query("[name=search]")
searchElement?: HTMLTextAreaElement;
@state()
menuOpen = false;
@property()
onSearch?: (value: string) => void;
@state()
selected?: number;
@state()
cursorX: number = 0;
@state()
cursorY: number = 0;
ql?: QL;
canvas?: CanvasRenderingContext2D;
set apiResponse(value: PaginatedResponse<unknown> | undefined) {
if (!value || !value.autocomplete || !this.ql) {
return;
}
this.ql.loadIntrospections(value.autocomplete as unknown as Introspections);
}
return ((delta % length) + length) % length;
}
@customElement("ak-search-ql")
export class QLSearch extends FormAssociatedElement<string> implements FormAssociated {
declare anchorRef: Ref<HTMLTextAreaElement>;
declare anchor: HTMLTextAreaElement | null;
public static styles: CSSResult[] = [
static styles: CSSResult[] = [
PFBase,
PFFormControl,
PFSearchInput,
@@ -56,409 +66,207 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
::-webkit-search-cancel-button {
display: none;
}
.ql.pf-c-form-control {
--input-height: 2.25em;
height: var(--input-height);
min-height: var(--input-height);
max-height: calc(var(--input-height) * 6);
font-family: monospace;
resize: vertical;
height: 2.25em;
}
.selected {
background-color: var(--pf-c-search-input__menu-item--hover--BackgroundColor);
}
:host([theme="dark"]) {
.pf-c-search-input__menu {
--pf-c-search-input__menu--BackgroundColor: var(--ak-dark-background-light-ish);
color: var(--ak-dark-foreground);
}
.pf-c-search-input__menu-item {
--pf-c-search-input__menu-item--Color: var(--ak-dark-foreground);
}
.pf-c-search-input__menu-item:hover {
--pf-c-search-input__menu-item--BackgroundColor: var(
--ak-dark-background-lighter
);
}
.pf-c-search-input__menu-list-item.selected {
--pf-c-search-input__menu-item--hover--BackgroundColor: var(
--ak-dark-background-light
);
}
.pf-c-search-input__text::before {
border: 0;
}
:host([theme="dark"]) .pf-c-search-input__menu {
--pf-c-search-input__menu--BackgroundColor: var(--ak-dark-background-light-ish);
color: var(--ak-dark-foreground);
}
:host([theme="dark"]) .pf-c-search-input__menu-item {
--pf-c-search-input__menu-item--Color: var(--ak-dark-foreground);
}
:host([theme="dark"]) .pf-c-search-input__menu-item:hover {
--pf-c-search-input__menu-item--BackgroundColor: var(--ak-dark-background-lighter);
}
:host([theme="dark"]) .pf-c-search-input__menu-list-item.selected {
--pf-c-search-input__menu-item--hover--BackgroundColor: var(
--ak-dark-background-light
);
}
:host([theme="dark"]) .pf-c-search-input__text::before {
border: 0;
}
.pf-c-search-input__menu {
position: fixed;
min-width: 0;
overflow-y: auto;
max-height: 50vh;
}
`,
];
//#region Properties
@property({ type: Boolean })
public open = false;
@property({ type: Number, attribute: false })
public selectionIndex = -1;
#value = "";
@property({ type: String })
public get value(): string {
return this.#value;
}
public set value(value: unknown) {
const parsed = typeof value === "string" ? value : "";
this.setFormValue(parsed.trim(), parsed);
this.#value = parsed;
if (this.anchor) {
this.anchor.value = this.#value;
}
}
//#endregion
//#region State
#menuRef = createRef<HTMLDivElement>();
#ql: QL | null = null;
#ctx: OffscreenCanvasRenderingContext2D | null = null;
#letterWidth = -1;
#scrollContainer: HTMLElement | null = null;
public set apiResponse(value: PaginatedResponse<unknown> | undefined) {
if (!value?.autocomplete || !this.#ql) {
firstUpdated() {
if (!this.searchElement) {
return;
}
this.#ql.loadIntrospections(value.autocomplete as unknown as Introspections);
}
//#endregion
//#region Lifecycle
public override connectedCallback() {
super.connectedCallback();
this.internals.ariaAutoComplete = "list";
this.internals.role = "combobox";
this.internals.ariaHasPopup = "listbox";
this.#scrollContainer =
rootInterface<LitElement>().renderRoot.querySelector("#main-content");
this.#scrollContainer?.addEventListener("scroll", this.#updateDropdownPosition, {
passive: true,
});
this.tabIndex = 0;
}
public override disconnectedCallback() {
super.disconnectedCallback();
this.#scrollContainer?.removeEventListener("scroll", this.#updateDropdownPosition);
}
public formStateRestoreCallback(state: string) {
this.value = state;
}
public formResetCallback() {
this.value = "";
}
public toJSON() {
return this.value;
}
public override updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("open")) {
this.internals.ariaExpanded = this.open ? "true" : "false";
}
if (changedProperties.has("selectionIndex")) {
const id = `suggestion-${this.selectionIndex}`;
this.setAttribute("aria-activedescendant", this.selectionIndex === -1 ? "" : id);
this.renderRoot.querySelector(`#${id}`)?.scrollIntoView({
behavior: "auto",
block: "nearest",
});
}
}
public override firstUpdated() {
const textarea = this.anchorRef.value;
if (!textarea) return;
this.#ql = new QL({
this.ql = new QL({
completionEnabled: true,
introspections: {
current_model: "",
models: {},
},
selector: textarea,
selector: this.searchElement,
autoResize: false,
});
const canvas = new OffscreenCanvas(300, 150);
this.#ctx = canvas.getContext("2d");
if (!this.#ctx) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
console.error("authentik/ql: failed to get canvas context");
return;
}
this.#ctx.font = window.getComputedStyle(textarea).font;
// We need the width of a letter to measure x; we use a monospaced font but still
// check the length for `m` as its the widest ASCII char
const metrics = this.#ctx?.measureText("m");
this.#letterWidth = Math.ceil(metrics?.width || 0) + 1;
context.font = window.getComputedStyle(this.searchElement).font;
this.canvas = context;
}
//#endregion
//#region Completions
#refreshCompletions = () => {
if (this.anchor) {
this.value = this.anchor.value;
}
if (!this.#ql) {
refreshCompletions() {
this.value = this.searchElement?.value;
if (!this.ql) {
return;
}
this.#ql.generateSuggestions();
if (this.#ql.suggestions.length < 1 || this.#ql.loading) {
this.open = false;
this.ql.generateSuggestions();
if (this.ql.suggestions.length < 1 || this.ql.loading) {
this.menuOpen = false;
return;
}
this.open = true;
this.menuOpen = true;
this.updateDropdownPosition();
this.requestUpdate();
}
requestAnimationFrame(this.#updateDropdownPosition);
};
#updateDropdownPosition = () => {
const anchor = this.anchorRef.value;
const menu = this.#menuRef.value;
if (!anchor || !menu) return;
updateDropdownPosition() {
if (!this.searchElement) {
return;
}
const bcr = this.getBoundingClientRect();
const style = window.getComputedStyle(anchor);
// We need the width of a letter to measure x; we use a monospaced font but still
// check the length for `m` as its the widest ASCII char
const metrics = this.canvas?.measureText("m");
const letterWidth = Math.ceil(metrics?.width || 0) + 1;
// Mostly static variables for padding, font line-height and how many
const lineHeight = parseInt(style.lineHeight, 10);
const paddingTop = parseInt(style.paddingTop, 10);
const paddingLeft = parseInt(style.paddingLeft, 10);
const paddingRight = parseInt(style.paddingRight, 10);
const lineHeight = parseInt(window.getComputedStyle(this.searchElement).lineHeight, 10);
const paddingTop = parseInt(window.getComputedStyle(this.searchElement).paddingTop, 10);
const paddingLeft = parseInt(window.getComputedStyle(this.searchElement).paddingLeft, 10);
const paddingRight = parseInt(window.getComputedStyle(this.searchElement).paddingRight, 10);
const actualInnerWidth = bcr.width - paddingLeft - paddingRight;
let relX = 0;
let relY = 1;
let letterIndex = 0;
for (const word of anchor.value.split(" ")) {
this.searchElement.value.split(" ").some((word, idx) => {
letterIndex += word.length;
const newRelX = relX + word.length * this.#letterWidth;
const newRelX = relX + word.length * letterWidth;
if (newRelX > actualInnerWidth) {
relY += 1;
if (letterIndex > anchor.selectionStart) {
if (letterIndex > this.searchElement!.selectionStart) {
relX =
this.#letterWidth * word.length -
(letterIndex - anchor.selectionStart) * this.#letterWidth;
break;
letterWidth * word.length -
(letterIndex - this.searchElement!.selectionStart) * letterWidth;
return true;
}
relX = word.length * this.#letterWidth;
relX = word.length * letterWidth;
} else {
relX = newRelX + 1;
}
}
});
const x = bcr.x + paddingLeft + relX;
const y = bcr.y + paddingTop + relY * lineHeight;
Object.assign(menu.style, {
left: `${x}px`,
top: `${y}px`,
} satisfies Partial<CSSStyleDeclaration>);
};
//#endregion
//#region Event Listeners
#keydownListener = (event: KeyboardEvent) => {
this.#updateDropdownPosition();
const suggestionsLength = this.#ql?.suggestions.length;
if (event.key === "Enter" && !this.open && this.form) {
const submitEvent = new SubmitEvent("submit", {
submitter: this,
bubbles: true,
composed: true,
cancelable: true,
});
this.form.dispatchEvent(submitEvent);
this.cursorX = bcr.x + paddingLeft + relX;
this.cursorY = bcr.y + paddingTop + relY * lineHeight;
}
onKeyDown(ev: KeyboardEvent) {
this.updateDropdownPosition();
if (ev.key === "Enter" && ev.metaKey && this.onSearch && this.searchElement) {
this.onSearch(this.searchElement?.value);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
if (this.open && suggestionsLength) {
if (this.selectionIndex === -1) {
this.selectionIndex = 0;
} else {
this.selectionIndex = torusIndex(suggestionsLength, this.selectionIndex + 1);
}
this.#refreshCompletions();
return;
}
this.selectionIndex = 0;
this.#refreshCompletions();
return;
}
if (!this.open) return;
switch (event.key) {
if (!this.menuOpen) return;
switch (ev.key) {
case "ArrowUp":
if (suggestionsLength) {
if (this.selectionIndex === -1) {
this.selectionIndex = suggestionsLength - 1;
if (this.ql?.suggestions.length) {
if (this.selected === undefined) {
this.selected = this.ql?.suggestions.length - 1;
} else if (this.selected === 0) {
this.selected = undefined;
} else {
this.selectionIndex = torusIndex(
suggestionsLength,
this.selectionIndex - 1,
);
this.selected -= 1;
}
this.#refreshCompletions();
event.preventDefault();
this.refreshCompletions();
ev.preventDefault();
}
return;
break;
case "ArrowDown":
if (this.ql?.suggestions.length) {
if (this.selected === undefined) {
this.selected = 0;
} else if (this.selected < this.ql?.suggestions.length - 1) {
this.selected += 1;
} else {
this.selected = undefined;
}
this.refreshCompletions();
ev.preventDefault();
}
break;
case "Tab":
if (this.selectionIndex) {
this.#ql?.selectCompletion(this.selectionIndex);
event.preventDefault();
if (this.selected) {
this.ql?.selectCompletion(this.selected);
ev.preventDefault();
}
return;
break;
case "Enter":
// Technically this is a textarea, due to automatic multi-line feature,
// but other than that it should look and behave like a normal input.
// So expected behavior when pressing Enter is to submit the form,
// not to add a new line.
if (this.selectionIndex !== -1) {
this.#ql?.selectCompletion(this.selectionIndex);
this.selectionIndex = 0;
if (this.selected !== undefined) {
this.ql?.selectCompletion(this.selected);
}
event.preventDefault();
return;
ev.preventDefault();
break;
case "Escape":
this.open = false;
return;
this.menuOpen = false;
break;
case "Shift": // Shift
case "Control": // Ctrl
case "Alt": // Alt
case "Meta": // Windows Key or Cmd on Mac
// Control keys shouldn't trigger completion popup
break;
}
};
}
#blurListener = ({ relatedTarget }: FocusEvent) => {
if (relatedTarget instanceof Node && this.renderRoot.contains(relatedTarget)) {
return;
}
this.open = false;
};
#focusListener = () => {
this.selectionIndex = this.selectionIndex === -1 ? 0 : this.selectionIndex;
this.#refreshCompletions();
};
//#endregion
//#region Render
protected renderMenu() {
if (!this.open || !this.#ql) {
renderMenu() {
if (!this.menuOpen || !this.ql) {
return nothing;
}
return html`
<div ${ref(this.#menuRef)} class="pf-c-search-input__menu">
<ul
class="pf-c-search-input__menu-list"
role="listbox"
id="ql-suggestions"
aria-label=${msg("Query suggestions")}
>
${this.#ql.suggestions.map((suggestion, idx) => {
// Cast to string to sooth Lit Analyzer's primitive type rule.
const label = `${StripHTMLTrustPolicy.createHTML(suggestion.suggestionText)}`;
<div
class="pf-c-search-input__menu"
style="left: ${this.cursorX}px; top: ${this.cursorY}px;"
>
<ul class="pf-c-search-input__menu-list">
${this.ql.suggestions.map((suggestion, idx) => {
return html`<li
role="option"
id="suggestion-${idx}"
aria-selected=${this.selectionIndex === idx ? "true" : "false"}
class="pf-c-search-input__menu-list-item ${this.selectionIndex === idx
class="pf-c-search-input__menu-list-item ${this.selected === idx
? "selected"
: ""}"
>
<button
class="pf-c-search-input__menu-item"
type="button"
aria-label=${label}
@click=${() => {
this.#ql?.selectCompletion(idx);
this.#refreshCompletions();
this.ql?.selectCompletion(idx);
this.refreshCompletions();
}}
>
<span class="pf-c-search-input__menu-item-text pf-m-monospace">
${suggestion.text}</span
<span class="pf-c-search-input__menu-item-text"
>${suggestion.text}</span
>
</button>
</li>`;
@@ -468,33 +276,25 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
`;
}
public override render(): TemplateResult {
render(): TemplateResult {
return html`<div class="pf-c-search-input">
<div class="pf-c-search-input__bar">
<span class="pf-c-search-input__text">
<textarea
${ref(this.anchorRef)}
class="pf-c-form-control pf-m-monospace ql"
class="pf-c-form-control ql"
name="search"
autocomplete="off"
aria-controls="ql-suggestions"
?required=${this.required}
placeholder=${msg("Search...")}
spellcheck="false"
@input=${this.#refreshCompletions}
@focus=${this.#focusListener}
@blur=${this.#blurListener}
@keydown=${this.#keydownListener}
@input=${(ev: InputEvent) => this.refreshCompletions()}
@keydown=${this.onKeyDown}
>
${ifDefined(this.#value)}</textarea
${ifDefined(this.value)}</textarea
>
</span>
</div>
${this.renderMenu()}
</div>`;
}
//#endregion
}
declare global {

View File

@@ -1,216 +0,0 @@
import { AKElement } from "#elements/Base";
import { Jsonifiable } from "type-fest";
import { msg } from "@lit/localize";
import { LitElement } from "lit";
import { property } from "lit/decorators.js";
import { createRef, Ref } from "lit/directives/ref.js";
/**
* A subset of form associated {@linkcode ElementInternals} properties.
*
* @see {@linkcode FormAssociatedElement} for usage.
*/
export interface FormAssociated
extends Pick<
ElementInternals,
| "form"
| "validity"
| "validationMessage"
| "willValidate"
| "labels"
| "checkValidity"
| "reportValidity"
> {
/**
* The name of the input, provided to the form.
*/
readonly name: string | null;
/**
* The type of the input, provided to the form.
*/
readonly type: string;
/**
* Whether or not the input is required.
*/
required?: boolean;
/**
* Whether or not the input is read-only.
*/
readonly?: boolean;
/**
* A JSON representation of the value.
*/
toJSON(): Jsonifiable;
}
export type FormValue = File | string | FormData | null;
/**
* A base element which provides reactive properties and methods for interacting with a parent form.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals | MDN}
*/
export abstract class FormAssociatedElement<
V extends FormValue = string,
T extends Jsonifiable = V extends string ? V : Jsonifiable,
S extends FormValue = V,
>
extends AKElement
implements FormAssociated
{
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
public static readonly formAssociated = true;
/**
* The internals of the element.
*
* @protected
* @see {@linkcode FormAssociated}
*/
protected internals = this.attachInternals();
//#region Reactive Properties
@property({ type: Boolean })
public get required() {
return this.internals.ariaRequired === "true";
}
public set required(value: boolean) {
this.internals.ariaRequired = value ? "true" : "false";
}
@property({ type: Boolean, attribute: "readonly" })
public get readOnly() {
return this.internals.ariaReadOnly === "true";
}
public set readOnly(value: boolean) {
this.internals.ariaReadOnly = value ? "true" : "false";
}
@property({ type: Boolean })
public get disabled() {
return this.internals.ariaDisabled === "true";
}
public set disabled(value: boolean) {
this.internals.ariaDisabled = value ? "true" : "false";
}
//#endregion
//#region Aliased Properties
public get form(): HTMLFormElement | null {
return this.internals.form;
}
public get name() {
return this.getAttribute("name");
}
public get type() {
return this.localName;
}
public get validity() {
return this.internals.validity;
}
public get validationMessage() {
return this.internals.validationMessage;
}
public get willValidate() {
return this.internals.willValidate;
}
public get labels() {
return this.internals.labels;
}
//#endregion
//#region Values
/**
* A reference to an element that is focusable when validation fails.
*/
protected anchorRef: Ref<HTMLElement>;
/**
* The element that is focusable when validation fails.
*/
declare protected anchor: HTMLElement | null;
/**
* Set the value of the form.
*
* @param value The value visible to the form during submission.
* @param state The value as provided by the user.
*/
protected setFormValue(value: V, state?: S) {
this.internals.setFormValue(value, state);
if (this.required) {
if (value) {
this.internals.setValidity({});
} else {
this.internals.setValidity(
{
valueMissing: true,
},
msg("This field is required."),
this.anchorRef.value,
);
}
}
}
public abstract toJSON(): T;
//#endregion
//#region Validation
public checkValidity = this.internals.checkValidity.bind(this.internals);
public reportValidity = this.internals.reportValidity.bind(this.internals);
//#endregion
/**
* Set the validity state of the form.
*
* @param flags The validity state flags.
* @param message The validation message.
* @param element The element to set the validity state on.
*/
protected setValidity(flags: ValidityStateFlags = {}, message?: string, element?: HTMLElement) {
this.internals.setValidity(flags, message, element ?? this.anchorRef.value);
}
//#endregion
public constructor() {
super();
this.anchorRef = createRef<HTMLElement>();
// We define the getter here to allow the base type to be extended,
// letting the subclasses define a more accurate HTMLElement type.
Object.defineProperty(this, "anchor", {
get() {
return this.anchorRef.value || null;
},
enumerable: true,
configurable: true,
});
}
}

View File

@@ -1,41 +0,0 @@
/**
* @file Type definitions for form-associated elements.
*
* While these types are part of the HTML standard, they're not yet defined
* in the TypeScript standard library, so we define them here.
*
* @expires 2026-01-01
*/
/**
* Callbacks for form-associated elements.
*/
interface HTMLElement {
/**
* A callback invoked when the browser autofilling sets a value.
*/
formStateRestoreCallback?(state: FormValue, mode: "autocomplete"): void;
/**
* A callback invoked when the browser restores a value from a previous session.
*/
formStateRestoreCallback?(state: FormValue, mode: "restore"): void;
/**
* A callback invoked when the browser restores a value from a previous session.
*/
formStateRestoreCallback?(state: FormValue, mode: "restore" | "autocomplete"): void;
/**
* A callback that is invoked when the form is reset.
*/
formResetCallback?(): void;
/**
* A callback that is invoked when the element's disabled state changes.
*/
formDisabledCallback?(disabled: boolean): void;
/**
* A callback that is invoked when the element is associated with a form.
*/
formAssociatedCallback?(form: HTMLFormElement): void;
}

View File

@@ -124,8 +124,8 @@ export abstract class Table<T extends object>
@property({ type: String })
public order?: string;
@property({ type: String, attribute: false })
public search?: string;
@property({ type: String })
public search: string = "";
@property({ type: Boolean })
public checkbox = false;
@@ -547,11 +547,11 @@ export abstract class Table<T extends object>
return html`<div class="pf-c-toolbar__group pf-m-search-filter ${isQL ? "ql" : ""}">
<ak-table-search
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
.defaultValue=${this.search}
value=${ifDefined(this.search)}
label=${ifDefined(this.searchLabel)}
placeholder=${ifDefined(this.searchPlaceholder)}
.onSearch=${this.#searchListener}
.supportsQL=${this.supportsQL}
?supportsQL=${this.supportsQL}
.apiResponse=${this.data}
>
</ak-table-search>

View File

@@ -8,7 +8,6 @@ import { msg } from "@lit/localize";
import { css, CSSResult, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
@@ -17,23 +16,17 @@ import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-table-search")
export class TableSearchForm extends WithLicenseSummary(AKElement) {
@property({ type: String, reflect: false })
public defaultValue?: string;
export class TableSearch extends WithLicenseSummary(AKElement) {
@property()
public value?: string;
@property({ type: String })
public label = msg("Table Search");
@property({ type: String })
public placeholder = msg("Search...");
@property({ attribute: false })
@property({ type: Boolean })
public supportsQL: boolean = false;
@property({ attribute: false })
public apiResponse?: PaginatedResponse<unknown>;
@property({ attribute: false })
@property()
public onSearch?: (value: string) => void;
static styles: CSSResult[] = [
@@ -52,26 +45,25 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
`,
];
#formRef = createRef<HTMLFormElement>();
public reset = (): void => {
this.#formRef.value?.reset();
this.onSearch?.("");
public reset = () => {
if (!this.onSearch) return;
this.value = "";
this.onSearch("");
};
#submitListener = (event: SubmitEvent) => {
event.preventDefault();
const form = this.#formRef.value;
if (!form || !this.onSearch) return;
form.reportValidity();
if (!this.onSearch) return;
const form = event.target as HTMLFormElement;
const data = new FormData(form);
const value = data.get("search")?.toString() ?? "";
const value = data.get("search")?.toString().trim();
if (!value) {
return;
}
this.onSearch(value);
};
@@ -79,31 +71,27 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
renderInput(): TemplateResult {
if (this.supportsQL && this.hasEnterpriseLicense) {
return html`<ak-search-ql
aria-label=${ifDefined(this.label)}
name="search"
required
placeholder=${ifDefined(this.placeholder)}
value=${ifDefined(this.defaultValue)}
.apiResponse=${this.apiResponse}
.value=${this.value}
.onSearch=${(value: string) => {
if (!this.onSearch) return;
this.onSearch(value);
}}
name="search"
></ak-search-ql>`;
}
return html`<input
aria-label=${ifDefined(this.label)}
name="search"
required
placeholder=${ifDefined(this.placeholder)}
value=${ifDefined(this.defaultValue)}
class="pf-c-form-control"
name="search"
type="search"
placeholder=${msg("Search...")}
value="${ifDefined(this.value)}"
/>`;
}
render(): TemplateResult {
return html`<form
${ref(this.#formRef)}
class="pf-c-input-group"
@submit=${this.#submitListener}
>
return html`<form class="pf-c-input-group" method="get" @submit=${this.#submitListener}>
${this.renderInput()}
<button
aria-label=${msg("Clear search")}
@@ -122,6 +110,6 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
declare global {
interface HTMLElementTagNameMap {
"ak-table-search": TableSearchForm;
"ak-table-search": TableSearch;
}
}

View File

@@ -9,7 +9,7 @@ import { ListenerController } from "#elements/utils/listenerController";
import { randomId } from "#elements/utils/randomId";
import { BaseStage } from "#flow/stages/base";
import { CaptchaHandler, CaptchaProvider, iframeTemplate } from "#flow/stages/captcha/shared";
import { CaptchaHandler, iframeTemplate } from "#flow/stages/captcha/shared";
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
@@ -109,7 +109,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#region State
@state()
protected activeHandler: CaptchaProvider | null = null;
protected activeHandler: CaptchaHandler | null = null;
@state()
protected error: string | null = null;
@@ -265,12 +265,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#endregion
/**
* Mapping of captcha provider names to their respective JS API global.
*
* Note that this is a `Map` to ensure the preferred order of discovering provider globals.
*/
#handlers = new Map<CaptchaProvider, CaptchaHandler>([
#handlers = new Map<string, CaptchaHandler>([
[
"grecaptcha",
{
@@ -420,7 +415,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
//#endregion
//#region Resizing
//#region Listeners
#loadListener = () => {
const iframe = this.#iframeRef.value;
@@ -428,73 +423,17 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
if (!iframe || !contentDocument) return;
let synchronizeHeight: () => void;
const resizeListener: ResizeObserverCallback = () => {
if (!this.#iframeRef) return;
if (this.activeHandler === CaptchaProvider.reCAPTCHA) {
// reCAPTCHA's use of nested iframes prevents their internal resize observer from
// reporting the correct height back to our iframe, so we have to do it ourselves.
const target = contentDocument.getElementById("ak-container");
synchronizeHeight = () => {
if (!this.#iframeRef) return;
if (!target) return;
const target = contentDocument.getElementById("ak-container");
this.iframeHeight = Math.round(target.clientHeight);
};
if (!target) return;
const innerIFrame = contentDocument.querySelector<HTMLIFrameElement>(
'iframe[style~="height:"]',
);
const innerBottom = innerIFrame?.getBoundingClientRect().bottom ?? 0;
const actualHeight = Math.max(innerBottom, target.clientHeight);
this.iframeHeight = Math.round(actualHeight * 1.1);
if (innerIFrame?.parentElement) {
innerIFrame.parentElement.style.height = `${actualHeight}px`;
}
};
// We watch for any newly inserted iframes, as they may alter the height
// of the parent iframe...
const mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== "childList") continue;
for (const node of mutation.addedNodes as NodeListOf<HTMLElement>) {
if (node.tagName !== "IFRAME") continue;
// And then resize the iframe to match the new size.
//
// This doesn't fix the issue entirely since the challenge frame
// doesn't yet know the correct height, but at least the user can
// try to load the challenge again with the correct height.
resizeObserver.observe(node as HTMLIFrameElement);
requestAnimationFrame(synchronizeHeight);
}
}
});
mutationObserver.observe(contentDocument.body, {
childList: true,
subtree: true,
});
} else {
synchronizeHeight = () => {
if (!this.#iframeRef) return;
const target = contentDocument.getElementById("ak-container");
if (!target) return;
this.iframeHeight = Math.round(target.clientHeight);
};
}
const resizeObserver = new ResizeObserver(synchronizeHeight);
const resizeObserver = new ResizeObserver(resizeListener);
requestAnimationFrame(() => {
resizeObserver.observe(contentDocument.body);
@@ -503,26 +442,22 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
});
};
//#endregion
//#region Loading
#scriptLoadListener = async (): Promise<void> => {
console.debug("authentik/stages/captcha: script loaded");
this.error = null;
this.#iframeLoaded = false;
for (const name of this.#handlers.keys()) {
for (const [name, handler] of this.#handlers) {
if (!Object.hasOwn(window, name)) {
continue;
}
try {
await this.#run(name);
await this.#run(handler);
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
this.activeHandler = name;
this.activeHandler = handler;
return;
} catch (error) {
@@ -534,9 +469,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}
};
async #run(captchaProvider: CaptchaProvider) {
const handler = this.#handlers.get(captchaProvider)!;
async #run(handler: CaptchaHandler) {
if (this.challenge.interactive) {
const iframe = this.#iframeRef.value;
@@ -545,44 +478,18 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return;
}
const { contentDocument } = iframe;
if (!contentDocument) {
console.debug(
`authentik/stages/captcha: No iframe content window found, skipping.`,
);
return;
}
console.debug(`authentik/stages/captcha: Rendering interactive.`);
const captchaElement = handler.interactive();
const template = iframeTemplate(captchaElement, {
challengeURL: this.challenge.jsUrl,
theme: this.activeTheme,
});
const template = iframeTemplate(captchaElement, this.challenge.jsUrl);
if (captchaProvider === CaptchaProvider.reCAPTCHA) {
// reCAPTCHA's domain verification can't seem to penetrate the true origin
// of the page when loaded from a blob URL, likely due to their double-nested
// iframe structure.
// We fallback to the deprecated `document.write` to get around this.
this.#iframeSource = "about:blank";
contentDocument.open();
contentDocument.write(template);
contentDocument.close();
URL.revokeObjectURL(this.#iframeSource);
// this.#loadListener();
} else {
URL.revokeObjectURL(this.#iframeSource);
const url = URL.createObjectURL(new Blob([template], { type: "text/html" }));
const url = URL.createObjectURL(new Blob([template], { type: "text/html" }));
this.#iframeSource = url;
this.#iframeSource = url;
iframe.src = url;
}
iframe.src = url;
return;
}

View File

@@ -1,20 +1,7 @@
import type { ResolvedUITheme } from "#common/theme";
import { createDocumentTemplate } from "#elements/utils/iframe";
import { html, TemplateResult } from "lit";
/**
* Mapping of captcha provider names to their respective JS API global.
*/
export const CaptchaProvider = {
reCAPTCHA: "grecaptcha",
hCaptcha: "hcaptcha",
Turnstile: "turnstile",
} as const satisfies Record<string, string>;
export type CaptchaProvider = (typeof CaptchaProvider)[keyof typeof CaptchaProvider];
export interface CaptchaHandler {
interactive(): TemplateResult;
execute(): Promise<void>;
@@ -22,29 +9,6 @@ export interface CaptchaHandler {
refresh(): Promise<void>;
}
const ThemeColor = {
dark: "#18191a",
light: "#ffffff",
} as const satisfies Record<ResolvedUITheme, string>;
export function themeMeta(theme: ResolvedUITheme) {
switch (theme) {
case "dark":
return html`
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content=${ThemeColor.dark} />
`;
case "light":
return html` <meta name="color-scheme" content="light" />
<meta name="theme-color" content=${ThemeColor.light} />`;
}
}
export interface IFrameTemplateInit {
challengeURL: string;
theme: ResolvedUITheme;
}
/**
* A container iframe for a hosted Captcha, with an event emitter to monitor
* when the Captcha forces a resize.
@@ -53,17 +17,10 @@ export interface IFrameTemplateInit {
* margin, adding 2rem of height to our container adds padding and prevents scrollbars
* or hidden rendering.
*/
export function iframeTemplate(
children: TemplateResult,
{ challengeURL, theme }: IFrameTemplateInit,
) {
export function iframeTemplate(children: TemplateResult, challengeURL: string): string {
return createDocumentTemplate({
head: html`
<meta charset="UTF-8" />
head: html`<meta charset="UTF-8" />
${themeMeta(theme)}
`,
body: html`
<script>
"use strict";
@@ -86,11 +43,6 @@ export function iframeTemplate(
</script>
<style>
html,
body {
background: ${ThemeColor[theme]};
}
body {
margin: 0;
padding: 0;
@@ -106,9 +58,8 @@ export function iframeTemplate(
align-items: center;
justify-content: center;
}
</style>
${children}
<script onload="loadListener()" src="${challengeURL}"></script>
`,
</style>`,
body: html`${children}
<script onload="loadListener()" src="${challengeURL}"></script> `,
});
}

View File

@@ -10037,24 +10037,6 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -7917,24 +7917,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -10085,24 +10085,6 @@ El valor de este campo se compara con el atributo de pertenencia del usuario.</t
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -10108,24 +10108,6 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
<target>None</target>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -10041,24 +10041,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -9363,24 +9363,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -9269,24 +9269,6 @@ Bindingen naar groepen/gebruikers worden gecontroleerd tegen de gebruiker van de
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -9684,24 +9684,6 @@ Powiązania z grupami/użytkownikami są sprawdzane względem użytkownika zdarz
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -9693,22 +9693,4 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body></file></xliff>

View File

@@ -9775,24 +9775,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -9748,24 +9748,6 @@ Gruplara/kullanıcılara yapılan bağlamalar, etkinliğin kullanıcısına kar
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -6544,24 +6544,6 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -10042,24 +10042,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -7623,24 +7623,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -9343,24 +9343,6 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="s669b18c6d2d9c95b">
<source>None</source>
</trans-unit>
<trans-unit id="s2de7890b100c4f4c">
<source>Flags</source>
</trans-unit>
<trans-unit id="sf0b34d32a602aee8">
<source>Modify flags to opt into new authentik behaviours early.</source>
</trans-unit>
<trans-unit id="s503217f10ebdc63e">
<source>Loading templates...</source>
</trans-unit>
<trans-unit id="s6891d230751dd7bf">
<source>Template used for the verification email.</source>
</trans-unit>
<trans-unit id="sf1fc82d7ae3f4d81">
<source>Email Subject Prefix</source>
</trans-unit>
<trans-unit id="s66401e0b2526acab">
<source>Email Template</source>
</trans-unit>
</body>
</file>

View File

@@ -6,4 +6,3 @@ coverage
node_modules
help
static
scripts/docsmg/target/

View File

@@ -34,7 +34,7 @@ tautulli_password: password
Add all Tautulli users to the Group. You should also create a Group Membership Policy to limit access to the application.
## Tautulli configuration
## Tautulli Setup
- Internal host
@@ -46,11 +46,11 @@ Add all Tautulli users to the Group. You should also create a Group Membership P
Set this to the external URL you will be accessing Tautulli from.
Basic authentication settings have been removed from the UI and are now available in the `config.ini` file. For basic auth to work, do the following:
Basic authentication settings have been removed from the UI and are now available in config.ini. For basic auth to work, do the following:
1. Close Tautulli.
1. shut down Tautulli
2. Set the following in the config file:
2. Set/Change the following in the config file:
```yaml
http_basic_auth = 1
@@ -59,11 +59,11 @@ http_hashed_password = 1
http_password = `<enter your password>`
```
3. Save the changes and then restart Tautulli.
3. Save the changes and then restart Tautulli
4. Afterwards, you need to deploy an Outpost in front of Tautulli, as described [here](https://docs.goauthentik.io/docs/add-secure-apps/outposts/).
Note: You can use the embedded outpost and simply add Tatulli to the list of applications to use.
4. Afterwards, you need to deploy an Outpost in front of Tautulli, as described [here](https://docs.goauthentik.io/docs/add-secure-apps/outposts/)
Note: You can use the embedded outpost and simply add Tatulli to the list of applications to use
## Configuration verification
To confirm that authentik is properly configured with Tautulli, log out and log back in via authentik (you can use private browsing mode to validate) and navigate to Tautulli. You should bypass the login prompt if setup correctly.
To confirm that authentik is properly configured with `Tautulli`, log out and log back in via authentik (you can use private browsing mode to validate) and navigate to Tautulli. You should bypass the login prompt if setup correctly.

View File

@@ -6,7 +6,7 @@ support_level: community
## What is Headscale
> Headscale is an open source alternative to the Tailscale coordination server and can be self-hosted for a single tailnet. Headscale is a re-implemented version of the Tailscale coordination server, developed independently and completely separate from Tailscale, with its own independent community of users and developers.
> [Headscale](https://github.com/juanfont/headscale) is an open source alternative to the Tailscale coordination server and can be self-hosted for a single tailnet. Headscale is a re-implemented version of the Tailscale coordination server, developed independently and completely separate from Tailscale, with its own independent community of users and developers.
>
> -- https://headscale.net

View File

@@ -1,69 +0,0 @@
---
title: Integrate with Vaultwarden
sidebar_label: Vaultwarden
support_level: community
---
## What is Vaultwarden
> Vaultwarden is an alternative server implementation of the Bitwarden Client API, written in Rust and compatible with official Bitwarden clients, perfect for self-hosted deployment where running the official resource-heavy service might not be ideal.
>
> -- https://github.com/dani-garcia/vaultwarden
## Preparation
The following placeholders are used in this guide:
- `vaultwarden.company` is the FQDN of the Vaultwarden installation.
- `authentik.company` is the FQDN of the authentik installation.
:::note
This documentation lists only the settings that you need to change from their default values. Be aware that any changes other than those explicitly mentioned in this guide could cause issues accessing your application.
:::
## authentik configuration
To support the integration of Vaultwarden with authentik, you need to create an application/provider pair in authentik.
### Create an application and provider in authentik
1. Log in to authentik as an administrator and open the authentik Admin interface.
2. Navigate to **Applications** > **Applications** and click **Create with Provider** to create an application and provider pair. (Alternatively you can first create a provider separately, then create the application and connect it with the provider.)
- **Application**: provide a descriptive name, an optional group for the type of application, the policy engine mode, and optional UI settings.
- **Choose a Provider type**: select **OAuth2/OpenID Connect** as the provider type.
- **Configure the Provider**: provide a name (or accept the auto-provided name), the authorization flow to use for this provider, and the following required configurations.
- Note the **Client ID**,**Client Secret**, and **slug** values because they will be required later.
- Set a `Strict` redirect URI to `https://vaultwarden.company/identity/connect/oidc-signin`.
- Select any available signing key.
- Under **Advanced protocol settings**:
- Set **Access token validity** to more than 5 minutes.
- Ensure the `offline_access` scope mapping is available by adding `authentik default OAuth Mapping: OpenID 'offline_access'` to the selected scopes.
- **Configure Bindings** _(optional)_: you can create a [binding](/docs/add-secure-apps/flows-stages/bindings/) (policy, group, or user) to manage the listing and access to applications on a user's **My applications** page.
3. Click **Submit** to save the new application and provider.
## Vaultwarden configuration
To configure authentik with Vaultwarden, you must add the following environment variables to your Vaultwarden deployment:
```yaml
SSO_ENABLED=true
SSO_AUTHORITY=https://authentik.company/application/o/<application_slug>
SSO_CLIENT_ID=<client_id>
SSO_CLIENT_SECRET=<client_secret>
SSO_SCOPES="openid email profile offline_access"
SSO_ALLOW_UNKNOWN_EMAIL_VERIFICATION=false
SSO_CLIENT_CACHE_EXPIRATION=0
SSO_ONLY=false # Set to true to disable email+master password login and require SSO
SSO_SIGNUPS_MATCH_EMAIL=true # Match first SSO login to existing account by email
```
Then restart Vaultwarden to apply the changes.
## References
- [Vaultwarden Wiki - SSO using OpenID Connect](https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect)
## Configuration verification
To verify the integration of authentik with Vaultwarden, log out of Vaultwarden, then on the login page enter a verified email and click **Use single sign-on**.

View File

@@ -19,7 +19,7 @@
"@goauthentik/eslint-config": "^1.0.5",
"@goauthentik/prettier-config": "^3.1.0",
"@goauthentik/tsconfig": "^1.0.4",
"@types/node": "^24.3.0",
"@types/node": "^24.2.1",
"@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.1",
"eslint": "^9.33.0",
@@ -7310,9 +7310,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"

View File

@@ -22,7 +22,7 @@
"@goauthentik/eslint-config": "^1.0.5",
"@goauthentik/prettier-config": "^3.1.0",
"@goauthentik/tsconfig": "^1.0.4",
"@types/node": "^24.3.0",
"@types/node": "^24.2.1",
"@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.1",
"eslint": "^9.33.0",