Compare commits

...

16 Commits

Author SHA1 Message Date
4b0d641a51 tests: better ws support
cherry-picked from https://github.com/goauthentik/authentik/pull/14539

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-05-18 00:45:55 +02:00
99b559893b core, web: update translations (#14530)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-05-16 16:03:21 +02:00
8014088c3a core: bump astral-sh/uv from 0.7.3 to 0.7.4 (#14531)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.7.3 to 0.7.4.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.7.3...0.7.4)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 16:03:08 +02:00
3ee353126f core: bump github.com/getsentry/sentry-go from 0.32.0 to 0.33.0 (#14532)
Bumps [github.com/getsentry/sentry-go](https://github.com/getsentry/sentry-go) from 0.32.0 to 0.33.0.
- [Release notes](https://github.com/getsentry/sentry-go/releases)
- [Changelog](https://github.com/getsentry/sentry-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-go/compare/v0.32.0...v0.33.0)

---
updated-dependencies:
- dependency-name: github.com/getsentry/sentry-go
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 16:02:57 +02:00
db76c5d9e2 core: bump goauthentik.io/api/v3 from 3.2025040.1 to 3.2025041.1 (#14533)
Bumps [goauthentik.io/api/v3](https://github.com/goauthentik/client-go) from 3.2025040.1 to 3.2025041.1.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Changelog](https://github.com/goauthentik/client-go/blob/main/model_version_history.go)
- [Commits](https://github.com/goauthentik/client-go/compare/v3.2025040.1...v3.2025041.1)

---
updated-dependencies:
- dependency-name: goauthentik.io/api/v3
  dependency-version: 3.2025041.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 16:02:50 +02:00
61bff69b7d core: bump django-pglock from 1.7.1 to 1.7.2 (#14534)
Bumps [django-pglock](https://github.com/AmbitionEng/django-pglock) from 1.7.1 to 1.7.2.
- [Release notes](https://github.com/AmbitionEng/django-pglock/releases)
- [Changelog](https://github.com/AmbitionEng/django-pglock/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AmbitionEng/django-pglock/compare/1.7.1...1.7.2)

---
updated-dependencies:
- dependency-name: django-pglock
  dependency-version: 1.7.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-16 16:02:42 +02:00
69651323e3 web: bump API Client version (#14528)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-05-15 20:19:16 +02:00
75a0ac9588 release: 2025.4.1 (#14527)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	package.json
2025-05-15 20:12:41 +02:00
941a697397 website/docs: release notes for 2025.4.1 (#14526)
* website/docs: release notes for 2025.4.1

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-05-15 19:26:01 +02:00
4a74db17a1 web: bump undici from 6.21.1 to 6.21.3 in /web (#14524)
Bumps [undici](https://github.com/nodejs/undici) from 6.21.1 to 6.21.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.21.1...v6.21.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.21.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-15 16:58:28 +02:00
0cf6bff93c tests/e2e: add test for authentication flow in compatibility mode (#14392)
* tests/e2e: add test for authentication flow in compatibility mode

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: Add prefix class to CSS for easier debugging of constructed stylesheets.

- Use CSS variables for highlighter.

* web: Fix issue where MDX components apply styles out of order.

* web: Fix hover color.

* web: Fix CSS module types. Clean up globals.

* web: Fix issues surrounding availability of shadow root in compatibility mode.

* web: Fix typo.

* web: Partial fixes for storybook dark theme.

* web: Fix overflow.

* web: Fix issues surrounding competing interfaces attempting to apply styles.

* fix padding in ak-alert in. markdown

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: Minimize use of sub-module exports.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <teffen@sister.software>
2025-05-15 16:51:11 +02:00
814e438422 stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#14513)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-05-15 16:25:28 +02:00
2db77a37dd lifecycle/aws: bump aws-cdk from 2.1014.0 to 2.1015.0 in /lifecycle/aws (#14516)
Bumps [aws-cdk](https://github.com/aws/aws-cdk-cli/tree/HEAD/packages/aws-cdk) from 2.1014.0 to 2.1015.0.
- [Release notes](https://github.com/aws/aws-cdk-cli/releases)
- [Commits](https://github.com/aws/aws-cdk-cli/commits/aws-cdk@v2.1015.0/packages/aws-cdk)

---
updated-dependencies:
- dependency-name: aws-cdk
  dependency-version: 2.1015.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-15 15:09:53 +02:00
e40c5ac617 web/admin: Dual select state management, custom event dispatching. (#14490)
* web/admin: Fix issues surrounding dual select state management.

* web: Fix nested path.

* web: Use PatternFly variable.
2025-05-15 14:47:47 +02:00
7440900dac core: fix unable to create group if no enable_group_superuser permission is given (#14510)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-05-15 14:41:26 +02:00
ca96b27825 web/admin: Fix sidebar toggle synchronization. (#14487)
* web: Fix issue where resizing from tablet or smaller viewport desyncs the sidebar.

* web: Fix issue where focus style overrides hover state style.
2025-05-14 17:19:22 +02:00
124 changed files with 3122 additions and 3660 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2025.4.0
current_version = 2025.4.1
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?

View File

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

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2025.4.0"
__version__ = "2025.4.1"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -99,18 +99,17 @@ class GroupSerializer(ModelSerializer):
if superuser
else "authentik_core.disable_group_superuser"
)
has_perm = user.has_perm(perm)
if self.instance and not has_perm:
has_perm = user.has_perm(perm, self.instance)
if not has_perm:
raise ValidationError(
_(
(
"User does not have permission to set "
"superuser status to {superuser_status}."
).format_map({"superuser_status": superuser})
if self.instance or superuser:
has_perm = user.has_perm(perm) or user.has_perm(perm, self.instance)
if not has_perm:
raise ValidationError(
_(
(
"User does not have permission to set "
"superuser status to {superuser_status}."
).format_map({"superuser_status": superuser})
)
)
)
return superuser
class Meta:

View File

@ -124,6 +124,16 @@ class TestGroupsAPI(APITestCase):
{"is_superuser": ["User does not have permission to set superuser status to True."]},
)
def test_superuser_no_perm_no_superuser(self):
"""Test creating a group without permission and without superuser flag"""
assign_perm("authentik_core.add_group", self.login_user)
self.client.force_login(self.login_user)
res = self.client.post(
reverse("authentik_api:group-list"),
data={"name": generate_id(), "is_superuser": False},
)
self.assertEqual(res.status_code, 201)
def test_superuser_update_no_perm(self):
"""Test updating a superuser group without permission"""
group = Group.objects.create(name=generate_id(), is_superuser=True)

View File

@ -7,7 +7,7 @@
{{ block.super }}
<link rel="prefetch" href="{{ flow_background_url }}" />
{% if flow.compatibility_mode and not inspector %}
<script>ShadyDOM = { force: !navigator.webdriver };</script>
<script>ShadyDOM = { force: true };</script>
{% endif %}
{% include "base/header_js.html" %}
<script>

View File

@ -11,7 +11,7 @@ from django.test.runner import DiscoverRunner
from authentik.lib.config import CONFIG
from authentik.lib.sentry import sentry_init
from authentik.root.signals import post_startup, pre_startup, startup
from tests.e2e.utils import get_docker_tag
from tests.docker import get_docker_tag
# globally set maxDiff to none to show full assert error
TestCase.maxDiff = None

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object",
"title": "authentik 2025.4.0 Blueprint schema",
"title": "authentik 2025.4.1 Blueprint schema",
"required": [
"version",
"entries"

View File

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

4
go.mod
View File

@ -5,7 +5,7 @@ go 1.24.0
require (
beryju.io/ldap v0.1.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/getsentry/sentry-go v0.32.0
github.com/getsentry/sentry-go v0.33.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-openapi/runtime v0.28.0
@ -27,7 +27,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025040.1
goauthentik.io/api/v3 v3.2025041.1
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.14.0

8
go.sum
View File

@ -69,8 +69,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY=
github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg=
github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@ -290,8 +290,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.2025040.1 h1:rQEcMNpz84/LPX8LVFteOJuserrd4PnU4k1Iu/wWqhs=
goauthentik.io/api/v3 v3.2025040.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2025041.1 h1:GAN6AoTmfnCGgx1SyM07jP4/LR/T3rkTEyShSBd3Co8=
goauthentik.io/api/v3 v3.2025041.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=

View File

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

View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "MIT",
"devDependencies": {
"aws-cdk": "^2.1014.0",
"aws-cdk": "^2.1015.0",
"cross-env": "^7.0.3"
},
"engines": {
@ -17,9 +17,9 @@
}
},
"node_modules/aws-cdk": {
"version": "2.1014.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1014.0.tgz",
"integrity": "sha512-es101rtRAClix9BncNL54iW90MiOyRv4iCC5tv/firGDnidS6pPinuK0IIFt0RO6w0+3heRxWBXg8HY+f9877w==",
"version": "2.1015.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1015.0.tgz",
"integrity": "sha512-txd+yMVVybtLfiwT409+fahbP0SkiwhmQvQf6PVVYnWzDPSknxYlUNJHisHV4tJEcbHWn1QPsLmqqMT0bw8hBg==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@ -10,7 +10,7 @@
"node": ">=20"
},
"devDependencies": {
"aws-cdk": "^2.1014.0",
"aws-cdk": "^2.1015.0",
"cross-env": "^7.0.3"
}
}

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@goauthentik/authentik",
"version": "2025.4.0",
"version": "2025.4.1",
"private": true,
"type": "module",
"devDependencies": {

View File

@ -1,6 +1,6 @@
[project]
name = "authentik"
version = "2025.4.0"
version = "2025.4.1"
description = ""
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
requires-python = "==3.13.*"
@ -19,7 +19,7 @@ dependencies = [
"django-filter==25.1",
"django-guardian<3.0.0",
"django-model-utils==5.0.0",
"django-pglock==1.7.1",
"django-pglock==1.7.2",
"django-prometheus==2.3.1",
"django-redis==5.4.0",
"django-storages[s3]==1.14.6",

View File

@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: authentik
version: 2025.4.0
version: 2025.4.1
description: Making authentication simple.
contact:
email: hello@goauthentik.io

View File

@ -0,0 +1,12 @@
import socket
from os import environ
IS_CI = "CI" in environ
RETRIES = int(environ.get("RETRIES", "3")) if IS_CI else 1
def get_local_ip() -> str:
"""Get the local machine's IP"""
hostname = socket.gethostname()
ip_addr = socket.gethostbyname(hostname)
return ip_addr

190
tests/browser.py Normal file
View File

@ -0,0 +1,190 @@
"""authentik e2e testing utilities"""
# This file cannot import anything django or anything that will load django
import json
from sys import stderr
from time import sleep
from typing import TYPE_CHECKING
from unittest.case import TestCase
from urllib.parse import urlencode
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.urls import reverse
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.command import Command
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait
from structlog.stdlib import get_logger
from tests import IS_CI, RETRIES, get_local_ip
from tests.websocket import BaseWebsocketTestCase
if TYPE_CHECKING:
from authentik.core.models import User
class BaseSeleniumTestCase(TestCase):
"""Mixin which adds helpers for spinning up Selenium"""
host = get_local_ip()
wait_timeout: int
user: "User"
def setUp(self):
if IS_CI:
print("::group::authentik Logs", file=stderr)
from django.apps import apps
from authentik.core.tests.utils import create_test_admin_user
apps.get_app_config("authentik_tenants").ready()
self.wait_timeout = 60
self.driver = self._get_driver()
self.driver.implicitly_wait(30)
self.wait = WebDriverWait(self.driver, self.wait_timeout)
self.logger = get_logger()
self.user = create_test_admin_user()
super().setUp()
def _get_driver(self) -> WebDriver:
count = 0
try:
opts = webdriver.ChromeOptions()
opts.add_argument("--disable-search-engine-choice-screen")
return webdriver.Chrome(options=opts)
except WebDriverException:
pass
while count < RETRIES:
try:
driver = webdriver.Remote(
command_executor="http://localhost:4444/wd/hub",
options=webdriver.ChromeOptions(),
)
driver.maximize_window()
return driver
except WebDriverException:
count += 1
raise ValueError(f"Webdriver failed after {RETRIES}.")
def tearDown(self):
if IS_CI:
print("::endgroup::", file=stderr)
super().tearDown()
if IS_CI:
print("::group::Browser logs")
# Very verbose way to get browser logs
# https://github.com/SeleniumHQ/selenium/pull/15641
# for some reason this removes the `get_log` API from Remote Webdriver
# and only keeps it on the local Chrome web driver, even when using
# a remote chrome driver...? (nvm the fact this was released as a minor version)
for line in self.driver.execute(Command.GET_LOG, {"type": "browser"})["value"]:
print(line["message"])
if IS_CI:
print("::endgroup::")
self.driver.quit()
def wait_for_url(self, desired_url):
"""Wait until URL is `desired_url`."""
self.wait.until(
lambda driver: driver.current_url == desired_url,
f"URL {self.driver.current_url} doesn't match expected URL {desired_url}",
)
def url(self, view, query: dict | None = None, **kwargs) -> str:
"""reverse `view` with `**kwargs` into full URL using live_server_url"""
url = self.live_server_url + reverse(view, kwargs=kwargs)
if query:
return url + "?" + urlencode(query)
return url
def if_user_url(self, path: str | None = None) -> str:
"""same as self.url() but show URL in shell"""
url = self.url("authentik_core:if-user")
if path:
return f"{url}#{path}"
return url
def get_shadow_root(
self, selector: str, container: WebElement | WebDriver | None = None
) -> WebElement:
"""Get shadow root element's inner shadowRoot"""
if not container:
container = self.driver
shadow_root = container.find_element(By.CSS_SELECTOR, selector)
element = self.driver.execute_script("return arguments[0].shadowRoot", shadow_root)
return element
def shady_dom(self) -> WebElement:
class wrapper:
def __init__(self, container: WebDriver):
self.container = container
def find_element(self, by: str, selector: str) -> WebElement:
return self.container.execute_script(
"return document.__shady_native_querySelector(arguments[0])", selector
)
return wrapper(self.driver)
def login(self, shadow_dom=True):
"""Do entire login flow"""
if shadow_dom:
flow_executor = self.get_shadow_root("ak-flow-executor")
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
else:
flow_executor = self.shady_dom()
identification_stage = self.shady_dom()
wait = WebDriverWait(identification_stage, self.wait_timeout)
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "input[name=uidField]")))
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").click()
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys(
self.user.username
)
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys(
Keys.ENTER
)
if shadow_dom:
flow_executor = self.get_shadow_root("ak-flow-executor")
password_stage = self.get_shadow_root("ak-stage-password", flow_executor)
else:
flow_executor = self.shady_dom()
password_stage = self.shady_dom()
wait = WebDriverWait(password_stage, self.wait_timeout)
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "input[name=password]")))
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(
self.user.username
)
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER)
sleep(1)
def assert_user(self, expected_user: "User"):
"""Check users/me API and assert it matches expected_user"""
from authentik.core.api.users import UserSerializer
self.driver.get(self.url("authentik_api:user-me") + "?format=json")
user_json = self.driver.find_element(By.CSS_SELECTOR, "pre").text
user = UserSerializer(data=json.loads(user_json)["user"])
user.is_valid()
self.assertEqual(user["username"].value, expected_user.username)
self.assertEqual(user["name"].value, expected_user.name)
self.assertEqual(user["email"].value, expected_user.email)
class SeleniumTestCase(BaseSeleniumTestCase, StaticLiveServerTestCase):
"""Test case which spins up a selenium instance and a HTTP-only test server"""
class WebsocketSeleniumTestCase(BaseSeleniumTestCase, BaseWebsocketTestCase):
"""Test case which spins up a selenium instance and a Websocket/HTTP test server"""

48
tests/decorators.py Normal file
View File

@ -0,0 +1,48 @@
"""authentik e2e testing utilities"""
from collections.abc import Callable
from functools import wraps
from django.test.testcases import TransactionTestCase
from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
from structlog.stdlib import get_logger
from tests import RETRIES
def retry(max_retires=RETRIES, exceptions=None):
"""Retry test multiple times. Default to catching Selenium Timeout Exception"""
if not exceptions:
exceptions = [WebDriverException, TimeoutException, NoSuchElementException]
logger = get_logger()
def retry_actual(func: Callable):
"""Retry test multiple times"""
count = 1
@wraps(func)
def wrapper(self: TransactionTestCase, *args, **kwargs):
"""Run test again if we're below max_retries, including tearDown and
setUp. Otherwise raise the error"""
nonlocal count
try:
return func(self, *args, **kwargs)
except tuple(exceptions) as exc:
count += 1
if count > max_retires:
logger.debug("Exceeded retry count", exc=exc, test=self)
raise exc
logger.debug("Retrying on error", exc=exc, test=self)
self.tearDown()
self._post_teardown()
self._pre_setup()
self.setUp()
return wrapper(self, *args, **kwargs)
return wrapper
return retry_actual

139
tests/docker.py Normal file
View File

@ -0,0 +1,139 @@
"""Docker testing helpers"""
import os
from time import sleep
from typing import TYPE_CHECKING, Any
from unittest.case import TestCase
from docker import DockerClient, from_env
from docker.errors import DockerException
from docker.models.containers import Container
from docker.models.networks import Network
from authentik.lib.generators import generate_id
from tests import IS_CI
if TYPE_CHECKING:
from authentik.outposts.models import Outpost
def get_docker_tag() -> str:
"""Get docker-tag based off of CI variables"""
env_pr_branch = "GITHUB_HEAD_REF"
default_branch = "GITHUB_REF"
branch_name = os.environ.get(default_branch, "main")
if os.environ.get(env_pr_branch, "") != "":
branch_name = os.environ[env_pr_branch]
branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
return f"gh-{branch_name}"
class DockerTestCase(TestCase):
"""Mixin for dealing with containers"""
max_healthcheck_attempts = 30
__client: DockerClient
__network: Network
__label_id = generate_id()
def setUp(self) -> None:
self.__client = from_env()
self.__network = self.docker_client.networks.create(
name=f"authentik-test-{self.__label_id}"
)
super().setUp()
@property
def docker_client(self) -> DockerClient:
return self.__client
@property
def docker_network(self) -> Network:
return self.__network
@property
def docker_labels(self) -> dict:
return {"io.goauthentik.test": self.__label_id}
def get_container_image(self, base: str) -> str:
"""Try to pull docker image based on git branch, fallback to main if not found."""
image = f"{base}:gh-main"
if not IS_CI:
return image
try:
branch_image = f"{base}:{get_docker_tag()}"
self.docker_client.images.pull(branch_image)
return branch_image
except DockerException:
self.docker_client.images.pull(image)
return image
def run_container(self, **specs: dict[str, Any]) -> Container:
if "network_mode" not in specs:
specs["network"] = self.__network.name
specs["labels"] = self.docker_labels
specs["detach"] = True
if hasattr(self, "live_server_url"):
specs.setdefault("environment", {})
specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url
container = self.docker_client.containers.run(**specs)
container.reload()
state = container.attrs.get("State", {})
if "Health" not in state:
return container
self.wait_for_container(container)
return container
def output_container_logs(self, container: Container | None = None):
"""Output the container logs to our STDOUT"""
if IS_CI:
image = container.image
tags = image.tags[0] if len(image.tags) > 0 else str(image)
print(f"::group::Container logs - {tags}")
for log in container.logs().decode().split("\n"):
print(log)
if IS_CI:
print("::endgroup::")
def tearDown(self):
containers: list[Container] = self.docker_client.containers.list(
filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())}
)
for container in containers:
self.output_container_logs(container)
try:
container.stop()
except DockerException:
pass
try:
container.remove(force=True)
except DockerException:
pass
self.__network.remove()
super().tearDown()
def wait_for_container(self, container: Container):
"""Check that container is health"""
attempt = 0
while attempt < self.max_healthcheck_attempts:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
attempt += 1
sleep(0.5)
self.failureException("Container failed to start")
def wait_for_outpost(self, outpost: "Outpost"):
# Wait until outpost healthcheck succeeds
attempt = 0
while attempt < self.max_healthcheck_attempts:
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
return
attempt += 1
sleep(0.5)
self.failureException("Outpost failed to become healthy")

View File

@ -18,10 +18,12 @@ from authentik.stages.authenticator_static.models import (
StaticToken,
)
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDevice
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsAuthenticator(SeleniumTestCase):
class TestFlowsAuthenticator(DockerTestCase, SeleniumTestCase):
"""test flow with otp stages"""
@retry()

View File

@ -11,10 +11,12 @@ from authentik.core.models import User
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsEnroll(SeleniumTestCase):
class TestFlowsEnroll(DockerTestCase, SeleniumTestCase):
"""Test Enroll flow"""
@retry()

View File

@ -1,12 +1,21 @@
"""test default login flow"""
from authentik.blueprints.tests import apply_blueprint
from tests.e2e.utils import SeleniumTestCase, retry
from authentik.flows.models import Flow
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsLogin(SeleniumTestCase):
class TestFlowsLogin(DockerTestCase, SeleniumTestCase):
"""test default login flow"""
def tearDown(self):
# Reset authentication flow's compatibility mode; we need to do this as its
# not specified in the blueprint
Flow.objects.filter(slug="default-authentication-flow").update(compatibility_mode=False)
return super().tearDown()
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
@ -23,3 +32,21 @@ class TestFlowsLogin(SeleniumTestCase):
self.login()
self.wait_for_url(self.if_user_url("/library"))
self.assert_user(self.user)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
def test_login_compatibility_mode(self):
"""test default login flow with compatibility mode enabled"""
Flow.objects.filter(slug="default-authentication-flow").update(compatibility_mode=True)
self.driver.get(
self.url(
"authentik_core:if-flow",
flow_slug="default-authentication-flow",
)
)
self.login(shadow_dom=False)
self.wait_for_url(self.if_user_url("/library"))
self.assert_user(self.user)

View File

@ -6,10 +6,12 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from authentik.blueprints.tests import apply_blueprint
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsLoginSFE(SeleniumTestCase):
class TestFlowsLoginSFE(DockerTestCase, SeleniumTestCase):
"""test default login flow"""
def login(self):

View File

@ -13,10 +13,12 @@ from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsRecovery(SeleniumTestCase):
class TestFlowsRecovery(DockerTestCase, SeleniumTestCase):
"""Test Recovery flow"""
def initial_stages(self, user: User):

View File

@ -8,10 +8,12 @@ from authentik.core.models import User
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_key
from authentik.stages.password.models import PasswordStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestFlowsStageSetup(SeleniumTestCase):
class TestFlowsStageSetup(DockerTestCase, SeleniumTestCase):
"""test stage setup flows"""
@retry()

View File

@ -16,10 +16,12 @@ from authentik.lib.generators import generate_id
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost, OutpostConfig, OutpostType
from authentik.providers.ldap.models import APIAccessMode, LDAPProvider
from tests.e2e.utils import SeleniumTestCase, retry
from tests.decorators import retry
from tests.docker import DockerTestCase
from tests.websocket import WebsocketTestCase
class TestProviderLDAP(SeleniumTestCase):
class TestProviderLDAP(DockerTestCase, WebsocketTestCase):
"""LDAP and Outpost e2e tests"""
def start_ldap(self, outpost: Outpost):

View File

@ -18,10 +18,12 @@ from authentik.providers.oauth2.models import (
RedirectURI,
RedirectURIMatchingMode,
)
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderOAuth2Github(SeleniumTestCase):
class TestProviderOAuth2Github(DockerTestCase, SeleniumTestCase):
"""test OAuth Provider flow"""
def setUp(self):

View File

@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
RedirectURIMatchingMode,
ScopeMapping,
)
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderOAuth2OAuth(SeleniumTestCase):
class TestProviderOAuth2OAuth(DockerTestCase, SeleniumTestCase):
"""test OAuth with OAuth Provider flow"""
def setUp(self):

View File

@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
RedirectURIMatchingMode,
ScopeMapping,
)
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderOAuth2OIDC(SeleniumTestCase):
class TestProviderOAuth2OIDC(DockerTestCase, SeleniumTestCase):
"""test OAuth with OpenID Provider flow"""
def setUp(self):

View File

@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
RedirectURIMatchingMode,
ScopeMapping,
)
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
class TestProviderOAuth2OIDCImplicit(DockerTestCase, SeleniumTestCase):
"""test OAuth with OpenID Provider flow"""
def setUp(self):

View File

@ -3,11 +3,8 @@
from base64 import b64encode
from dataclasses import asdict
from json import loads
from sys import platform
from time import sleep
from unittest.case import skip, skipUnless
from channels.testing import ChannelsLiveServerTestCase
from jwt import decode
from selenium.webdriver.common.by import By
@ -18,10 +15,13 @@ from authentik.lib.generators import generate_id
from authentik.outposts.models import DockerServiceConnection, Outpost, OutpostConfig, OutpostType
from authentik.outposts.tasks import outpost_connection_discovery
from authentik.providers.proxy.models import ProxyProvider
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
from tests.websocket import WebsocketTestCase
class TestProviderProxy(SeleniumTestCase):
class TestProviderProxy(DockerTestCase, SeleniumTestCase):
"""Proxy and Outpost e2e tests"""
def setUp(self):
@ -37,13 +37,41 @@ class TestProviderProxy(SeleniumTestCase):
"""Start proxy container based on outpost created"""
self.run_container(
image=self.get_container_image("ghcr.io/goauthentik/dev-proxy"),
ports={
"9000": "9000",
},
environment={
"AUTHENTIK_TOKEN": outpost.token.key,
},
ports={"9000": "9000"},
environment={"AUTHENTIK_TOKEN": outpost.token.key},
)
self.wait_for_outpost(outpost)
def _prepare(self):
# set additionalHeaders to test later
self.user.attributes["additionalHeaders"] = {"X-Foo": "bar"}
self.user.save()
proxy: ProxyProvider = ProxyProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
internal_host=f"http://{self.host}",
external_host="http://localhost:9000",
basic_auth_enabled=True,
basic_auth_user_attribute="basic-username",
basic_auth_password_attribute="basic-password", # nosec
)
# Ensure OAuth2 Params are set
proxy.set_oauth_defaults()
proxy.save()
# we need to create an application to actually access the proxy
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
outpost: Outpost = Outpost.objects.create(
name=generate_id(),
type=OutpostType.PROXY,
)
outpost.providers.add(proxy)
outpost.build_user_permissions(outpost.user)
self.start_proxy(outpost)
@retry()
@apply_blueprint(
@ -61,44 +89,7 @@ class TestProviderProxy(SeleniumTestCase):
@reconcile_app("authentik_crypto")
def test_proxy_simple(self):
"""Test simple outpost setup with single provider"""
# set additionalHeaders to test later
self.user.attributes["additionalHeaders"] = {"X-Foo": "bar"}
self.user.save()
proxy: ProxyProvider = ProxyProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
internal_host=f"http://{self.host}",
external_host="http://localhost:9000",
)
# Ensure OAuth2 Params are set
proxy.set_oauth_defaults()
proxy.save()
# we need to create an application to actually access the proxy
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
outpost: Outpost = Outpost.objects.create(
name=generate_id(),
type=OutpostType.PROXY,
)
outpost.providers.add(proxy)
outpost.build_user_permissions(outpost.user)
self.start_proxy(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
break
healthcheck_retries += 1
sleep(0.5)
sleep(5)
self._prepare()
self.driver.get("http://localhost:9000/api")
self.login()
sleep(1)
@ -137,49 +128,13 @@ class TestProviderProxy(SeleniumTestCase):
@reconcile_app("authentik_crypto")
def test_proxy_basic_auth(self):
"""Test simple outpost setup with single provider"""
self._prepare()
# Setup basic auth
cred = generate_id()
attr = "basic-password" # nosec
self.user.attributes["basic-username"] = cred
self.user.attributes[attr] = cred
self.user.attributes["basic-password"] = cred
self.user.save()
proxy: ProxyProvider = ProxyProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
internal_host=f"http://{self.host}",
external_host="http://localhost:9000",
basic_auth_enabled=True,
basic_auth_user_attribute="basic-username",
basic_auth_password_attribute=attr,
)
# Ensure OAuth2 Params are set
proxy.set_oauth_defaults()
proxy.save()
# we need to create an application to actually access the proxy
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
outpost: Outpost = Outpost.objects.create(
name=generate_id(),
type=OutpostType.PROXY,
)
outpost.providers.add(proxy)
outpost.build_user_permissions(outpost.user)
self.start_proxy(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
break
healthcheck_retries += 1
sleep(0.5)
sleep(5)
self.driver.get("http://localhost:9000/api")
self.login()
sleep(1)
@ -187,9 +142,9 @@ class TestProviderProxy(SeleniumTestCase):
full_body_text = self.driver.find_element(By.CSS_SELECTOR, "pre").text
body = loads(full_body_text)
self.assertEqual(body["headers"]["X-Authentik-Username"], [self.user.username])
self.assertEqual(body.get("headers").get("X-Authentik-Username"), [self.user.username])
auth_header = b64encode(f"{cred}:{cred}".encode()).decode()
self.assertEqual(body["headers"]["Authorization"], [f"Basic {auth_header}"])
self.assertEqual(body.get("headers").get("Authorization"), [f"Basic {auth_header}"])
self.driver.get("http://localhost:9000/outpost.goauthentik.io/sign_out")
sleep(2)
@ -199,10 +154,7 @@ class TestProviderProxy(SeleniumTestCase):
self.assertIn("You've logged out of", title)
# TODO: Fix flaky test
@skip("Flaky test")
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestProviderProxyConnect(ChannelsLiveServerTestCase):
class TestProviderProxyConnect(DockerTestCase, WebsocketTestCase):
"""Test Proxy connectivity over websockets"""
@retry(exceptions=[AssertionError])
@ -241,14 +193,7 @@ class TestProviderProxyConnect(ChannelsLiveServerTestCase):
outpost.build_user_permissions(outpost.user)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen and state.version:
break
healthcheck_retries += 1
sleep(0.5)
self.wait_for_outpost(outpost)
state = outpost.state
self.assertGreaterEqual(len(state), 1)

View File

@ -13,10 +13,12 @@ from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderProxyForward(SeleniumTestCase):
class TestProviderProxyForward(DockerTestCase, SeleniumTestCase):
"""Proxy and Outpost e2e tests"""
def setUp(self):
@ -30,14 +32,11 @@ class TestProviderProxyForward(SeleniumTestCase):
"""Start proxy container based on outpost created"""
self.run_container(
image=self.get_container_image("ghcr.io/goauthentik/dev-proxy"),
ports={
"9000": "9000",
},
environment={
"AUTHENTIK_TOKEN": outpost.token.key,
},
ports={"9000": "9000"},
environment={"AUTHENTIK_TOKEN": outpost.token.key},
name="ak-test-outpost",
)
self.wait_for_outpost(outpost)
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
@ -77,17 +76,6 @@ class TestProviderProxyForward(SeleniumTestCase):
self.start_outpost(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
break
healthcheck_retries += 1
sleep(0.5)
sleep(5)
@retry()
def test_traefik(self):
"""Test traefik"""

View File

@ -1,7 +1,6 @@
"""Radius e2e tests"""
from dataclasses import asdict
from time import sleep
from pyrad.client import Client
from pyrad.dictionary import Dictionary
@ -9,14 +8,17 @@ from pyrad.packet import AccessAccept, AccessReject, AccessRequest
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application, User
from authentik.core.tests.utils import create_test_user
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id, generate_key
from authentik.outposts.models import Outpost, OutpostConfig, OutpostType
from authentik.providers.radius.models import RadiusProvider
from tests.e2e.utils import SeleniumTestCase, retry
from tests.decorators import retry
from tests.docker import DockerTestCase
from tests.websocket import WebsocketTestCase
class TestProviderRadius(SeleniumTestCase):
class TestProviderRadius(DockerTestCase, WebsocketTestCase):
"""Radius Outpost e2e tests"""
def setUp(self):
@ -28,13 +30,13 @@ class TestProviderRadius(SeleniumTestCase):
self.run_container(
image=self.get_container_image("ghcr.io/goauthentik/dev-radius"),
ports={"1812/udp": "1812/udp"},
environment={
"AUTHENTIK_TOKEN": outpost.token.key,
},
environment={"AUTHENTIK_TOKEN": outpost.token.key},
)
self.wait_for_outpost(outpost)
def _prepare(self) -> User:
"""prepare user, provider, app and container"""
self.user = create_test_user()
radius: RadiusProvider = RadiusProvider.objects.create(
name=generate_id(),
authorization_flow=Flow.objects.get(slug="default-authentication-flow"),
@ -50,17 +52,6 @@ class TestProviderRadius(SeleniumTestCase):
outpost.providers.add(radius)
self.start_radius(outpost)
# Wait until outpost healthcheck succeeds
healthcheck_retries = 0
while healthcheck_retries < 50: # noqa: PLR2004
if len(outpost.state) > 0:
state = outpost.state[0]
if state.last_seen:
break
healthcheck_retries += 1
sleep(0.5)
sleep(5)
return outpost
@retry()

View File

@ -14,10 +14,12 @@ from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
from authentik.sources.saml.processors.constants import SAML_BINDING_POST
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestProviderSAML(SeleniumTestCase):
class TestProviderSAML(DockerTestCase, SeleniumTestCase):
"""test SAML Provider flow"""
def setup_client(self, provider: SAMLProvider, force_post: bool = False):

View File

@ -11,10 +11,12 @@ from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestSourceLDAPSamba(SeleniumTestCase):
class TestSourceLDAPSamba(DockerTestCase, SeleniumTestCase):
"""test LDAP Source"""
def setUp(self):

View File

@ -16,7 +16,9 @@ from authentik.sources.oauth.models import OAuthSource
from authentik.sources.oauth.types.registry import SourceType, registry
from authentik.sources.oauth.views.callback import OAuthCallback
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class OAuth1Callback(OAuthCallback):
@ -48,7 +50,7 @@ class OAUth1Type(SourceType):
}
class TestSourceOAuth1(SeleniumTestCase):
class TestSourceOAuth1(DockerTestCase, SeleniumTestCase):
"""Test OAuth1 Source"""
def setUp(self) -> None:

View File

@ -16,10 +16,12 @@ from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.sources.oauth.models import OAuthSource
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
class TestSourceOAuth2(SeleniumTestCase):
class TestSourceOAuth2(DockerTestCase, SeleniumTestCase):
"""test OAuth Source flow"""
def setUp(self):

View File

@ -16,7 +16,9 @@ from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
IDP_CERT = """-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
@ -70,7 +72,7 @@ Sm75WXsflOxuTn08LbgGc4s=
-----END PRIVATE KEY-----"""
class TestSourceSAML(SeleniumTestCase):
class TestSourceSAML(DockerTestCase, SeleniumTestCase):
"""test SAML Source flow"""
def setUp(self):

View File

@ -8,12 +8,14 @@ from docker.types import Healthcheck
from authentik.lib.generators import generate_id
from authentik.lib.utils.http import get_http_session
from authentik.sources.scim.models import SCIMSource
from tests.e2e.utils import SeleniumTestCase, retry
from tests.browser import SeleniumTestCase
from tests.decorators import retry
from tests.docker import DockerTestCase
TEST_POLL_MAX = 25
class TestSourceSCIM(SeleniumTestCase):
class TestSourceSCIM(DockerTestCase, SeleniumTestCase):
"""test SCIM Source flow"""
def setUp(self):

View File

@ -1,317 +0,0 @@
"""authentik e2e testing utilities"""
import json
import os
import socket
from collections.abc import Callable
from functools import lru_cache, wraps
from os import environ
from sys import stderr
from time import sleep
from typing import Any
from unittest.case import TestCase
from urllib.parse import urlencode
from django.apps import apps
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from django.db import connection
from django.db.migrations.loader import MigrationLoader
from django.test.testcases import TransactionTestCase
from django.urls import reverse
from docker import DockerClient, from_env
from docker.errors import DockerException
from docker.models.containers import Container
from docker.models.networks import Network
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.remote.command import Command
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.wait import WebDriverWait
from structlog.stdlib import get_logger
from authentik.core.api.users import UserSerializer
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id
RETRIES = int(environ.get("RETRIES", "3"))
IS_CI = "CI" in environ
def get_docker_tag() -> str:
"""Get docker-tag based off of CI variables"""
env_pr_branch = "GITHUB_HEAD_REF"
default_branch = "GITHUB_REF"
branch_name = os.environ.get(default_branch, "main")
if os.environ.get(env_pr_branch, "") != "":
branch_name = os.environ[env_pr_branch]
branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
return f"gh-{branch_name}"
def get_local_ip() -> str:
"""Get the local machine's IP"""
hostname = socket.gethostname()
ip_addr = socket.gethostbyname(hostname)
return ip_addr
class DockerTestCase(TestCase):
"""Mixin for dealing with containers"""
max_healthcheck_attempts = 30
__client: DockerClient
__network: Network
__label_id = generate_id()
def setUp(self) -> None:
self.__client = from_env()
self.__network = self.docker_client.networks.create(name=f"authentik-test-{generate_id()}")
@property
def docker_client(self) -> DockerClient:
return self.__client
@property
def docker_network(self) -> Network:
return self.__network
@property
def docker_labels(self) -> dict:
return {"io.goauthentik.test": self.__label_id}
def wait_for_container(self, container: Container):
"""Check that container is health"""
attempt = 0
while True:
container.reload()
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
if status == "healthy":
return container
sleep(1)
attempt += 1
if attempt >= self.max_healthcheck_attempts:
self.failureException("Container failed to start")
def get_container_image(self, base: str) -> str:
"""Try to pull docker image based on git branch, fallback to main if not found."""
image = f"{base}:gh-main"
try:
branch_image = f"{base}:{get_docker_tag()}"
self.docker_client.images.pull(branch_image)
return branch_image
except DockerException:
self.docker_client.images.pull(image)
return image
def run_container(self, **specs: dict[str, Any]) -> Container:
if "network_mode" not in specs:
specs["network"] = self.__network.name
specs["labels"] = self.docker_labels
specs["detach"] = True
if hasattr(self, "live_server_url"):
specs.setdefault("environment", {})
specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url
container = self.docker_client.containers.run(**specs)
container.reload()
state = container.attrs.get("State", {})
if "Health" not in state:
return container
self.wait_for_container(container)
return container
def output_container_logs(self, container: Container | None = None):
"""Output the container logs to our STDOUT"""
if IS_CI:
image = container.image
tags = image.tags[0] if len(image.tags) > 0 else str(image)
print(f"::group::Container logs - {tags}")
for log in container.logs().decode().split("\n"):
print(log)
if IS_CI:
print("::endgroup::")
def tearDown(self):
containers: list[Container] = self.docker_client.containers.list(
filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())}
)
for container in containers:
self.output_container_logs(container)
try:
container.kill()
except DockerException:
pass
try:
container.remove(force=True)
except DockerException:
pass
self.__network.remove()
class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
host = get_local_ip()
wait_timeout: int
user: User
def setUp(self):
if IS_CI:
print("::group::authentik Logs", file=stderr)
apps.get_app_config("authentik_tenants").ready()
self.wait_timeout = 60
self.driver = self._get_driver()
self.driver.implicitly_wait(30)
self.wait = WebDriverWait(self.driver, self.wait_timeout)
self.logger = get_logger()
self.user = create_test_admin_user()
super().setUp()
def _get_driver(self) -> WebDriver:
count = 0
try:
opts = webdriver.ChromeOptions()
opts.add_argument("--disable-search-engine-choice-screen")
return webdriver.Chrome(options=opts)
except WebDriverException:
pass
while count < RETRIES:
try:
driver = webdriver.Remote(
command_executor="http://localhost:4444/wd/hub",
options=webdriver.ChromeOptions(),
)
driver.maximize_window()
return driver
except WebDriverException:
count += 1
raise ValueError(f"Webdriver failed after {RETRIES}.")
def tearDown(self):
if IS_CI:
print("::endgroup::", file=stderr)
super().tearDown()
if IS_CI:
print("::group::Browser logs")
# Very verbose way to get browser logs
# https://github.com/SeleniumHQ/selenium/pull/15641
# for some reason this removes the `get_log` API from Remote Webdriver
# and only keeps it on the local Chrome web driver, even when using
# a remote chrome driver...? (nvm the fact this was released as a minor version)
for line in self.driver.execute(Command.GET_LOG, {"type": "browser"})["value"]:
print(line["message"])
if IS_CI:
print("::endgroup::")
self.driver.quit()
def wait_for_url(self, desired_url):
"""Wait until URL is `desired_url`."""
self.wait.until(
lambda driver: driver.current_url == desired_url,
f"URL {self.driver.current_url} doesn't match expected URL {desired_url}",
)
def url(self, view, query: dict | None = None, **kwargs) -> str:
"""reverse `view` with `**kwargs` into full URL using live_server_url"""
url = self.live_server_url + reverse(view, kwargs=kwargs)
if query:
return url + "?" + urlencode(query)
return url
def if_user_url(self, path: str | None = None) -> str:
"""same as self.url() but show URL in shell"""
url = self.url("authentik_core:if-user")
if path:
return f"{url}#{path}"
return url
def get_shadow_root(
self, selector: str, container: WebElement | WebDriver | None = None
) -> WebElement:
"""Get shadow root element's inner shadowRoot"""
if not container:
container = self.driver
shadow_root = container.find_element(By.CSS_SELECTOR, selector)
element = self.driver.execute_script("return arguments[0].shadowRoot", shadow_root)
return element
def login(self):
"""Do entire login flow"""
flow_executor = self.get_shadow_root("ak-flow-executor")
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").click()
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys(
self.user.username
)
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys(
Keys.ENTER
)
flow_executor = self.get_shadow_root("ak-flow-executor")
password_stage = self.get_shadow_root("ak-stage-password", flow_executor)
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(
self.user.username
)
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER)
sleep(1)
def assert_user(self, expected_user: User):
"""Check users/me API and assert it matches expected_user"""
self.driver.get(self.url("authentik_api:user-me") + "?format=json")
user_json = self.driver.find_element(By.CSS_SELECTOR, "pre").text
user = UserSerializer(data=json.loads(user_json)["user"])
user.is_valid()
self.assertEqual(user["username"].value, expected_user.username)
self.assertEqual(user["name"].value, expected_user.name)
self.assertEqual(user["email"].value, expected_user.email)
@lru_cache
def get_loader():
"""Thin wrapper to lazily get a Migration Loader, only when it's needed
and only once"""
return MigrationLoader(connection)
def retry(max_retires=RETRIES, exceptions=None):
"""Retry test multiple times. Default to catching Selenium Timeout Exception"""
if not exceptions:
exceptions = [WebDriverException, TimeoutException, NoSuchElementException]
logger = get_logger()
def retry_actual(func: Callable):
"""Retry test multiple times"""
count = 1
@wraps(func)
def wrapper(self: TransactionTestCase, *args, **kwargs):
"""Run test again if we're below max_retries, including tearDown and
setUp. Otherwise raise the error"""
nonlocal count
try:
return func(self, *args, **kwargs)
except tuple(exceptions) as exc:
count += 1
if count > max_retires:
logger.debug("Exceeded retry count", exc=exc, test=self)
raise exc
logger.debug("Retrying on error", exc=exc, test=self)
self.tearDown()
self._post_teardown()
self._pre_setup()
self.setUp()
return wrapper(self, *args, **kwargs)
return wrapper
return retry_actual

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -19,7 +19,7 @@ from authentik.outposts.models import (
)
from authentik.outposts.tasks import outpost_connection_discovery
from authentik.providers.proxy.models import ProxyProvider
from tests.e2e.utils import DockerTestCase, get_docker_tag
from tests.docker import DockerTestCase, get_docker_tag
class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):

View File

@ -19,7 +19,7 @@ from authentik.outposts.models import (
from authentik.outposts.tasks import outpost_connection_discovery
from authentik.providers.proxy.controllers.docker import DockerController
from authentik.providers.proxy.models import ProxyProvider
from tests.e2e.utils import DockerTestCase, get_docker_tag
from tests.docker import DockerTestCase, get_docker_tag
class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):

52
tests/websocket.py Normal file
View File

@ -0,0 +1,52 @@
# This file cannot import anything django or anything that will load django
from sys import stderr
from channels.testing import ChannelsLiveServerTestCase
from daphne.testing import DaphneProcess
from structlog.stdlib import get_logger
from tests import IS_CI, get_local_ip
def set_database_connection():
from django.conf import settings
settings.DATABASES["default"]["NAME"] = settings.DATABASES["default"]["TEST"]["NAME"]
settings.TEST = True
class DatabasePatchDaphneProcess(DaphneProcess):
# See https://github.com/django/channels/issues/2048
# See https://github.com/django/channels/pull/2033
def __init__(self, host, get_application, kwargs=None, setup=None, teardown=None):
super().__init__(host, get_application, kwargs, setup, teardown)
self.setup = set_database_connection
class BaseWebsocketTestCase(ChannelsLiveServerTestCase):
"""Base channels test case"""
host = get_local_ip()
ProtocolServerProcess = DatabasePatchDaphneProcess
class WebsocketTestCase(BaseWebsocketTestCase):
"""Test case to allow testing against a running Websocket/HTTP server"""
def setUp(self):
if IS_CI:
print("::group::authentik Logs", file=stderr)
from django.apps import apps
from authentik.core.tests.utils import create_test_admin_user
apps.get_app_config("authentik_tenants").ready()
self.logger = get_logger()
self.user = create_test_admin_user()
super().setUp()
def tearDown(self):
if IS_CI:
print("::endgroup::", file=stderr)
super().tearDown()

10
uv.lock generated
View File

@ -164,7 +164,7 @@ wheels = [
[[package]]
name = "authentik"
version = "2025.4.0"
version = "2025.4.1"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },
@ -279,7 +279,7 @@ requires-dist = [
{ name = "django-filter", specifier = "==25.1" },
{ name = "django-guardian", specifier = "<3.0.0" },
{ name = "django-model-utils", specifier = "==5.0.0" },
{ name = "django-pglock", specifier = "==1.7.1" },
{ name = "django-pglock", specifier = "==1.7.2" },
{ name = "django-prometheus", specifier = "==2.3.1" },
{ name = "django-redis", specifier = "==5.4.0" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
@ -1063,15 +1063,15 @@ wheels = [
[[package]]
name = "django-pglock"
version = "1.7.1"
version = "1.7.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "django-pgactivity" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/9f/3b4b2f7021b626b3981646254f04fcc2db681d7ba7b24be563552368be70/django_pglock-1.7.1.tar.gz", hash = "sha256:69050bdb522fd34585d49bb8a4798dbfbab9ec4754dd1927b1b9eef2ec0edadf", size = 16907, upload-time = "2024-12-16T01:53:47.29Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/1a/6c552b75d0a6b380215c919a667072e4949a3123f275506c6e6ff82f6b76/django_pglock-1.7.2.tar.gz", hash = "sha256:d1d8521b382a5819e8d14978d0e8e63ab2763cb784f5a6bfdbe5de807da4a61a", size = 17287, upload-time = "2025-05-15T22:07:23.744Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/bf/6fc72033801279b4ae2003c5b93cc22dfe0814ca1f56432f7cd06975381d/django_pglock-1.7.1-py3-none-any.whl", hash = "sha256:15db418fb56bee37fc8707038495b5085af9b8c203ebfa300202572127bdb3f0", size = 17343, upload-time = "2024-12-16T01:53:45.243Z" },
{ url = "https://files.pythonhosted.org/packages/fb/d2/21f19531945f03021460d40654bc2fc3b0c474b57b279d5f5a1c34be7f1b/django_pglock-1.7.2-py3-none-any.whl", hash = "sha256:2f9335527779445fe86507b37e26cfde485a32b91d982a8f80039d3bcd25d596", size = 17674, upload-time = "2025-05-15T22:07:22.618Z" },
]
[[package]]

View File

@ -1,11 +0,0 @@
import { create } from "@storybook/theming/create";
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
export default create({
base: isDarkMode ? "dark" : "light",
brandTitle: "authentik Storybook",
brandUrl: "https://goauthentik.io",
brandImage: "https://goauthentik.io/img/icon_left_brand_colour.svg",
brandTarget: "_self",
});

69
web/.storybook/main.js Normal file
View File

@ -0,0 +1,69 @@
/**
* @file Storybook configuration.
* @import { StorybookConfig } from "@storybook/web-components-vite";
* @import { InlineConfig, Plugin } from "vite";
*/
import { cwd } from "process";
import postcssLit from "rollup-plugin-postcss-lit";
import tsconfigPaths from "vite-tsconfig-paths";
const NODE_ENV = process.env.NODE_ENV || "development";
const CSSImportPattern = /import [\w\$]+ from .+\.(css)/g;
const JavaScriptFilePattern = /\.m?(js|ts|tsx)$/;
/**
* @satisfies {Plugin<never>}
*/
const inlineCSSPlugin = {
name: "inline-css-plugin",
transform: (source, id) => {
if (!JavaScriptFilePattern.test(id)) return;
const code = source.replace(CSSImportPattern, (match) => {
return `${match}?inline`;
});
return {
code,
};
},
};
/**
* @satisfies {StorybookConfig}
*/
const config = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-controls",
"@storybook/addon-links",
"@storybook/addon-essentials",
"storybook-addon-mock",
],
framework: {
name: "@storybook/web-components-vite",
options: {},
},
docs: {
autodocs: "tag",
},
viteFinal({ plugins = [], ...config }) {
/**
* @satisfies {InlineConfig}
*/
const mergedConfig = {
...config,
define: {
"process.env.NODE_ENV": JSON.stringify(NODE_ENV),
"process.env.CWD": JSON.stringify(cwd()),
"process.env.AK_API_BASE_PATH": JSON.stringify(process.env.AK_API_BASE_PATH || ""),
},
plugins: [inlineCSSPlugin, ...plugins, postcssLit(), tsconfigPaths()],
};
return mergedConfig;
},
};
export default config;

View File

@ -1,81 +0,0 @@
import replace from "@rollup/plugin-replace";
import type { StorybookConfig } from "@storybook/web-components-vite";
import { cwd } from "process";
import modify from "rollup-plugin-modify";
import postcssLit from "rollup-plugin-postcss-lit";
import tsconfigPaths from "vite-tsconfig-paths";
export const isProdBuild = process.env.NODE_ENV === "production";
export const apiBasePath = process.env.AK_API_BASE_PATH || "";
const importInlinePatterns = [
'import AKGlobal from "(\\.\\./)*common/styles/authentik\\.css',
'import AKGlobal from "@goauthentik/common/styles/authentik\\.css',
'import PF.+ from "@patternfly/patternfly/\\S+\\.css',
'import ThemeDark from "@goauthentik/common/styles/theme-dark\\.css',
'import OneDark from "@goauthentik/common/styles/one-dark\\.css',
'import styles from "\\./LibraryPageImpl\\.css',
];
const importInlineRegexp = new RegExp(importInlinePatterns.map((a) => `(${a})`).join("|"));
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-controls",
"@storybook/addon-links",
"@storybook/addon-essentials",
"storybook-addon-mock",
],
staticDirs: [
{
from: "../node_modules/@patternfly/patternfly/patternfly-base.css",
to: "@patternfly/patternfly/patternfly-base.css",
},
{
from: "../src/common/styles/authentik.css",
to: "@goauthentik/common/styles/authentik.css",
},
{
from: "../src/common/styles/theme-dark.css",
to: "@goauthentik/common/styles/theme-dark.css",
},
{
from: "../src/common/styles/one-dark.css",
to: "@goauthentik/common/styles/one-dark.css",
},
],
framework: {
name: "@storybook/web-components-vite",
options: {},
},
docs: {
autodocs: "tag",
},
async viteFinal(config) {
return {
...config,
plugins: [
modify({
find: importInlineRegexp,
replace: (match: RegExpMatchArray) => {
return `${match}?inline`;
},
}),
replace({
"process.env.NODE_ENV": JSON.stringify(
isProdBuild ? "production" : "development",
),
"process.env.CWD": JSON.stringify(cwd()),
"process.env.AK_API_BASE_PATH": JSON.stringify(apiBasePath),
"preventAssignment": true,
}),
...config.plugins,
postcssLit(),
tsconfigPaths(),
],
};
},
};
export default config;

38
web/.storybook/manager.js Normal file
View File

@ -0,0 +1,38 @@
/**
* @file Storybook manager configuration.
*
* @import { ThemeVarsPartial } from "storybook/internal/theming";
*/
import { createUIThemeEffect, resolveUITheme } from "@goauthentik/web/common/theme.ts";
import { addons } from "@storybook/manager-api";
import { create } from "@storybook/theming/create";
/**
* @satisfies {Partial<ThemeVarsPartial>}
*/
const baseTheme = {
brandTitle: "authentik Storybook",
brandUrl: "https://goauthentik.io",
brandImage: "https://goauthentik.io/img/icon_left_brand_colour.svg",
brandTarget: "_self",
};
const uiTheme = resolveUITheme();
addons.setConfig({
theme: create({
...baseTheme,
base: uiTheme,
}),
enableShortcuts: false,
});
createUIThemeEffect((nextUITheme) => {
addons.setConfig({
theme: create({
...baseTheme,
base: nextUITheme,
}),
enableShortcuts: false,
});
});

View File

@ -1,9 +0,0 @@
// .storybook/manager.js
import { addons } from "@storybook/manager-api";
import authentikTheme from "./authentikTheme";
addons.setConfig({
theme: authentikTheme,
enableShortcuts: false,
});

View File

@ -1,5 +1,3 @@
<link rel="stylesheet" href="@patternfly/patternfly/patternfly-base.css" />
<link rel="stylesheet" href="@goauthentik/common/styles/authentik.css" />
<style>
body {
overflow-y: scroll;

32
web/.storybook/preview.js Normal file
View File

@ -0,0 +1,32 @@
/// <reference types="../types/css.js" />
/**
* @file Storybook manager configuration.
*
* @import { Preview } from "@storybook/web-components";
*/
import { applyDocumentTheme } from "@goauthentik/web/common/theme.ts";
applyDocumentTheme();
/**
* @satisfies {Preview}
*/
const preview = {
parameters: {
options: {
storySort: {
method: "alphabetical",
},
},
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;

View File

@ -1,30 +0,0 @@
import type { Preview } from "@storybook/web-components";
import "@goauthentik/common/styles/authentik.css";
// import "@goauthentik/common/styles/theme-dark.css";
import "@patternfly/patternfly/components/Brand/brand.css";
import "@patternfly/patternfly/components/Page/page.css";
// .storybook/preview.js
import "@patternfly/patternfly/patternfly-base.css";
const preview: Preview = {
parameters: {
options: {
storySort: {
method: "alphabetical",
},
},
actions: { argTypesRegex: "^on[A-Z].*" },
cssUserPrefs: {
"prefers-color-scheme": "light",
},
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;

2771
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -36,7 +36,14 @@
"exports": {
"./package.json": "./package.json",
"./paths": "./paths.js",
"./scripts/*": "./scripts/*.mjs"
"./scripts/*": "./scripts/*.mjs",
"./elements/*": "./src/elements/*",
"./common/*": "./src/common/*",
"./components/*": "./src/components/*",
"./flow/*": "./src/flow/*",
"./locales/*": "./src/locales/*",
"./user/*": "./src/user/*",
"./admin/*": "./src/admin/*"
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
@ -49,7 +56,7 @@
"@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.4.0-1746018955",
"@goauthentik/api": "^2025.4.1-1747332783",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4",
@ -105,14 +112,14 @@
"@hcaptcha/types": "^1.0.4",
"@lit/localize-tools": "^0.8.0",
"@rollup/plugin-replace": "^6.0.1",
"@storybook/addon-essentials": "^8.3.4",
"@storybook/addon-links": "^8.3.4",
"@storybook/api": "^7.6.17",
"@storybook/blocks": "^8.3.4",
"@storybook/builder-vite": "^8.3.4",
"@storybook/manager-api": "^8.3.4",
"@storybook/web-components": "^8.3.4",
"@storybook/web-components-vite": "^8.3.4",
"@storybook/addon-essentials": "^8.6.12",
"@storybook/addon-links": "^8.6.12",
"@storybook/blocks": "^8.6.12",
"@storybook/experimental-addon-test": "^8.6.12",
"@storybook/manager-api": "^8.6.12",
"@storybook/test": "^8.6.12",
"@storybook/web-components": "^8.6.12",
"@storybook/web-components-vite": "^8.6.12",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/chart.js": "^2.9.41",
"@types/codemirror": "^5.60.15",
@ -144,9 +151,8 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"pseudolocale": "^2.1.0",
"rollup-plugin-modify": "^3.0.0",
"rollup-plugin-postcss-lit": "^2.1.0",
"storybook": "^8.3.4",
"storybook": "^8.6.12",
"storybook-addon-mock": "^5.0.0",
"turnstile-types": "^1.2.3",
"typescript": "^5.6.2",

View File

@ -4,13 +4,13 @@ import { ROUTES } from "@goauthentik/admin/Routes";
import {
EVENT_API_DRAWER_TOGGLE,
EVENT_NOTIFICATION_DRAWER_TOGGLE,
EVENT_SIDEBAR_TOGGLE,
} from "@goauthentik/common/constants";
import { configureSentry } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users";
import { WebsocketClient } from "@goauthentik/common/ws";
import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
import { SidebarToggleEventDetail } from "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
@ -26,7 +26,7 @@ import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { customElement, eventOptions, property, query } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -52,28 +52,33 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
//#region Properties
@property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
public notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@property({ type: Boolean })
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
public apiDrawerOpen = getURLParam("apiDrawerOpen", false);
ws: WebsocketClient;
protected readonly ws: WebsocketClient;
@state()
user?: SessionUser;
@property({
type: Object,
attribute: false,
reflect: false,
})
public user?: SessionUser;
@query("ak-about-modal")
aboutModal?: AboutModal;
public aboutModal?: AboutModal;
@property({ type: Boolean, reflect: true })
public sidebarOpen: boolean;
#toggleSidebar = () => {
this.sidebarOpen = !this.sidebarOpen;
};
@eventOptions({ passive: true })
protected sidebarListener(event: CustomEvent<SidebarToggleEventDetail>) {
this.sidebarOpen = !!event.detail.open;
}
#sidebarMatcher: MediaQueryList;
#sidebarListener = (event: MediaQueryListEvent) => {
#sidebarMediaQueryListener = (event: MediaQueryListEvent) => {
this.sidebarOpen = event.matches;
};
@ -81,50 +86,48 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
//#region Styles
static get styles(): CSSResult[] {
return [
PFBase,
PFPage,
PFButton,
PFDrawer,
PFNav,
css`
.pf-c-page__main,
.pf-c-drawer__content,
.pf-c-page__drawer {
z-index: auto !important;
background-color: transparent;
}
static styles: CSSResult[] = [
PFBase,
PFPage,
PFButton,
PFDrawer,
PFNav,
css`
.pf-c-page__main,
.pf-c-drawer__content,
.pf-c-page__drawer {
z-index: auto !important;
background-color: transparent;
}
.display-none {
display: none;
}
.display-none {
display: none;
}
.pf-c-page {
background-color: var(--pf-c-page--BackgroundColor) !important;
}
:host([theme="dark"]) {
/* Global page background colour */
.pf-c-page {
background-color: var(--pf-c-page--BackgroundColor) !important;
--pf-c-page--BackgroundColor: var(--ak-dark-background);
}
}
:host([theme="dark"]) {
/* Global page background colour */
.pf-c-page {
--pf-c-page--BackgroundColor: var(--ak-dark-background);
}
}
ak-page-navbar {
grid-area: header;
}
ak-page-navbar {
grid-area: header;
}
.ak-sidebar {
grid-area: nav;
}
.ak-sidebar {
grid-area: nav;
}
.pf-c-drawer__panel {
z-index: var(--pf-global--ZIndex--xl);
}
`,
];
}
.pf-c-drawer__panel {
z-index: var(--pf-global--ZIndex--xl);
}
`,
];
//#endregion
@ -141,8 +144,6 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
public connectedCallback() {
super.connectedCallback();
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({
@ -157,13 +158,14 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
});
});
this.#sidebarMatcher.addEventListener("change", this.#sidebarListener);
this.#sidebarMatcher.addEventListener("change", this.#sidebarMediaQueryListener, {
passive: true,
});
}
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
this.#sidebarMatcher.removeEventListener("change", this.#sidebarListener);
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
}
async firstUpdated(): Promise<void> {
@ -196,7 +198,7 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
return html` <ak-locale-context>
<div class="pf-c-page">
<ak-page-navbar>
<ak-page-navbar ?open=${this.sidebarOpen} @sidebar-toggle=${this.sidebarListener}>
<ak-version-banner></ak-version-banner>
<ak-enterprise-status interface="admin"></ak-enterprise-status>
</ak-page-navbar>

View File

@ -21,7 +21,7 @@ import { type LocalTypeCreate } from "./ProviderChoices.js";
@customElement("ak-application-wizard-provider-choice-step")
export class ApplicationWizardProviderChoiceStep extends WithLicenseSummary(ApplicationWizardStep) {
label = msg("Choose A Provider");
label = msg("Choose a Provider");
@state()
failureMessage = "";

View File

@ -45,9 +45,9 @@ const providerListArgs = (page: number, search = "") => ({
});
const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => {
const label = item.assignedBackchannelApplicationName
? item.assignedBackchannelApplicationName
: item.assignedApplicationName;
const label =
item.assignedBackchannelApplicationName || item.assignedApplicationName || item.name;
return [
`${item.pk}`,
html`<div class="selection-main">${label}</div>

View File

@ -15,7 +15,7 @@ import { DetailedCountry, GeoIPPolicy, PoliciesApi } from "@goauthentik/api";
import { countryCache } from "./CountryCache";
function countryToPair(country: DetailedCountry): DualSelectPair {
return [country.code, country.name];
return [country.code, country.name, country.name];
}
@customElement("ak-policy-geoip-form")
@ -210,17 +210,16 @@ export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
.getCountries()
.then((results) => {
if (!search) return results;
return results.filter((result) =>
result.name
.toLowerCase()
.includes(search.toLowerCase()),
);
})
.then((results) => {
return {
options: results.map(countryToPair),
};
});
.then((results) => ({
options: results.map(countryToPair),
}));
}}
.selected=${(this.instance?.countriesObj ?? []).map(countryToPair)}
available-label="${msg("Available Countries")}"

View File

@ -1,19 +1,14 @@
import {
CSRFHeaderName,
CSRFMiddleware,
EventMiddleware,
LoggingMiddleware,
} from "@goauthentik/common/api/middleware";
import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
} from "@goauthentik/common/api/middleware.js";
import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants.js";
import { globalAK } from "@goauthentik/common/global.js";
import { SentryMiddleware } from "@goauthentik/common/sentry";
import { Config, Configuration, CoreApi, CurrentBrand, RootApi } from "@goauthentik/api";
// HACK: Workaround for ESBuild not being able to hoist import statement across entrypoints.
// This can be removed after ESBuild uses a single build context for all entrypoints.
export { CSRFHeaderName };
let globalConfigPromise: Promise<Config> | undefined = Promise.resolve(globalAK().config);
export function config(): Promise<Config> {
if (!globalConfigPromise) {

View File

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

View File

@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2025.4.0";
export const VERSION = "2025.4.1";
export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";";
@ -11,7 +11,6 @@ export const EVENT_REFRESH = "ak-refresh";
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";
export const EVENT_API_DRAWER_TOGGLE = "ak-api-drawer-toggle";
export const EVENT_FLOW_INSPECTOR_TOGGLE = "ak-flow-inspector-toggle";
export const EVENT_SIDEBAR_TOGGLE = "ak-sidebar-toggle";
export const EVENT_WS_MESSAGE = "ak-ws-message";
export const EVENT_FLOW_ADVANCE = "ak-flow-advance";
export const EVENT_LOCALE_CHANGE = "ak-locale-change";

View File

@ -1,3 +1,12 @@
/**
* @file authentik base UI theme.
*/
/* Defined to better identify the base theme when debugging constructed stylesheets. */
.__AK_UI_BASE__ {
--__AK_UI_BASE__: 1;
}
/* #region Global */
:root {

View File

@ -1,42 +1,48 @@
/*
/**
* @file Atom One Dark syntax highlighting theme.
*
* @see https://github.com/atom/one-dark-syntax
*/
Atom One Dark by Daniel Gamage
Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax
/* Defined to better identify the One Dark theme when debugging constructed stylesheets. */
.__HIGHLIGHT_THEME_ONE_DARK__ {
--__HIGHLIGHT_THEME_ONE_DARK__: 1;
}
base: #282c34
mono-1: #abb2bf
mono-2: #818896
mono-3: #5c6370
hue-1: #56b6c2
hue-2: #61aeee
hue-3: #c678dd
hue-4: #98c379
hue-5: #e06c75
hue-5-2: #be5046
hue-6: #d19a66
hue-6-2: #e6c07b
*/
:root {
--one-dark-base: #282c34;
--one-dark-mono-1: #abb2bf;
--one-dark-mono-2: #818896;
--one-dark-mono-3: #5c6370;
--one-dark-hue-1: #56b6c2;
--one-dark-hue-2: #61aeee;
--one-dark-hue-3: #c678dd;
--one-dark-hue-4: #98c379;
--one-dark-hue-5: #e06c75;
--one-dark-hue-5-2: #be5046;
--one-dark-hue-6: #d19a66;
--one-dark-hue-6-2: #e6c07b;
}
.hljs {
color: #abb2bf;
background: #282c34;
color: var(--one-dark-mono-1);
background: var(--one-dark-base);
}
pre:has(.hljs) {
background: #282c34;
background: var(--one-dark-base);
}
.hljs-comment,
.hljs-quote {
color: #5c6370;
color: var(--one-dark-mono-3);
font-style: italic;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #c678dd;
color: var(--one-dark-hue-3);
}
.hljs-section,
@ -44,11 +50,11 @@ pre:has(.hljs) {
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #e06c75;
color: var(--one-dark-hue-5);
}
.hljs-literal {
color: #56b6c2;
color: var(--one-dark-hue-1);
}
.hljs-string,
@ -56,7 +62,7 @@ pre:has(.hljs) {
.hljs-addition,
.hljs-attribute,
.hljs-meta .hljs-string {
color: #98c379;
color: var(--one-dark-hue-4);
}
.hljs-attr,
@ -67,7 +73,7 @@ pre:has(.hljs) {
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #d19a66;
color: var(--one-dark-hue-6);
}
.hljs-symbol,
@ -76,13 +82,13 @@ pre:has(.hljs) {
.hljs-meta,
.hljs-selector-id,
.hljs-title {
color: #61aeee;
color: var(--one-dark-hue-2);
}
.hljs-built_in,
.hljs-title.class_,
.hljs-class .hljs-title {
color: #e6c07b;
color: var(--one-dark-hue-6-2);
}
.hljs-emphasis {

View File

@ -1,3 +1,12 @@
/**
* @file authentik dark UI theme.
*/
/* Defined to better identify the dark theme when debugging constructed stylesheets. */
.__AK_UI_DARK__ {
--__AK_UI_DARK__: 1;
}
/* #region Global */
:root {
@ -5,9 +14,6 @@
--ak-global--Color--100: var(--ak-dark-foreground) !important;
--pf-c-page__main-section--m-light--BackgroundColor: var(--ak-dark-background-darker);
--pf-global--BorderColor--100: var(--ak-dark-background-lighter) !important;
--ak-mermaid-message-text: var(--ak-dark-foreground) !important;
--ak-mermaid-box-background-color: var(--ak-dark-background-lighter) !important;
--ak-table-stripe-background: var(--pf-global--BackgroundColor--dark-200);
}
body {
@ -256,8 +262,13 @@ input[type="date"]::-webkit-calendar-picker-indicator {
color: var(--ak-dark-background-lighter);
}
.pf-c-button.pf-m-plain:hover {
color: var(--ak-dark-foreground);
.pf-c-button.pf-m-plain {
--pf-c-button--m-plain--focus--Color: var(--pf-global--Color--200);
--pf-c-button--m-plain--hover--Color: var(--ak-dark-foreground);
&:focus:hover {
color: var(--pf-c-button--m-plain--hover--Color);
}
}
.pf-c-button.pf-m-control {

View File

@ -1,17 +1,27 @@
/**
* @file Stylesheet utilities.
*/
import { CSSResult, CSSResultOrNative, ReactiveElement, css } from "lit";
import { CSSResultOrNative, ReactiveElement, adoptStyles as adoptStyleSheetsShim, css } from "lit";
/**
* Elements containing adoptable stylesheets.
* Element-like objects containing adoptable stylesheets.
*
* Note that while these all possess the `adoptedStyleSheets` property,
* browser differences and polyfills may make them not actually adoptable.
*
* This type exists to normalize the different ways of accessing the property.
*/
export type StyleSheetParent = Pick<DocumentOrShadowRoot, "adoptedStyleSheets">;
export type StyleRoot =
| Document
| ShadowRoot
| DocumentFragment
| HTMLElement
| DocumentOrShadowRoot;
/**
* Type-predicate to determine if a given object has adoptable stylesheets.
*/
export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheetParent {
export function isStyleRoot(input: StyleRoot): input is ShadowRoot {
// Sanity check - Does the input have the right shape?
if (!input || typeof input !== "object") return false;
@ -25,39 +35,12 @@ export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheet
// All we care about is that it's shaped like an array.
if (!("length" in input.adoptedStyleSheets)) return false;
if (typeof input.adoptedStyleSheets.length !== "number") return false;
// Finally is the array mutable?
return "push" in input.adoptedStyleSheets;
return typeof input.adoptedStyleSheets.length === "number";
}
/**
* Assert that the given input can adopt stylesheets.
*/
export function assertAdoptableStyleSheetParent<T>(
input: T,
): asserts input is T & StyleSheetParent {
if (isAdoptableStyleSheetParent(input)) return;
console.debug("Given input missing `adoptedStyleSheets`", input);
throw new TypeError("Assertion failed: `adoptedStyleSheets` missing in given input");
}
export function resolveStyleSheetParent<T extends HTMLElement | DocumentFragment | Document>(
renderRoot: T,
) {
const styleRoot = "ShadyDOM" in window ? document : renderRoot;
assertAdoptableStyleSheetParent(styleRoot);
return styleRoot;
}
export type StyleSheetInit = string | CSSResult | CSSStyleSheet;
/**
* Given a source of CSS, create a `CSSStyleSheet`.
* Create a lazy-loaded `CSSResult` compatible with Lit's
* element lifecycle.
*
* @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
*
@ -68,8 +51,12 @@ export type StyleSheetInit = string | CSSResult | CSSStyleSheet;
*
* It works well when Storybook is running in `dev`, but in `build` it fails.
* Storied components will have to map their textual CSS imports.
*
* @see {@linkcode createStyleSheetUnsafe} to create a `CSSStyleSheet` from the given input.
*/
export function createStyleSheet(input: string): CSSResult {
export function createCSSResult(input: string | CSSModule | CSSResultOrNative): CSSResultOrNative {
if (typeof input !== "string") return input;
const inputTemplate = [input] as unknown as TemplateStringsArray;
const result = css(inputTemplate, []);
@ -78,74 +65,91 @@ export function createStyleSheet(input: string): CSSResult {
}
/**
* Given a source of CSS, create a `CSSStyleSheet`.
* Create a `CSSStyleSheet` from the given input, if it is not already a `CSSStyleSheet`.
*
* @see {@linkcode createStyleSheet}
* @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
*
* @see {@linkcode createCSSResult} for the lazy-loaded `CSSResult` normalization.
*/
export function normalizeCSSSource(css: string): CSSStyleSheet;
export function normalizeCSSSource(styleSheet: CSSStyleSheet): CSSStyleSheet;
export function normalizeCSSSource(cssResult: CSSResult): CSSResult;
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative;
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative {
if (typeof input === "string") return createStyleSheet(input);
export function createStyleSheetUnsafe(
input: string | CSSModule | CSSResultOrNative,
): CSSStyleSheet {
const result = typeof input === "string" ? createCSSResult(input) : input;
return input;
}
/**
* Create a `CSSStyleSheet` from the given input.
*/
export function createStyleSheetUnsafe(input: StyleSheetInit): CSSStyleSheet {
const result = normalizeCSSSource(input);
if (result instanceof CSSStyleSheet) return result;
if (!result.styleSheet) {
console.debug(
"authentik/common/stylesheets: CSSResult missing styleSheet, returning empty",
{ result, input },
);
if (result.styleSheet) return result.styleSheet;
throw new TypeError("Expected a CSSStyleSheet");
}
const styleSheet = new CSSStyleSheet();
return result.styleSheet;
styleSheet.replaceSync(result.cssText);
return styleSheet;
}
export type StyleSheetsAction =
| Iterable<CSSStyleSheet>
| ((currentStyleSheets: CSSStyleSheet[]) => Iterable<CSSStyleSheet>);
/**
* Append stylesheet(s) to the given roots.
* Set the adopted stylesheets of a given style parent.
*
* @see {@linkcode removeStyleSheet} to remove a stylesheet from a given roots.
* ```ts
* setAdoptedStyleSheets(document.body, (currentStyleSheets) => [
* ...currentStyleSheets,
* myStyleSheet,
* ]);
* ```
*
* @remarks
* Replacing `adoptedStyleSheets` more than once in the same frame may result in
* the `currentStyleSheets` parameter being out of sync with the actual sheets.
*
* A style root's `adoptedStyleSheets` is a proxy object that only updates when
* DOM is repainted. We can't easily cache the previous entries since the style root
* may polyfilled via ShadyDOM.
*
* Short of using {@linkcode requestAnimationFrame} to sequence the adoption,
* and a visibility toggle to avoid a flash of styles between renders,
* we can't reliably cache the previous entries.
*
* In the meantime, we should try to apply all the sheets in a single frame.
*/
export function appendStyleSheet(
styleParent: StyleSheetParent,
...insertions: CSSStyleSheet[]
): void {
insertions = Array.isArray(insertions) ? insertions : [insertions];
export function setAdoptedStyleSheets(styleRoot: StyleRoot, styleSheets: StyleSheetsAction): void {
let changed = false;
for (const styleSheetInsertion of insertions) {
if (styleParent.adoptedStyleSheets.includes(styleSheetInsertion)) return;
const currentAdoptedStyleSheets = isStyleRoot(styleRoot)
? [...styleRoot.adoptedStyleSheets]
: [];
styleParent.adoptedStyleSheets = [...styleParent.adoptedStyleSheets, styleSheetInsertion];
const result =
typeof styleSheets === "function" ? styleSheets(currentAdoptedStyleSheets) : styleSheets;
const nextAdoptedStyleSheets: CSSStyleSheet[] = [];
for (const [idx, styleSheet] of Array.from(result).entries()) {
const previousStyleSheet = currentAdoptedStyleSheets[idx];
changed ||= previousStyleSheet !== styleSheet;
if (nextAdoptedStyleSheets.includes(styleSheet)) continue;
nextAdoptedStyleSheets.push(styleSheet);
}
changed ||= nextAdoptedStyleSheets.length !== currentAdoptedStyleSheets.length;
if (!changed) return;
if (styleRoot === document) {
document.adoptedStyleSheets = nextAdoptedStyleSheets;
return;
}
adoptStyleSheetsShim(styleRoot as unknown as ShadowRoot, nextAdoptedStyleSheets);
}
/**
* Remove a stylesheet from the given roots, matching by referential equality.
*
* @see {@linkcode appendStyleSheet} to append a stylesheet to a given roots.
*/
export function removeStyleSheet(
styleParent: StyleSheetParent,
...removals: CSSStyleSheet[]
): void {
const nextAdoptedStyleSheets = styleParent.adoptedStyleSheets.filter(
(styleSheet) => !removals.includes(styleSheet),
);
if (nextAdoptedStyleSheets.length === styleParent.adoptedStyleSheets.length) return;
styleParent.adoptedStyleSheets = nextAdoptedStyleSheets;
}
//#region Debugging
/**
* Serialize a stylesheet to a string.
@ -159,8 +163,8 @@ export function serializeStyleSheet(stylesheet: CSSStyleSheet): string {
/**
* Inspect the adopted stylesheets of a given style parent, serializing them to strings.
*/
export function inspectStyleSheets(styleParent: StyleSheetParent): string[] {
return styleParent.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
export function inspectStyleSheets(styleRoot: ShadowRoot): string[] {
return styleRoot.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
}
interface InspectedStyleSheetEntry {
@ -174,8 +178,11 @@ interface InspectedStyleSheetEntry {
* Recursively inspect the adopted stylesheets of a given style parent, serializing them to strings.
*/
export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleSheetEntry {
const styleParent = resolveStyleSheetParent(element.renderRoot);
const styles = inspectStyleSheets(styleParent);
if (!isStyleRoot(element.renderRoot)) {
throw new TypeError("Cannot inspect a render root that doesn't have adoptable stylesheets");
}
const styles = inspectStyleSheets(element.renderRoot);
const tagName = element.tagName.toLowerCase();
const treewalker = document.createTreeWalker(element.renderRoot, NodeFilter.SHOW_ELEMENT, {
@ -186,12 +193,14 @@ export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleS
return NodeFilter.FILTER_SKIP;
},
});
const children: InspectedStyleSheetEntry[] = [];
let currentNode: Node | null = treewalker.nextNode();
while (currentNode) {
const childElement = currentNode as ReactiveElement;
if (!isAdoptableStyleSheetParent(childElement.renderRoot)) {
if (!isStyleRoot(childElement.renderRoot)) {
currentNode = treewalker.nextNode();
continue;
}
@ -221,3 +230,5 @@ if (process.env.NODE_ENV === "development") {
inspectStyleSheets,
});
}
//#endregion

View File

@ -1,10 +1,47 @@
/**
* @file Theme utilities.
*/
import { UIConfig } from "@goauthentik/common/ui/config";
import {
type StyleRoot,
createStyleSheetUnsafe,
setAdoptedStyleSheets,
} from "@goauthentik/common/stylesheets.js";
import { UIConfig } from "@goauthentik/common/ui/config.js";
import AKBase from "@goauthentik/common/styles/authentik.css";
import AKBaseDark from "@goauthentik/common/styles/theme-dark.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
//#region Stylesheet Exports
/**
* A global style sheet for the Patternfly base styles.
*
* @remarks
*
* While a component *may* import its own instance of the PFBase style sheet,
* this instance ensures referential identity.
*/
export const $PFBase = createStyleSheetUnsafe(PFBase);
/**
* A global style sheet for the authentik base styles.
*
* @see {@linkcode $PFBase} for details.
*/
export const $AKBase = createStyleSheetUnsafe(AKBase);
/**
* A global style sheet for the authentik dark theme.
*
* @see {@linkcode $PFBase} for details.
*/
export const $AKBaseDark = createStyleSheetUnsafe(AKBaseDark);
//#endregion
//#region Scheme Types
/**
@ -134,15 +171,21 @@ export function resolveUITheme(
* Effect listener invoked when the color scheme changes.
*/
export type UIThemeListener = (currentUITheme: ResolvedUITheme) => void;
/**
* Create an effect that runs
* Effect destructor invoked when cleanup is required.
*/
export type UIThemeDestructor = () => void;
/**
* Create an effect that runs UI theme changes.
*
* @returns A cleanup function that removes the effect.
*/
export function createUIThemeEffect(
effect: UIThemeListener,
listenerOptions?: AddEventListenerOptions,
): () => void {
): UIThemeDestructor {
const colorSchemeTarget = resolveUITheme();
const invertedColorSchemeTarget = UIThemeInversion[colorSchemeTarget];
@ -174,6 +217,8 @@ export function createUIThemeEffect(
mediaQueryList.removeEventListener("change", changeListener);
};
listenerOptions?.signal?.addEventListener("abort", cleanup);
return cleanup;
}
@ -181,16 +226,96 @@ export function createUIThemeEffect(
//#region Theme Element
/**
* Applies the current UI theme to the given style root.
*
* @param styleRoot The style root to apply the theme to.
* @param currentUITheme The current UI theme to apply.
* @param additionalStyleSheets Additional style sheets to apply, in addition to the theme's base sheets.
* @category CSS
*
* @see {@linkcode setAdoptedStyleSheets} for caveats.
*/
export function applyUITheme(
styleRoot: StyleRoot,
currentUITheme: ResolvedUITheme = resolveUITheme(),
...additionalStyleSheets: Array<CSSStyleSheet | undefined | null>
): void {
setAdoptedStyleSheets(styleRoot, (currentStyleSheets) => {
const appendedSheets = additionalStyleSheets.filter(Boolean) as CSSStyleSheet[];
if (currentUITheme === UiThemeEnum.Dark) {
return [...currentStyleSheets, $AKBaseDark, ...appendedSheets];
}
return [
...currentStyleSheets.filter((styleSheet) => styleSheet !== $AKBaseDark),
...appendedSheets,
];
});
}
/**
* Applies the given theme to the document, i.e. the `<html>` element.
*
* @param hint The color scheme hint to use.
*/
export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "auto"): void {
const preferredColorScheme = formatColorScheme(hint);
const applyStyleSheets: UIThemeListener = (currentUITheme) => {
console.debug(`authentik/theme (document): switching to ${currentUITheme} theme`);
setAdoptedStyleSheets(document, (currentStyleSheets) => {
if (currentUITheme === "dark") {
return [...currentStyleSheets, $PFBase, $AKBase, $AKBaseDark];
}
return [
...currentStyleSheets.filter((styleSheet) => styleSheet !== $AKBaseDark),
$PFBase,
$AKBase,
];
});
document.documentElement.dataset.theme = currentUITheme;
};
if (preferredColorScheme === "auto") {
createUIThemeEffect(applyStyleSheets);
return;
}
applyStyleSheets(preferredColorScheme);
}
/**
* An element that can be themed.
*/
export interface ThemedElement extends HTMLElement {
brand?: CurrentBrand;
uiConfig?: UIConfig;
config?: Config;
/**
* The brand information for the current theme.
*/
readonly brand?: CurrentBrand;
/**
* The UI configuration for the current theme,
* typically injected through a Lit Mixin.
*
* @see {@linkcode UIConfig} for details.
*/
readonly uiConfig?: UIConfig;
/**
* An authentik configuration initially provided by the server.
*/
readonly config?: Config;
activeTheme: ResolvedUITheme;
}
/**
* Returns the root interface element of the page.
*
* @todo Can this be handled with a Lit Mixin?
*/
export function rootInterface<T extends ThemedElement = ThemedElement>(): T | null {
const element = document.body.querySelector<T>("[data-ak-interface-root]");

View File

@ -1,5 +1,5 @@
import { me } from "@goauthentik/common/users";
import { isUserRoute } from "@goauthentik/elements/router/utils";
import { me } from "@goauthentik/common/users.js";
import { isUserRoute } from "@goauthentik/elements/router/utils.js";
import { UiThemeEnum, UserSelf } from "@goauthentik/api";
import { CurrentBrand } from "@goauthentik/api";

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config.js";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants.js";
import { isResponseErrorLike } from "@goauthentik/common/errors/network.js";
import { CoreApi, SessionUser } from "@goauthentik/api";

View File

@ -31,18 +31,19 @@ const container = (testItem: TemplateResult) =>
li {
display: block;
}
p {
color: black;
margin-top: 1em;
ak-hint {
--ak-hint--Color: var(--pf-global--Color--dark-100);
}
* {
--ak-hint--Color: black !important;
@media (prefers-color-scheme: dark) {
ak-hint {
--ak-hint--Color: var(--pf-global--Color--light-100);
}
}
ak-hint-title::part(ak-hint-title),
ak-hint-footer::part(ak-hint-footer),
slotted::(*) {
color: black;
p {
margin-top: 1em;
}
</style>

View File

@ -2,7 +2,7 @@ import { AKElement } from "@goauthentik/elements/Base";
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
import { spread } from "@open-wc/lit-helpers";
import { html, nothing } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
@ -64,7 +64,15 @@ export class Alert extends AKElement implements IAlert {
icon = "fa-exclamation-circle";
static get styles() {
return [PFBase, PFAlert];
return [
PFBase,
PFAlert,
css`
p {
margin: 0;
}
`,
];
}
get classmap() {

View File

@ -1,30 +1,24 @@
import { globalAK } from "@goauthentik/common/global";
import { globalAK } from "@goauthentik/common/global.js";
import {
StyleSheetInit,
StyleSheetParent,
appendStyleSheet,
StyleRoot,
createCSSResult,
createStyleSheetUnsafe,
removeStyleSheet,
resolveStyleSheetParent,
} from "@goauthentik/common/stylesheets";
} from "@goauthentik/common/stylesheets.js";
import {
$AKBase,
CSSColorSchemeValue,
ResolvedUITheme,
UIThemeListener,
ThemedElement,
applyUITheme,
createUIThemeEffect,
formatColorScheme,
resolveUITheme,
} from "@goauthentik/common/theme";
import { type ThemedElement } from "@goauthentik/common/theme";
} from "@goauthentik/common/theme.js";
import { localized } from "@lit/localize";
import { CSSResultGroup, CSSResultOrNative, LitElement } from "lit";
import { CSSResult, CSSResultGroup, CSSResultOrNative, LitElement } from "lit";
import { property } from "lit/decorators.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import OneDark from "@goauthentik/common/styles/one-dark.css";
import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
import { UiThemeEnum } from "@goauthentik/api";
// Re-export the theme helpers
@ -32,6 +26,58 @@ export { rootInterface } from "@goauthentik/common/theme";
@localized()
export class AKElement extends LitElement implements ThemedElement {
//#region Static Properties
public static styles?: Array<CSSResult | CSSModule>;
protected static override finalizeStyles(styles?: CSSResultGroup): CSSResultOrNative[] {
if (!styles) return [$AKBase];
if (!Array.isArray(styles)) return [createCSSResult(styles), $AKBase];
return [
// ---
...(styles.flat() as CSSResultOrNative[]).map(createCSSResult),
$AKBase,
];
}
//#endregion
//#region Lifecycle
constructor() {
super();
const { brand } = globalAK();
this.preferredColorScheme = formatColorScheme(brand.uiTheme);
this.activeTheme = resolveUITheme(brand?.uiTheme);
this.#customCSSStyleSheet = brand?.brandingCustomCss
? createStyleSheetUnsafe(brand.brandingCustomCss)
: null;
}
public override disconnectedCallback(): void {
this.#themeAbortController?.abort();
super.disconnectedCallback();
}
/**
* Returns the node into which the element should render.
*
* @see {LitElement.createRenderRoot} for more information.
*/
protected override createRenderRoot(): HTMLElement | DocumentFragment {
const renderRoot = super.createRenderRoot();
this.styleRoot ??= renderRoot;
return renderRoot;
}
//#endregion
//#region Properties
/**
@ -53,87 +99,54 @@ export class AKElement extends LitElement implements ThemedElement {
//#region Private Properties
readonly #preferredColorScheme: CSSColorSchemeValue;
/**
* The preferred color scheme used to look up the UI theme.
*/
protected readonly preferredColorScheme: CSSColorSchemeValue;
#customCSSStyleSheet: CSSStyleSheet | null;
#darkThemeStyleSheet: CSSStyleSheet | null = null;
/**
* A custom CSS style sheet to apply to the element.
*/
readonly #customCSSStyleSheet: CSSStyleSheet | null;
/**
* A controller to abort theme updates, such as when the element is disconnected.
*/
#themeAbortController: AbortController | null = null;
/**
* The style root to which the theme is applied.
*/
#styleRoot?: StyleRoot;
//#endregion
//#region Lifecycle
protected static finalizeStyles(styles?: CSSResultGroup): CSSResultOrNative[] {
// Ensure all style sheets being passed are really style sheets.
const baseStyles: StyleSheetInit[] = [AKGlobal, OneDark];
if (!styles) return baseStyles.map(createStyleSheetUnsafe);
if (Array.isArray(styles)) {
return [
//---
...(styles as unknown as CSSResultOrNative[]),
...baseStyles,
].flatMap(createStyleSheetUnsafe);
}
return [styles, ...baseStyles].map(createStyleSheetUnsafe);
}
constructor() {
super();
const { brand } = globalAK();
this.#preferredColorScheme = formatColorScheme(brand.uiTheme);
this.activeTheme = resolveUITheme(brand?.uiTheme);
this.#customCSSStyleSheet = brand?.brandingCustomCss
? createStyleSheetUnsafe(brand.brandingCustomCss)
: null;
}
public disconnectedCallback(): void {
super.disconnectedCallback();
protected set styleRoot(nextStyleRoot: StyleRoot | undefined) {
this.#themeAbortController?.abort();
}
#styleRoot?: StyleSheetParent;
this.#styleRoot = nextStyleRoot;
#dispatchTheme: UIThemeListener = (nextUITheme) => {
if (!this.#styleRoot) return;
if (nextUITheme === UiThemeEnum.Dark) {
this.#darkThemeStyleSheet ||= createStyleSheetUnsafe(ThemeDark);
appendStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet);
this.activeTheme = UiThemeEnum.Dark;
} else if (this.#darkThemeStyleSheet) {
removeStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet);
this.#darkThemeStyleSheet = null;
this.activeTheme = UiThemeEnum.Light;
}
};
protected createRenderRoot(): HTMLElement | DocumentFragment {
const renderRoot = super.createRenderRoot();
this.#styleRoot = resolveStyleSheetParent(renderRoot);
if (this.#customCSSStyleSheet) {
console.debug(`authentik/element[${this.tagName.toLowerCase()}]: Adding custom CSS`);
appendStyleSheet(this.#styleRoot, this.#customCSSStyleSheet);
}
if (!nextStyleRoot) return;
this.#themeAbortController = new AbortController();
if (this.#preferredColorScheme === "dark") {
this.#dispatchTheme(UiThemeEnum.Dark);
} else if (this.#preferredColorScheme === "auto") {
createUIThemeEffect(this.#dispatchTheme, {
signal: this.#themeAbortController.signal,
});
}
if (this.preferredColorScheme === "dark") {
applyUITheme(nextStyleRoot, UiThemeEnum.Dark, this.#customCSSStyleSheet);
return renderRoot;
this.activeTheme = UiThemeEnum.Dark;
} else if (this.preferredColorScheme === "auto") {
createUIThemeEffect(
(nextUITheme) => {
applyUITheme(nextStyleRoot, nextUITheme, this.#customCSSStyleSheet);
this.activeTheme = nextUITheme;
},
{
signal: this.#themeAbortController.signal,
},
);
}
}
protected get styleRoot(): StyleRoot | undefined {
return this.#styleRoot;
}
//#endregion

View File

@ -1,45 +1,45 @@
import {
appendStyleSheet,
createStyleSheetUnsafe,
resolveStyleSheetParent,
} from "@goauthentik/common/stylesheets";
import { ThemedElement } from "@goauthentik/common/theme";
import { UIConfig } from "@goauthentik/common/ui/config";
import { AKElement } from "@goauthentik/elements/Base";
import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController";
import { globalAK } from "@goauthentik/common/global.js";
import { ThemedElement, applyDocumentTheme } from "@goauthentik/common/theme.js";
import { UIConfig } from "@goauthentik/common/ui/config.js";
import { AKElement } from "@goauthentik/elements/Base.js";
import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController.js";
import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
import { state } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api";
import {
type Config,
type CurrentBrand,
type LicenseSummary,
type Version,
} from "@goauthentik/api";
import { BrandContextController } from "./BrandContextController";
import { ConfigContextController } from "./ConfigContextController";
import { EnterpriseContextController } from "./EnterpriseContextController";
import { BrandContextController } from "./BrandContextController.js";
import { ConfigContextController } from "./ConfigContextController.js";
import { EnterpriseContextController } from "./EnterpriseContextController.js";
const configContext = Symbol("configContext");
const modalController = Symbol("modalController");
const versionContext = Symbol("versionContext");
export abstract class LightInterface extends AKElement implements ThemedElement {
protected static readonly PFBaseStyleSheet = createStyleSheetUnsafe(PFBase);
constructor() {
super();
const styleParent = resolveStyleSheetParent(document);
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
appendStyleSheet(styleParent, Interface.PFBaseStyleSheet);
if (!document.documentElement.dataset.theme) {
applyDocumentTheme(globalAK().brand.uiTheme);
}
}
}
export abstract class Interface extends LightInterface implements ThemedElement {
[configContext]: ConfigContextController;
static styles = [PFBase];
protected [configContext]: ConfigContextController;
[modalController]: ModalOrchestrationController;
protected [modalController]: ModalOrchestrationController;
@state()
public config?: Config;
@ -49,6 +49,7 @@ export abstract class Interface extends LightInterface implements ThemedElement
constructor() {
super();
this.addController(new BrandContextController(this));
this[configContext] = new ConfigContextController(this);
this[modalController] = new ModalOrchestrationController(this);

View File

@ -1,8 +1,4 @@
import {
EVENT_SIDEBAR_TOGGLE,
EVENT_WS_MESSAGE,
TITLE_DEFAULT,
} from "@goauthentik/common/constants";
import { EVENT_WS_MESSAGE, TITLE_DEFAULT } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global";
import { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config";
import { DefaultBrand } from "@goauthentik/common/ui/config";
@ -29,6 +25,14 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { SessionUser } from "@goauthentik/api";
//#region Events
export interface SidebarToggleEventDetail {
open?: boolean;
}
//#endregion
//#region Page Navbar
export interface PageNavbarDetails {
@ -45,7 +49,10 @@ export interface PageNavbarDetails {
* dispatched by the `ak-page-header` component.
*/
@customElement("ak-page-navbar")
export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails {
export class AKPageNavbar
extends WithBrandConfig(AKElement)
implements PageNavbarDetails, SidebarToggleEventDetail
{
//#region Static Properties
private static elementRef: AKPageNavbar | null = null;
@ -260,29 +267,31 @@ export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavb
//#region Properties
@property({ type: String })
@state()
icon?: string;
@property({ type: Boolean })
@state()
iconImage = false;
@property({ type: String })
@state()
header?: string;
@property({ type: String })
@state()
description?: string;
@property({ type: Boolean })
@state()
hasIcon = true;
@property({ type: Boolean })
open = true;
@property({
type: Boolean,
})
public open?: boolean;
@state()
session?: SessionUser;
protected session?: SessionUser;
@state()
uiConfig!: UIConfig;
protected uiConfig!: UIConfig;
//#endregion
@ -305,9 +314,10 @@ export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavb
this.open = !this.open;
this.dispatchEvent(
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
new CustomEvent<SidebarToggleEventDetail>("sidebar-toggle", {
bubbles: true,
composed: true,
detail: { open: this.open },
}),
);
}

View File

@ -12,7 +12,6 @@ import type { DualSelectPair } from "./types.js";
* A top-level component for multi-select elements have dynamically generated "selected"
* lists.
*/
@customElement("ak-dual-select-dynamic-selected")
export class AkDualSelectDynamic extends AkDualSelectProvider {
/**
@ -23,20 +22,24 @@ export class AkDualSelectDynamic extends AkDualSelectProvider {
* @attr
*/
@property({ attribute: false })
selector: (_: DualSelectPair[]) => Promise<DualSelectPair[]> = async (_) => Promise.resolve([]);
selector: (_: DualSelectPair[]) => Promise<DualSelectPair[]> = () => Promise.resolve([]);
private firstUpdateHasRun = false;
#didFirstUpdate = false;
willUpdate(changed: PropertyValues<this>) {
super.willUpdate(changed);
// On the first update *only*, even before rendering, when the options are handed up, update
// the selected list with the contents derived from the selector.
if (!this.firstUpdateHasRun && this.options.length > 0) {
this.firstUpdateHasRun = true;
this.selector(this.options).then((selected) => {
this.selected = selected;
});
}
if (this.#didFirstUpdate) return;
if (this.options.length === 0) return;
this.#didFirstUpdate = true;
this.selector(this.options).then((selected) => {
this.selected = selected;
});
}
render() {

View File

@ -1,18 +1,16 @@
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
import { debounce } from "@goauthentik/elements/utils/debounce";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter.js";
import { msg } from "@lit/localize";
import { PropertyValues, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js";
import type { Pagination } from "@goauthentik/api";
import "./ak-dual-select";
import { AkDualSelect } from "./ak-dual-select";
import type { DataProvider, DualSelectPair } from "./types";
import "./ak-dual-select.js";
import { AkDualSelect } from "./ak-dual-select.js";
import { type DataProvider, DualSelectEventType, type DualSelectPair } from "./types.js";
/**
* @element ak-dual-select-provider
@ -22,18 +20,19 @@ import type { DataProvider, DualSelectPair } from "./types";
* between authentik and the generic ak-dual-select component; aside from knowing that
* the Pagination object "looks like Django," the interior components don't know anything
* about authentik at all and could be dropped into Gravity unchanged.)
*
*/
@customElement("ak-dual-select-provider")
export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) {
/** A function that takes a page and returns the DualSelectPair[] collection with which to update
* the "Available" pane.
//#region Properties
/**
* A function that takes a page and returns the {@linkcode DualSelectPair DualSelectPair[]}
* collection with which to update the "Available" pane.
*
* @attr
*/
@property({ type: Object })
provider!: DataProvider;
public provider!: DataProvider;
/**
* The list of selected items. This is the *complete* list, not paginated, as presented by a
@ -42,7 +41,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
* @attr
*/
@property({ type: Array })
selected: DualSelectPair[] = [];
public selected: DualSelectPair[] = [];
/**
* The label for the left ("available") pane
@ -50,7 +49,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
* @attr
*/
@property({ attribute: "available-label" })
availableLabel = msg("Available options");
public availableLabel = msg("Available options");
/**
* The label for the right ("selected") pane
@ -58,7 +57,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
* @attr
*/
@property({ attribute: "selected-label" })
selectedLabel = msg("Selected options");
public selectedLabel = msg("Selected options");
/**
* The debounce for the search as the user is typing in a request
@ -66,103 +65,125 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
* @attr
*/
@property({ attribute: "search-delay", type: Number })
searchDelay = 250;
public searchDelay = 250;
public get value() {
return this.dualSelector.value!.selected.map(([k, _]) => k);
}
public json() {
return this.value;
}
//#endregion
//#region State
@state()
options: DualSelectPair[] = [];
protected options: DualSelectPair[] = [];
protected dualSelector: Ref<AkDualSelect> = createRef();
#loading = false;
protected isLoading = false;
#didFirstUpdate = false;
#selected: DualSelectPair[] = [];
private doneFirstUpdate = false;
private internalSelected: DualSelectPair[] = [];
#previousSearchValue = "";
protected pagination?: Pagination;
constructor() {
super();
setTimeout(() => this.fetch(1), 0);
this.onNav = this.onNav.bind(this);
this.onChange = this.onChange.bind(this);
this.onSearch = this.onSearch.bind(this);
this.addCustomListener("ak-pagination-nav-to", this.onNav);
this.addCustomListener("ak-dual-select-change", this.onChange);
this.addCustomListener("ak-dual-select-search", this.onSearch);
//#endregion
//#region Refs
protected dualSelector = createRef<AkDualSelect>();
//#endregion
//#region Lifecycle
public connectedCallback(): void {
super.connectedCallback();
this.addCustomListener(DualSelectEventType.NavigateTo, this.#navigationListener);
this.addCustomListener(DualSelectEventType.Change, this.#changeListener);
this.addCustomListener(DualSelectEventType.Search, this.#searchListener);
this.#fetch(1);
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("selected") && !this.doneFirstUpdate) {
this.doneFirstUpdate = true;
this.internalSelected = this.selected;
}
if (changedProperties.has("searchDelay")) {
this.doSearch = debounce(
AkDualSelectProvider.prototype.doSearch.bind(this),
this.searchDelay,
);
if (changedProperties.has("selected") && !this.#didFirstUpdate) {
this.#didFirstUpdate = true;
this.#selected = this.selected;
}
if (changedProperties.has("provider")) {
this.pagination = undefined;
this.fetch();
this.#previousSearchValue = "";
this.#fetch();
}
}
async fetch(page?: number, search = "") {
if (this.isLoading) {
return;
}
this.isLoading = true;
const goto = page ?? this.pagination?.current ?? 1;
const data = await this.provider(goto, search);
this.pagination = data.pagination;
this.options = data.options;
this.isLoading = false;
}
//#endregion
onNav(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`);
}
this.fetch(event.detail);
}
//#region Private Methods
onChange(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.internalSelected = event.detail.value;
this.selected = this.internalSelected;
}
#fetch = async (page?: number, search = this.#previousSearchValue): Promise<void> => {
if (this.#loading) return;
onSearch(event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.doSearch(event.detail);
}
this.#previousSearchValue = search;
this.#loading = true;
doSearch(search: string) {
this.pagination = undefined;
this.fetch(undefined, search);
}
page ??= this.pagination?.current ?? 1;
get value() {
return this.dualSelector.value!.selected.map(([k, _]) => k);
}
return this.provider(page, search)
.then((data) => {
this.pagination = data.pagination;
this.options = data.options;
})
.catch((error) => {
console.error(error);
})
.finally(() => {
this.#loading = false;
});
};
json() {
return this.value;
}
//#endregion
//#region Event Listeners
#navigationListener = (event: CustomEvent<number>) => {
this.#fetch(event.detail, this.#previousSearchValue);
};
#changeListener = (event: CustomEvent<{ value: DualSelectPair[] }>) => {
this.#selected = event.detail.value;
this.selected = this.#selected;
};
#searchListener = (event: CustomEvent<string>) => {
this.#doSearch(event.detail);
};
#searchTimeoutID?: ReturnType<typeof setTimeout>;
#doSearch = (search: string) => {
clearTimeout(this.#searchTimeoutID);
setTimeout(() => {
this.pagination = undefined;
this.#fetch(undefined, search);
}, this.searchDelay);
};
//#endregion
render() {
return html`<ak-dual-select
${ref(this.dualSelector)}
.options=${this.options}
.pages=${this.pagination}
.selected=${this.internalSelected}
.selected=${this.#selected}
available-label=${this.availableLabel}
selected-label=${this.selectedLabel}
></ak-dual-select>`;

View File

@ -3,6 +3,7 @@ import {
CustomEmitterElement,
CustomListenerElement,
} from "@goauthentik/elements/utils/eventEmitter";
import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { PropertyValues, html, nothing } from "lit";
@ -15,34 +16,41 @@ import { globalVariables, mainStyles } from "./components/styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./components/ak-dual-select-available-pane";
import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane";
import "./components/ak-dual-select-controls";
import "./components/ak-dual-select-selected-pane";
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane";
import "./components/ak-pagination";
import "./components/ak-search-bar";
import "./components/ak-dual-select-available-pane.js";
import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane.js";
import "./components/ak-dual-select-controls.js";
import "./components/ak-dual-select-selected-pane.js";
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane.js";
import "./components/ak-pagination.js";
import "./components/ak-search-bar.js";
import {
EVENT_ADD_ALL,
EVENT_ADD_ONE,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_ONE,
EVENT_REMOVE_SELECTED,
} from "./constants";
import type { BasePagination, DualSelectPair, SearchbarEvent } from "./types";
BasePagination,
DualSelectEventType,
DualSelectPair,
SearchbarEventDetail,
SearchbarEventSource,
} from "./types.js";
function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) {
const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2];
return l < r ? -1 : l > r ? 1 : 0;
function localeComparator(a: DualSelectPair, b: DualSelectPair) {
const aSortBy = a[2];
const bSortBy = b[2];
return aSortBy.localeCompare(bSortBy);
}
function mapDualPairs(pairs: DualSelectPair[]) {
return new Map(pairs.map(([k, v, _]) => [k, v]));
function keyfinder(key: string) {
return ([k]: DualSelectPair) => k === key;
}
const styles = [PFBase, PFButton, globalVariables, mainStyles];
const DelegatedEvents = [
DualSelectEventType.AddSelected,
DualSelectEventType.RemoveSelected,
DualSelectEventType.AddAll,
DualSelectEventType.RemoveAll,
DualSelectEventType.DeleteAll,
DualSelectEventType.AddOne,
DualSelectEventType.RemoveOne,
] as const satisfies DualSelectEventType[];
/**
* @element ak-dual-select
@ -53,24 +61,25 @@ const styles = [PFBase, PFButton, globalVariables, mainStyles];
*
* @fires ak-dual-select-change - A custom change event with the current `selected` list.
*/
const keyfinder =
(key: string) =>
([k]: DualSelectPair) =>
k === key;
@customElement("ak-dual-select")
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
static get styles() {
return styles;
}
static styles = [PFBase, PFButton, globalVariables, mainStyles];
/* The list of options to *currently* show. Note that this is not *all* the options, only the
* currently shown list of options from a pagination collection. */
//#region Properties
/**
* The list of options to *currently* show.
*
* Note that this is not *all* the options,
* only the currently shown list of options from a pagination collection.
*/
@property({ type: Array })
options: DualSelectPair[] = [];
/* The list of options selected. This is the *entire* list and will not be paginated. */
/**
* The list of options selected.
* This is the *entire* list and will not be paginated.
*/
@property({ type: Array })
selected: DualSelectPair[] = [];
@ -83,138 +92,133 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
@property({ attribute: "selected-label" })
selectedLabel = msg("Selected options");
//#endregion
//#region State
@state()
selectedFilter: string = "";
protected selectedFilter: string = "";
#selectedKeys: Set<string> = new Set();
//#endregion
//#region Refs
availablePane: Ref<AkDualSelectAvailablePane> = createRef();
selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
selectedKeys: Set<string> = new Set();
//#endregion
//#region Lifecycle
constructor() {
super();
this.handleMove = this.handleMove.bind(this);
this.handleSearch = this.handleSearch.bind(this);
[
EVENT_ADD_ALL,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_SELECTED,
EVENT_ADD_ONE,
EVENT_REMOVE_ONE,
].forEach((eventName: string) => {
this.addCustomListener(eventName, (event: Event) => this.handleMove(eventName, event));
});
for (const eventName of DelegatedEvents) {
this.addCustomListener(eventName, this.#moveListener);
}
this.addCustomListener("ak-dual-select-move", () => {
this.requestUpdate();
});
this.addCustomListener("ak-search", this.handleSearch);
this.addCustomListener("ak-search", this.#searchListener);
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("selected")) {
this.selectedKeys = new Set(this.selected.map(([key, _]) => key));
this.#selectedKeys = new Set(this.selected.map(([key]) => key));
}
// Pagination invalidates available moveables.
if (changedProperties.has("options") && this.availablePane.value) {
this.availablePane.value.clearMove();
}
}
handleMove(eventName: string, event: Event) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expected move event here, got ${eventName}`);
}
//#endregion
switch (eventName) {
case EVENT_ADD_SELECTED: {
this.addSelected();
break;
}
case EVENT_REMOVE_SELECTED: {
this.removeSelected();
break;
}
case EVENT_ADD_ALL: {
this.addAllVisible();
break;
}
case EVENT_REMOVE_ALL: {
this.removeAllVisible();
break;
}
case EVENT_DELETE_ALL: {
this.removeAll();
break;
}
case EVENT_ADD_ONE: {
this.addOne(event.detail);
break;
}
case EVENT_REMOVE_ONE: {
this.removeOne(event.detail);
break;
}
//#region Event Listeners
#moveListener = (event: CustomEvent<string>) => {
match(event.type)
.with(DualSelectEventType.AddSelected, () => this.addSelected())
.with(DualSelectEventType.RemoveSelected, () => this.removeSelected())
.with(DualSelectEventType.AddAll, () => this.addAllVisible())
.with(DualSelectEventType.RemoveAll, () => this.removeAllVisible())
.with(DualSelectEventType.DeleteAll, () => this.removeAll())
.with(DualSelectEventType.AddOne, () => this.addOne(event.detail))
.with(DualSelectEventType.RemoveOne, () => this.removeOne(event.detail))
.otherwise(() => {
throw new Error(`Expected move event here, got ${event.type}`);
});
this.dispatchCustomEvent(DualSelectEventType.Change, { value: this.value });
default:
throw new Error(
`AkDualSelect.handleMove received unknown event type: ${eventName}`,
);
}
this.dispatchCustomEvent("ak-dual-select-change", { value: this.value });
event.stopPropagation();
}
};
protected addSelected() {
if (this.availablePane.value!.moveable.length === 0) return;
addSelected() {
if (this.availablePane.value!.moveable.length === 0) {
return;
}
this.selected = this.availablePane.value!.moveable.reduce(
(acc, key) => {
const value = this.options.find(keyfinder(key));
return value && !acc.find(keyfinder(value[0])) ? [...acc, value] : acc;
},
[...this.selected],
);
// This is where the information gets... lossy. Dammit.
this.availablePane.value!.clearMove();
}
addOne(key: string) {
protected addOne(key: string) {
const requested = this.options.find(keyfinder(key));
if (requested && !this.selected.find(keyfinder(requested[0]))) {
this.selected = [...this.selected, requested];
}
if (!requested) return;
if (this.selected.find(keyfinder(requested[0]))) return;
this.selected = [...this.selected, requested];
}
// These are the *currently visible* options; the parent node is responsible for paginating and
// updating the list of currently visible options;
addAllVisible() {
protected addAllVisible() {
// Create a new array of all current options and selected, and de-dupe.
const selected = mapDualPairs([...this.options, ...this.selected]);
this.selected = Array.from(selected.entries());
const selected = new Map<string, DualSelectPair>([
...this.options.map((pair) => [pair[0], pair] as const),
...this.selected.map((pair) => [pair[0], pair] as const),
]);
this.selected = Array.from(selected.values());
this.availablePane.value!.clearMove();
}
removeSelected() {
if (this.selectedPane.value!.moveable.length === 0) {
return;
}
protected removeSelected() {
if (this.selectedPane.value!.moveable.length === 0) return;
const deselected = new Set(this.selectedPane.value!.moveable);
this.selected = this.selected.filter(([key]) => !deselected.has(key));
this.selectedPane.value!.clearMove();
}
removeOne(key: string) {
protected removeOne(key: string) {
this.selected = this.selected.filter(([k]) => k !== key);
}
removeAllVisible() {
protected removeAllVisible() {
// Remove all the items from selected that are in the *currently visible* options list
const options = new Set(this.options.map(([k, _]) => k));
const options = new Set(this.options.map(([k]) => k));
this.selected = this.selected.filter(([k]) => !options.has(k));
this.selectedPane.value!.clearMove();
}
@ -223,24 +227,25 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
this.selectedPane.value!.clearMove();
}
handleSearch(event: SearchbarEvent) {
switch (event.detail.source) {
case "ak-dual-list-available-search":
return this.handleAvailableSearch(event.detail.value);
case "ak-dual-list-selected-search":
return this.handleSelectedSearch(event.detail.value);
}
#searchListener = (event: CustomEvent<SearchbarEventDetail>) => {
const { source, value } = event.detail;
match(source)
.with(SearchbarEventSource.Available, () => {
this.dispatchCustomEvent(DualSelectEventType.Search, value);
})
.with(SearchbarEventSource.Selected, () => {
this.selectedFilter = value;
this.selectedPane.value!.clearMove();
})
.exhaustive();
event.stopPropagation();
}
};
handleAvailableSearch(value: string) {
this.dispatchCustomEvent("ak-dual-select-search", value);
}
//#endregion
handleSelectedSearch(value: string) {
this.selectedFilter = value;
this.selectedPane.value!.clearMove();
}
//#region Public Getters
get value() {
return this.selected;
@ -251,7 +256,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
// added.
const allMoved =
this.options.length ===
this.options.filter(([key, _]) => this.selectedKeys.has(key)).length;
this.options.filter(([key, _]) => this.#selectedKeys.has(key)).length;
return this.options.length > 0 && !allMoved;
}
@ -259,7 +264,8 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
get canRemoveAll() {
// False if no visible option can be found in the selected list
return (
this.options.length > 0 && !!this.options.find(([key, _]) => this.selectedKeys.has(key))
this.options.length > 0 &&
!!this.options.find(([key, _]) => this.#selectedKeys.has(key))
);
}
@ -267,6 +273,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
return (this.pages?.next ?? 0) > 0 || (this.pages?.previous ?? 0) > 0;
}
//#endregion
//#region Render
render() {
const selected =
this.selectedFilter === ""
@ -282,11 +292,15 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
const availableCount = this.availablePane.value?.toMove.size ?? 0;
const selectedCount = this.selectedPane.value?.toMove.size ?? 0;
const selectedTotal = selected.length;
const availableStatus =
availableCount > 0 ? msg(str`${availableCount} item(s) marked to add.`) : "&nbsp;";
const selectedTotalStatus = msg(str`${selectedTotal} item(s) selected.`);
const selectedCountStatus =
selectedCount > 0 ? " " + msg(str`${selectedCount} item(s) marked to remove.`) : "";
const selectedStatus = `${selectedTotalStatus} ${selectedCountStatus}`;
return html`
@ -310,7 +324,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
<ak-dual-select-available-pane
${ref(this.availablePane)}
.options=${this.options}
.selected=${this.selectedKeys}
.selected=${this.#selectedKeys}
></ak-dual-select-available-pane>
${this.needPagination
? html`<ak-pagination .pages=${this.pages}></ak-pagination>`
@ -344,12 +358,14 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
<ak-dual-select-selected-pane
${ref(this.selectedPane)}
.selected=${selected.toSorted(alphaSort)}
.selected=${selected.toSorted(localeComparator)}
></ak-dual-select-selected-pane>
</div>
</div>
`;
}
//#endregion
}
declare global {

View File

@ -1,26 +1,24 @@
import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { html, nothing } from "lit";
import { PropertyValues, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { map } from "lit/directives/map.js";
import { createRef, ref } from "lit/directives/ref.js";
import { availablePaneStyles, listStyles } from "./styles.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EVENT_ADD_ONE } from "../constants";
import type { DualSelectPair } from "../types";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
import { DualSelectEventType, DualSelectPair } from "../types.js";
const hostAttributes = [
["aria-labelledby", "dual-list-selector-available-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
] as const satisfies Array<[string, string]>;
/**
* @element ak-dual-select-available-panel
@ -37,81 +35,109 @@ const hostAttributes = [
*
* It is not expected that the `ak-dual-select-available-move-changed` event will be used; instead,
* the attribute will be read by the parent when a control is clicked.
*
*/
@customElement("ak-dual-select-available-pane")
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEventType>(
AKElement,
) {
static styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
//#region Properties
/* The array of key/value pairs this pane is currently showing */
@property({ type: Array })
readonly options: DualSelectPair[] = [];
/* A set (set being easy for lookups) of keys with all the pairs selected, so that the ones
* currently being shown that have already been selected can be marked and their clicks ignored.
*
/**
* A set (set being easy for lookups) of keys with all the pairs selected,
* so that the ones currently being shown that have already been selected
* can be marked and their clicks ignored.
*/
@property({ type: Object })
readonly selected: Set<string> = new Set();
/* This is the only mutator for this object. It collects the list of objects the user has
* clicked on *in this pane*. It is explicitly marked as "public" to emphasize that the parent
* orchestrator for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
//#endregion
//#region State
/**
* This is the only mutator for this object.
* It collects the list of objects the user has clicked on *in this pane*.
*
* It is explicitly marked as "public" to emphasize that the parent orchestrator
* for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
*/
@state()
public toMove: Set<string> = new Set();
constructor() {
super();
this.onClick = this.onClick.bind(this);
this.onMove = this.onMove.bind(this);
}
//#endregion
//#region Refs
protected listRef = createRef<HTMLDivElement>();
//#region Lifecycle
connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
for (const [attr, value] of hostAttributes) {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
}
clearMove() {
protected updated(changed: PropertyValues<this>) {
if (changed.has("options")) {
this.listRef.value?.scrollTo(0, 0);
}
}
//#region Public API
public clearMove() {
this.toMove = new Set();
}
onClick(key: string) {
if (this.selected.has(key)) {
return;
}
if (this.toMove.has(key)) {
this.toMove.delete(key);
} else {
this.toMove.add(key);
}
this.dispatchCustomEvent(
"ak-dual-select-available-move-changed",
Array.from(this.toMove.values()).sort(),
);
this.dispatchCustomEvent("ak-dual-select-move");
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
onMove(key: string) {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_ADD_ONE, key);
this.requestUpdate();
}
get moveable() {
return Array.from(this.toMove.values());
}
//#endregion
//#region Event Listeners
#clickListener(key: string): void {
if (this.selected.has(key)) return;
if (this.toMove.has(key)) {
this.toMove.delete(key);
} else {
this.toMove.add(key);
}
this.dispatchCustomEvent(
DualSelectEventType.MoveChanged,
Array.from(this.toMove.values()).sort(),
);
this.dispatchCustomEvent(DualSelectEventType.Move);
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
#moveListener(key: string): void {
this.toMove.delete(key);
this.dispatchCustomEvent(DualSelectEventType.AddOne, key);
this.requestUpdate();
}
//#region Render
// DO NOT use `Array.map()` instead of Lit's `map()` function. Lit's `map()` is object-aware and
// will not re-arrange or reconstruct the list automatically if the actual sources do not
// change; this allows the available pane to illustrate selected items with the checkmark
@ -119,17 +145,18 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
render() {
return html`
<div class="pf-c-dual-list-selector__menu">
<div ${ref(this.listRef)} class="pf-c-dual-list-selector__menu">
<ul class="pf-c-dual-list-selector__list">
${map(this.options, ([key, label]) => {
const selected = classMap({
"pf-m-selected": this.toMove.has(key),
});
return html` <li
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
@click=${() => this.#clickListener(key)}
@dblclick=${() => this.#moveListener(key)}
role="option"
data-ak-key=${key}
tabindex="-1"
@ -154,6 +181,8 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
</div>
`;
}
//#endregion
}
export default AkDualSelectAvailablePane;

View File

@ -8,34 +8,7 @@ import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
EVENT_ADD_ALL,
EVENT_ADD_SELECTED,
EVENT_DELETE_ALL,
EVENT_REMOVE_ALL,
EVENT_REMOVE_SELECTED,
} from "../constants";
const styles = [
PFBase,
PFButton,
css`
:host {
align-self: center;
padding-right: var(--pf-c-dual-list-selector__controls--PaddingRight);
padding-left: var(--pf-c-dual-list-selector__controls--PaddingLeft);
}
.pf-c-dual-list-selector {
max-width: 4rem;
}
.ak-dual-list-selector__controls {
display: grid;
justify-content: center;
align-content: center;
height: 100%;
}
`,
];
import { DualSelectEventType } from "../types.js";
/**
* @element ak-dual-select-controls
@ -43,64 +16,84 @@ const styles = [
* The "control box" for a dual-list multi-select. It's controlled by the parent orchestrator as to
* whether or not any of its controls are enabled. It sends a variety of messages to the parent
* orchestrator which will then reconcile the "available" and "selected" panes at need.
*
*/
@customElement("ak-dual-select-controls")
export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
export class AkDualSelectControls extends CustomEmitterElement<DualSelectEventType>(AKElement) {
static styles = [
PFBase,
PFButton,
css`
:host {
align-self: center;
padding-right: var(--pf-c-dual-list-selector__controls--PaddingRight);
padding-left: var(--pf-c-dual-list-selector__controls--PaddingLeft);
}
.pf-c-dual-list-selector {
max-width: calc(var(--pf-global--spacer-md, 1rem) * 4);
}
.ak-dual-list-selector__controls {
display: grid;
justify-content: center;
align-content: center;
height: 100%;
}
`,
];
/* Set to true if any *visible* elements can be added to the selected list
/**
* Set to true if any *visible* elements can be added to the selected list.
*/
@property({ attribute: "add-active", type: Boolean })
addActive = false;
/* Set to true if any elements can be removed from the selected list (essentially,
/**
* Set to true if any elements can be removed from the selected list (essentially,
* if the selected list is not empty)
*/
@property({ attribute: "remove-active", type: Boolean })
removeActive = false;
/* Set to true if *all* the currently visible elements can be moved
/**
* Set to true if *all* the currently visible elements can be moved
* into the selected list (essentially, if any visible elements are
* not currently selected)
* not currently selected).
*/
@property({ attribute: "add-all-active", type: Boolean })
addAllActive = false;
/* Set to true if *any* of the elements currently visible in the available
/**
* Set to true if *any* of the elements currently visible in the available
* pane are available to be moved to the selected list, enabling that
* all of those specific elements be moved out of the selected list
* all of those specific elements be moved out of the selected list.
*/
@property({ attribute: "remove-all-active", type: Boolean })
removeAllActive = false;
/* if deleteAll is enabled, set to true to show that there are elements in the
/**
* if deleteAll is enabled, set to true to show that there are elements in the
* selected list that can be deleted.
*/
@property({ attribute: "delete-all-active", type: Boolean })
enableDeleteAll = false;
/* Set to true if you want the `...AllActive` buttons made available. */
/**
* Set to true if you want the `...AllActive` buttons made available.
*/
@property({ attribute: "enable-select-all", type: Boolean })
selectAll = false;
/* Set to true if you want the `ClearAllSelected` button made available */
/**
* Set to true if you want the `ClearAllSelected` button made available
*/
@property({ attribute: "enable-delete-all", type: Boolean })
deleteAll = false;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(eventName: string) {
this.dispatchCustomEvent(eventName);
}
renderButton(label: string, event: string, active: boolean, direction: string) {
renderButton(
label: string,
eventType: DualSelectEventType,
active: boolean,
direction: string,
) {
return html`
<div class="pf-c-dual-list-selector__controls-item">
<button
@ -109,7 +102,7 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
aria-label=${label}
class="pf-c-button pf-m-plain"
type="button"
@click=${() => this.onClick(event)}
@click=${() => this.dispatchCustomEvent(eventType)}
data-ouia-component-type="AK/Button"
>
<i class="fa ${direction}"></i>
@ -123,7 +116,7 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
<div class="ak-dual-list-selector__controls">
${this.renderButton(
msg("Add"),
EVENT_ADD_SELECTED,
DualSelectEventType.AddSelected,
this.addActive,
"fa-angle-right",
)}
@ -131,13 +124,13 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
? html`
${this.renderButton(
msg("Add All Available"),
EVENT_ADD_ALL,
DualSelectEventType.AddAll,
this.addAllActive,
"fa-angle-double-right",
)}
${this.renderButton(
msg("Remove All Available"),
EVENT_REMOVE_ALL,
DualSelectEventType.RemoveAll,
this.removeAllActive,
"fa-angle-double-left",
)}
@ -145,14 +138,14 @@ export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
: nothing}
${this.renderButton(
msg("Remove"),
EVENT_REMOVE_SELECTED,
DualSelectEventType.RemoveSelected,
this.removeActive,
"fa-angle-left",
)}
${this.deleteAll
? html`${this.renderButton(
msg("Remove All"),
EVENT_DELETE_ALL,
DualSelectEventType.DeleteAll,
this.enableDeleteAll,
"fa-times",
)}`

View File

@ -11,16 +11,13 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFDualListSelector from "@patternfly/patternfly/components/DualListSelector/dual-list-selector.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EVENT_REMOVE_ONE } from "../constants";
import type { DualSelectPair } from "../types";
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
import { DualSelectEventType, DualSelectPair } from "../types";
const hostAttributes = [
["aria-labelledby", "dual-list-selector-selected-pane-status"],
["aria-multiselectable", "true"],
["role", "listbox"],
];
] as const satisfies Array<[string, string]>;
/**
* @element ak-dual-select-available-panel
@ -38,68 +35,86 @@ const hostAttributes = [
*
*/
@customElement("ak-dual-select-selected-pane")
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
export class AkDualSelectSelectedPane extends CustomEmitterElement<DualSelectEventType>(AKElement) {
static styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
/* The array of key/value pairs that are in the selected list. ALL of them. */
//#region Properties
/* The array of key/value pairs that are in the selected list. ALL of them. */
@property({ type: Array })
readonly selected: DualSelectPair[] = [];
/*
* This is the only mutator for this object. It collects the list of objects the user has
* clicked on *in this pane*. It is explicitly marked as "public" to emphasize that the parent
* orchestrator for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
//#endregion
//#region State
/**
* This is the only mutator for this object.
* It collects the list of objects the user has clicked on *in this pane*.
*
* It is explicitly marked as "public" to emphasize that the parent orchestrator
* for the dual-select widget can and will access it to get the list of keys to be
* moved (removed) if the user so requests.
*/
@state()
public toMove: Set<string> = new Set();
constructor() {
super();
this.onClick = this.onClick.bind(this);
this.onMove = this.onMove.bind(this);
}
//#endregion
connectedCallback() {
//#region Lifecycle
public connectedCallback() {
super.connectedCallback();
hostAttributes.forEach(([attr, value]) => {
for (const [attr, value] of hostAttributes) {
if (!this.hasAttribute(attr)) {
this.setAttribute(attr, value);
}
});
}
}
clearMove() {
//#endregion
//#region Public API
public clearMove() {
this.toMove = new Set();
}
onClick(key: string) {
public get moveable() {
return Array.from(this.toMove.values());
}
//#endregion
//#region Event Listeners
#clickListener = (key: string): void => {
if (this.toMove.has(key)) {
this.toMove.delete(key);
} else {
this.toMove.add(key);
}
this.dispatchCustomEvent(
"ak-dual-select-selected-move-changed",
DualSelectEventType.MoveChanged,
Array.from(this.toMove.values()).sort(),
);
this.dispatchCustomEvent("ak-dual-select-move");
// Necessary because updating a map won't trigger a state change
this.requestUpdate();
}
};
onMove(key: string) {
#moveListener = (key: string): void => {
this.toMove.delete(key);
this.dispatchCustomEvent(EVENT_REMOVE_ONE, key);
this.requestUpdate();
}
get moveable() {
return Array.from(this.toMove.values());
}
this.dispatchCustomEvent(DualSelectEventType.RemoveOne, key);
this.requestUpdate();
};
//#endregion
//#region Render
render() {
return html`
@ -113,8 +128,8 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
class="pf-c-dual-list-selector__list-item"
aria-selected="false"
id="dual-list-selector-basic-selected-pane-list-option-0"
@click=${() => this.onClick(key)}
@dblclick=${() => this.onMove(key)}
@click=${() => this.#clickListener(key)}
@dblclick=${() => this.#moveListener(key)}
role="option"
data-ak-key=${key}
tabindex="-1"
@ -134,6 +149,8 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
</div>
`;
}
//#endregion
}
export default AkDualSelectSelectedPane;

View File

@ -9,85 +9,77 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFPagination from "@patternfly/patternfly/components/Pagination/pagination.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { BasePagination } from "../types";
const styles = [
PFBase,
PFButton,
PFPagination,
css`
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button {
color: var(--pf-c-button--m-plain--disabled--Color);
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
}
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button:disabled {
color: var(--pf-c-button--disabled--Color);
}
`,
];
import { BasePagination, DualSelectEventType } from "../types.js";
@customElement("ak-pagination")
export class AkPagination extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
export class AkPagination extends CustomEmitterElement<DualSelectEventType>(AKElement) {
static styles = [
PFBase,
PFButton,
PFPagination,
css`
:host([theme="dark"]) {
.pf-c-pagination__nav-control .pf-c-button {
color: var(--pf-c-button--m-plain--disabled--Color);
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
}
.pf-c-pagination__nav-control .pf-c-button:disabled {
color: var(--pf-c-button--disabled--Color);
}
}
`,
];
@property({ attribute: false })
pages?: BasePagination;
constructor() {
super();
this.onClick = this.onClick.bind(this);
}
onClick(nav: number | undefined) {
this.dispatchCustomEvent("ak-pagination-nav-to", nav ?? 0);
}
#clickListener = (nav: number = 0) => {
this.dispatchCustomEvent(DualSelectEventType.NavigateTo, nav);
};
render() {
return this.pages
? html` <div class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div
class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md"
>
<div class="pf-c-options-menu">
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span class="pf-c-options-menu__toggle-text">
${msg(
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`,
)}
</span>
</div>
</div>
<nav class="pf-c-pagination__nav" aria-label=${msg("Pagination")}>
<div class="pf-c-pagination__nav-control pf-m-prev">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.onClick(this.pages?.previous);
}}
?disabled="${(this.pages?.previous ?? 0) < 1}"
aria-label="${msg("Go to previous page")}"
>
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
</div>
<div class="pf-c-pagination__nav-control pf-m-next">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.onClick(this.pages?.next);
}}
?disabled="${(this.pages?.next ?? 0) <= 0}"
aria-label="${msg("Go to next page")}"
>
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
</nav>
</div>
</div>`
: nothing;
const { pages } = this;
if (!pages) return nothing;
return html` <div class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div class="pf-c-options-menu">
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span class="pf-c-options-menu__toggle-text">
${msg(str`${pages.startIndex} - ${pages.endIndex} of ${pages.count}`)}
</span>
</div>
</div>
<nav class="pf-c-pagination__nav" aria-label=${msg("Pagination")}>
<div class="pf-c-pagination__nav-control pf-m-prev">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.#clickListener(pages.previous);
}}
?disabled="${(pages.previous ?? 0) < 1}"
aria-label="${msg("Go to previous page")}"
>
<i class="fas fa-angle-left" aria-hidden="true"></i>
</button>
</div>
<div class="pf-c-pagination__nav-control pf-m-next">
<button
class="pf-c-button pf-m-plain"
@click=${() => {
this.#clickListener(pages.next);
}}
?disabled="${(pages.next ?? 0) <= 0}"
aria-label="${msg("Go to next page")}"
>
<i class="fas fa-angle-right" aria-hidden="true"></i>
</button>
</div>
</nav>
</div>
</div>`;
}
}

View File

@ -4,47 +4,45 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js";
import { globalVariables, searchStyles } from "./search.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import type { SearchbarEvent } from "../types";
const styles = [PFBase, globalVariables, searchStyles];
import type { SearchbarEventDetail, SearchbarEventSource } from "../types.ts";
import { globalVariables, searchStyles } from "./search.css.js";
@customElement("ak-search-bar")
export class AkSearchbar extends CustomEmitterElement(AKElement) {
static get styles() {
return styles;
}
static styles = [PFBase, globalVariables, searchStyles];
@property({ type: String, reflect: true })
value = "";
public value = "";
/**
* If you're using more than one search, this token can help listeners distinguishing between
* those searches. Lit's own helpers sometimes erase the source and current targets.
*/
@property({ type: String })
name = "";
public name?: SearchbarEventSource;
input: Ref<HTMLInputElement> = createRef();
protected inputRef = createRef<HTMLInputElement>();
constructor() {
super();
this.onChange = this.onChange.bind(this);
}
#changeListener = () => {
const inputElement = this.inputRef.value;
onChange(_event: Event) {
if (this.input.value) {
this.value = this.input.value.value;
if (inputElement) {
this.value = inputElement.value;
}
this.dispatchCustomEvent<SearchbarEvent>("ak-search", {
if (!this.name) {
console.warn("ak-search-bar: no name provided, event will not be dispatched");
return;
}
this.dispatchCustomEvent<SearchbarEventDetail>("ak-search", {
source: this.name,
value: this.value,
});
}
};
render() {
return html`
@ -56,8 +54,8 @@ export class AkSearchbar extends CustomEmitterElement(AKElement) {
><input
type="search"
class="pf-c-text-input-group__text-input"
${ref(this.input)}
@input=${this.onChange}
${ref(this.inputRef)}
@input=${this.#changeListener}
value="${this.value}"
/></span>
</div>

View File

@ -1,7 +0,0 @@
export const EVENT_ADD_SELECTED = "ak-dual-select-add";
export const EVENT_REMOVE_SELECTED = "ak-dual-select-remove";
export const EVENT_ADD_ALL = "ak-dual-select-add-all";
export const EVENT_REMOVE_ALL = "ak-dual-select-remove-all";
export const EVENT_DELETE_ALL = "ak-dual-select-remove-everything";
export const EVENT_ADD_ONE = "ak-dual-select-add-one";
export const EVENT_REMOVE_ONE = "ak-dual-select-remove-one";

View File

@ -1,7 +1,7 @@
import { AkDualSelect } from "./ak-dual-select";
import "./ak-dual-select";
import { AkDualSelectProvider } from "./ak-dual-select-provider";
import "./ak-dual-select-provider";
import { AkDualSelectProvider } from "./ak-dual-select-provider.js";
import "./ak-dual-select.js";
import { AkDualSelect } from "./ak-dual-select.js";
export { AkDualSelect, AkDualSelectProvider };
export default AkDualSelect;

View File

@ -9,7 +9,7 @@ import { Pagination } from "@goauthentik/api";
import "../ak-dual-select";
import { AkDualSelect } from "../ak-dual-select";
import type { DualSelectPair } from "../types";
import { DualSelectEventType, type DualSelectPair } from "../types";
const goodForYouRaw = `
Apple, Arrowroot, Artichoke, Arugula, Asparagus, Avocado, Bamboo, Banana, Basil, Beet Root,
@ -24,7 +24,8 @@ Rosemary, Rutabaga, Shallot, Soybeans, Spinach, Squash, Strawberries, Sweet pota
Thyme, Tomatillo, Tomato, Turnip, Waterchestnut, Watercress, Watermelon, Yams
`;
const keyToPair = (key: string): DualSelectPair => [slug(key), key];
const keyToPair = (key: string): DualSelectPair => [slug(key), key, key];
const goodForYou: DualSelectPair[] = goodForYouRaw
.split("\n")
.join(" ")
@ -83,7 +84,7 @@ export class AkSbFruity extends LitElement {
totalPages: Math.ceil(this.options.length / this.pageLength),
};
this.onNavigation = this.onNavigation.bind(this);
this.addEventListener("ak-pagination-nav-to", this.onNavigation);
this.addEventListener(DualSelectEventType.NavigateTo, this.onNavigation);
}
onNavigation(evt: Event) {

View File

@ -1,5 +1,4 @@
import "@goauthentik/elements/messages/MessageContainer";
import { debounce } from "@goauthentik/elements/utils/debounce";
import { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
@ -45,20 +44,24 @@ const displayMessage = (result: any) => {
target!.replaceChildren(doc.firstChild!);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayMessage2 = (result: any) => {
const displayMessage2 = (result: string) => {
console.debug("Huh.");
const doc = new DOMParser().parseFromString(`<p><i>Behavior</i>: ${result}</p>`, "text/xml");
const target = document.querySelector("#action-button-message-pad-2");
target!.replaceChildren(doc.firstChild!);
};
const displayMessage2b = debounce(displayMessage2, 250);
let displayMessage2bTimeoutID: ReturnType<typeof setTimeout>;
window.addEventListener("input", (event: Event) => {
const message = (event.target as HTMLInputElement | undefined)?.value ?? "-- undefined --";
displayMessage(message);
displayMessage2b(message);
clearTimeout(displayMessage2bTimeoutID);
displayMessage2bTimeoutID = setTimeout(() => {
displayMessage2(message);
}, 250);
});
type Story = StoryObj;

View File

@ -5,6 +5,7 @@ import { TemplateResult, html } from "lit";
import "../components/ak-pagination";
import { AkPagination } from "../components/ak-pagination";
import { DualSelectEventType } from "../types";
const metadata: Meta<AkPagination> = {
title: "Elements / Dual Select / Pagination Control",
@ -54,7 +55,7 @@ const handleMoveChanged = (result: any) => {
);
};
window.addEventListener("ak-pagination-nav-to", handleMoveChanged);
window.addEventListener(DualSelectEventType.NavigateTo, handleMoveChanged);
type Story = StoryObj;

View File

@ -12,9 +12,7 @@ import { globalVariables } from "../components/styles.css";
@customElement("sb-dual-select-host-provider")
export class SbHostProvider extends LitElement {
static get styles() {
return globalVariables;
}
static styles = globalVariables;
render() {
return html`<slot></slot>`;

View File

@ -2,19 +2,44 @@ import { TemplateResult } from "lit";
import { Pagination } from "@goauthentik/api";
//
// - key: string
// - label (string or TemplateResult),
// - sortBy (optional) string to sort by. If the sort string is
// - localMapping: The object the key represents; used by some specific apps. API layers may use
// this as a way to find the preset object.
//
// Note that this is a *tuple*, not a record or map!
export const DualSelectEventType = {
AddSelected: "ak-dual-select-add",
RemoveSelected: "ak-dual-select-remove",
Search: "ak-dual-select-search",
AddAll: "ak-dual-select-add-all",
RemoveAll: "ak-dual-select-remove-all",
DeleteAll: "ak-dual-select-remove-everything",
AddOne: "ak-dual-select-add-one",
RemoveOne: "ak-dual-select-remove-one",
Move: "ak-dual-select-move",
MoveChanged: "ak-dual-select-available-move-changed",
Change: "ak-dual-select-change",
NavigateTo: "ak-pagination-nav-to",
} as const satisfies Record<string, string>;
export type DualSelectPair<T = never> = [
export type DualSelectEventType = (typeof DualSelectEventType)[keyof typeof DualSelectEventType];
/**
* A tuple representing a single object in the dual select list.
*/
export type DualSelectPair<T = unknown> = [
/**
* The key used to identify the object in the API.
*/
key: string,
/**
* A human-readable label for the object.
*/
label: string | TemplateResult,
sortBy?: string,
/**
* A string to sort by. If not provided, the key will be used.
*/
sortBy: string,
/**
* A local mapping of the key to the object. This is used by some specific apps.
*
* API layers may use this as a way to find the preset object.
*/
localMapping?: T,
];
@ -30,9 +55,14 @@ export type DataProvision = {
export type DataProvider = (page: number, search?: string) => Promise<DataProvision>;
export const SearchbarEventSource = {
Available: "ak-dual-list-available-search",
Selected: "ak-dual-list-selected-search",
} as const satisfies Record<string, string>;
export type SearchbarEventSource = (typeof SearchbarEventSource)[keyof typeof SearchbarEventSource];
export interface SearchbarEventDetail {
source: string;
source: SearchbarEventSource;
value: string;
}
export type SearchbarEvent = CustomEvent<SearchbarEventDetail>;

View File

@ -27,11 +27,14 @@ import remarkGFM from "remark-gfm";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import remarkParse from "remark-parse";
import { CSSResult, css } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators.js";
import OneDark from "@goauthentik/common/styles/one-dark.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFList from "@patternfly/patternfly/components/List/list.css";
import PFTable from "@patternfly/patternfly/components/Table/table.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { UiThemeEnum } from "@goauthentik/api";
@ -67,80 +70,96 @@ export class AKMDX extends AKElement {
resolvedHTML = "";
static get styles(): CSSResult[] {
return [
PFList,
PFContent,
css`
a {
--pf-global--link--Color: var(--pf-global--link--Color--light);
--pf-global--link--Color--hover: var(--pf-global--link--Color--light--hover);
--pf-global--link--Color--visited: var(--pf-global--link--Color);
static styles = [
PFBase,
PFList,
PFTable,
PFContent,
OneDark,
css`
:host {
--ak-mermaid-message-text: var(--pf-c-content--Color);
--ak-table-stripe-background: var(--pf-global--BackgroundColor--light-200);
}
:host([theme="dark"]) {
--ak-mermaid-message-text: var(--ak-dark-foreground);
--ak-mermaid-box-background-color: var(--ak-dark-background-lighter);
--ak-table-stripe-background: var(--pf-global--BackgroundColor--dark-200);
}
ak-alert + p {
margin-block-start: var(--pf-global--spacer--md);
}
a {
--pf-global--link--Color: var(--pf-global--link--Color--light);
--pf-global--link--Color--hover: var(--pf-global--link--Color--light--hover);
--pf-global--link--Color--visited: var(--pf-global--link--Color);
}
/*
Note that order of anchor pseudo-selectors must follow:
1. link
2. visited
3. hover
4. active
*/
a:link {
color: var(--pf-global--link--Color);
}
a:visited {
color: var(--pf-global--link--Color--visited);
}
a:hover {
color: var(--pf-global--link--Color--hover);
}
a:active {
color: var(--pf-global--link--Color);
}
h2:first-of-type {
margin-top: 0;
}
table thead,
table tr:nth-child(2n) {
background-color: var(--ak-table-stripe-background,);
}
table td,
table th {
border: var(--pf-table-border-width) solid var(--ifm-table-border-color);
padding: var(--pf-global--spacer--md);
}
pre {
overflow-x: auto;
}
pre:has(.hljs) {
padding: var(--pf-global--spacer--md);
}
svg[id^="mermaid-svg-"] {
.rect {
fill: var(
--ak-mermaid-box-background-color,
var(--pf-global--BackgroundColor--light-300)
) !important;
}
/*
Note that order of anchor pseudo-selectors must follow:
1. link
2. visited
3. hover
4. active
*/
a:link {
color: var(--pf-global--link--Color);
.messageText {
stroke-width: 4;
fill: var(--ak-mermaid-message-text) !important;
paint-order: stroke;
}
a:visited {
color: var(--pf-global--link--Color--visited);
}
a:hover {
color: var(--pf-global--link--Color--hover);
}
a:active {
color: var(--pf-global--link--Color);
}
h2:first-of-type {
margin-top: 0;
}
table thead,
table tr:nth-child(2n) {
background-color: var(
--ak-table-stripe-background,
var(--pf-global--BackgroundColor--light-200)
);
}
table td,
table th {
border: var(--pf-table-border-width) solid var(--ifm-table-border-color);
padding: var(--pf-global--spacer--md);
}
pre:has(.hljs) {
padding: var(--pf-global--spacer--md);
}
svg[id^="mermaid-svg-"] {
.rect {
fill: var(
--ak-mermaid-box-background-color,
var(--pf-global--BackgroundColor--light-300)
) !important;
}
.messageText {
stroke-width: 4;
fill: var(--ak-mermaid-message-text) !important;
paint-order: stroke;
}
}
`,
];
}
}
`,
];
public async connectedCallback() {
super.connectedCallback();

View File

@ -16,5 +16,5 @@ export const MDXWrapper: React.FC<MDXWrapperProps> = ({ children, frontmatter })
nextChildren.unshift(<h1 key="header-title">{title}</h1>);
}
return <>{nextChildren}</>;
return <div className="pf-c-content">{nextChildren}</div>;
};

View File

@ -40,7 +40,7 @@ export class FormGroup extends AKElement {
* restructured to allow for this.
*/
.pf-c-form__field-group:has(.pf-c-form__field-group-header:hover) .pf-c-button {
color: var(--pf-global--Color--100) !important;
color: var(--pf-c-button--m-plain--hover--Color) !important;
}
/**

View File

@ -1,21 +1,16 @@
import {
appendStyleSheet,
assertAdoptableStyleSheetParent,
createStyleSheetUnsafe,
} from "@goauthentik/common/stylesheets.js";
import { applyDocumentTheme } from "@goauthentik/common/theme.js";
import { TemplateResult, render as litRender } from "lit";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
// A special version of render that ensures our style sheets will always be available
// to all elements under test. Ensures they look right during testing, and that any
// CSS-based checks for visibility will return correct values.
/**
* A special version of render that ensures our stylesheets:
*
* - Will always be available to all elements under test.
* - Ensure they look right during testing.
* - CSS-based checks for visibility will return correct values.
*/
export const render = (body: TemplateResult) => {
assertAdoptableStyleSheetParent(document);
applyDocumentTheme();
appendStyleSheet(document, ...[PFBase, AKGlobal].map(createStyleSheetUnsafe));
return litRender(body, document.body);
};

View File

@ -1,6 +1,11 @@
import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit";
import "lit";
/**
* Type utility to make readonly properties mutable.
*/
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
/**
* A custom element which may be used as a host for a ReactiveController.
*
@ -8,11 +13,12 @@ import "lit";
*
* This type is derived from an internal type in Lit.
*/
export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & T> & HTMLElement;
export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & Writeable<T>> & HTMLElement;
export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement;
export type LitElementConstructor = new (...args: never[]) => LitElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type LitElementConstructor = new (...args: any[]) => LitElement;
/**
* A constructor that has been extended with a mixin.

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