Compare commits
2 Commits
esbuild-ts
...
core/fix-g
Author | SHA1 | Date | |
---|---|---|---|
af9ce90b8b | |||
05e5c6309c |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2025.4.1
|
||||
current_version = 2025.4.0
|
||||
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*))?
|
||||
|
@ -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.4 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.7.3 AS uv
|
||||
# Stage 6: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2025.4.1"
|
||||
__version__ = "2025.4.0"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -16,10 +16,12 @@ from drf_spectacular.utils import (
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
|
||||
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ListSerializer, ValidationError
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.views import View
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@ -85,33 +87,6 @@ class GroupSerializer(ModelSerializer):
|
||||
raise ValidationError(_("Cannot set group as parent of itself."))
|
||||
return parent
|
||||
|
||||
def validate_is_superuser(self, superuser: bool):
|
||||
"""Ensure that the user creating this group has permissions to set the superuser flag"""
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return superuser
|
||||
# If we're updating an instance, and the state hasn't changed, we don't need to check perms
|
||||
if self.instance and superuser == self.instance.is_superuser:
|
||||
return superuser
|
||||
user: User = request.user
|
||||
perm = (
|
||||
"authentik_core.enable_group_superuser"
|
||||
if superuser
|
||||
else "authentik_core.disable_group_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:
|
||||
model = Group
|
||||
fields = [
|
||||
@ -179,6 +154,36 @@ class GroupFilter(FilterSet):
|
||||
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
|
||||
|
||||
|
||||
class SuperuserSetter(BasePermission):
|
||||
"""Check for enable_group_superuser or disable_group_superuser permissions"""
|
||||
|
||||
message = _("User does not have permission to set the given superuser status.")
|
||||
enable_perm = "authentik_core.enable_group_superuser"
|
||||
disable_perm = "authentik_core.disable_group_superuser"
|
||||
|
||||
def has_permission(self, request: Request, view: View):
|
||||
if request.method != "POST":
|
||||
return True
|
||||
|
||||
is_superuser = request.data.get("is_superuser", False)
|
||||
if not is_superuser:
|
||||
return True
|
||||
|
||||
return request.user.has_perm(self.enable_perm)
|
||||
|
||||
def has_object_permission(self, request: Request, view: View, object: Group):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
|
||||
new_value = request.data.get("is_superuser")
|
||||
old_value = object.is_superuser
|
||||
if new_value is None or new_value == old_value:
|
||||
return True
|
||||
|
||||
perm = self.enable_perm if new_value else self.disable_perm
|
||||
return request.user.has_perm(perm) or request.user.has_perm(perm, object)
|
||||
|
||||
|
||||
class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
"""Group Viewset"""
|
||||
|
||||
@ -191,6 +196,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = GroupSerializer
|
||||
search_fields = ["name", "is_superuser"]
|
||||
filterset_class = GroupFilter
|
||||
permission_classes = [SuperuserSetter]
|
||||
ordering = ["name"]
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -118,21 +118,24 @@ class TestGroupsAPI(APITestCase):
|
||||
reverse("authentik_api:group-list"),
|
||||
data={"name": generate_id(), "is_superuser": True},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertEqual(res.status_code, 403)
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{"is_superuser": ["User does not have permission to set superuser status to True."]},
|
||||
{"detail": "User does not have permission to set the given superuser status."},
|
||||
)
|
||||
|
||||
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)
|
||||
def test_superuser_update_object_perm(self):
|
||||
"""Test updating a superuser group with object permission"""
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=False)
|
||||
assign_perm("view_group", self.login_user, group)
|
||||
assign_perm("change_group", self.login_user, group)
|
||||
assign_perm("enable_group_superuser", self.login_user, group)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-list"),
|
||||
data={"name": generate_id(), "is_superuser": False},
|
||||
res = self.client.patch(
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={"is_superuser": True},
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_superuser_update_no_perm(self):
|
||||
"""Test updating a superuser group without permission"""
|
||||
@ -144,10 +147,10 @@ class TestGroupsAPI(APITestCase):
|
||||
reverse("authentik_api:group-detail", kwargs={"pk": group.pk}),
|
||||
data={"is_superuser": False},
|
||||
)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertEqual(res.status_code, 403)
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{"is_superuser": ["User does not have permission to set superuser status to False."]},
|
||||
{"detail": "User does not have permission to set the given superuser status."},
|
||||
)
|
||||
|
||||
def test_superuser_update_no_change(self):
|
||||
@ -173,3 +176,27 @@ class TestGroupsAPI(APITestCase):
|
||||
data={"name": generate_id(), "is_superuser": True},
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
||||
def test_superuser_create_no_perm(self):
|
||||
"""Test creating a superuser group with no permission"""
|
||||
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": True},
|
||||
)
|
||||
self.assertEqual(res.status_code, 403)
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{"detail": "User does not have permission to set the given superuser status."},
|
||||
)
|
||||
|
||||
def test_no_superuser_create_no_perm(self):
|
||||
"""Test creating a non-superuser group with no permission"""
|
||||
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()},
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
@ -7,7 +7,7 @@
|
||||
{{ block.super }}
|
||||
<link rel="prefetch" href="{{ flow_background_url }}" />
|
||||
{% if flow.compatibility_mode and not inspector %}
|
||||
<script>ShadyDOM = { force: true };</script>
|
||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
||||
{% endif %}
|
||||
{% include "base/header_js.html" %}
|
||||
<script>
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -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.1 Blueprint schema",
|
||||
"title": "authentik 2025.4.0 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.0}
|
||||
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.1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.0}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
4
go.mod
4
go.mod
@ -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.33.0
|
||||
github.com/getsentry/sentry-go v0.32.0
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
github.com/go-ldap/ldap/v3 v3.4.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.2025041.1
|
||||
goauthentik.io/api/v3 v3.2025040.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
8
go.sum
@ -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.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg=
|
||||
github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
|
||||
github.com/getsentry/sentry-go v0.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY=
|
||||
github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.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.2025041.1 h1:GAN6AoTmfnCGgx1SyM07jP4/LR/T3rkTEyShSBd3Co8=
|
||||
goauthentik.io/api/v3 v3.2025041.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
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=
|
||||
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=
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2025.4.1"
|
||||
const VERSION = "2025.4.0"
|
||||
|
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1015.0",
|
||||
"aws-cdk": "^2.1014.0",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -17,9 +17,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1015.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1015.0.tgz",
|
||||
"integrity": "sha512-txd+yMVVybtLfiwT409+fahbP0SkiwhmQvQf6PVVYnWzDPSknxYlUNJHisHV4tJEcbHWn1QPsLmqqMT0bw8hBg==",
|
||||
"version": "2.1014.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1014.0.tgz",
|
||||
"integrity": "sha512-es101rtRAClix9BncNL54iW90MiOyRv4iCC5tv/firGDnidS6pPinuK0IIFt0RO6w0+3heRxWBXg8HY+f9877w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
@ -10,7 +10,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1015.0",
|
||||
"aws-cdk": "^2.1014.0",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.4.1
|
||||
Default: 2025.4.0
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.4.1",
|
||||
"version": "2025.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2025.4.1"
|
||||
version = "2025.4.0"
|
||||
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.2",
|
||||
"django-pglock==1.7.1",
|
||||
"django-prometheus==2.3.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
|
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2025.4.1
|
||||
version: 2025.4.0
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
|
@ -1,19 +1,12 @@
|
||||
"""test default login flow"""
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.flows.models import Flow
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
|
||||
|
||||
class TestFlowsLogin(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",
|
||||
@ -30,21 +23,3 @@ 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)
|
||||
|
@ -29,7 +29,6 @@ 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
|
||||
|
||||
@ -38,8 +37,8 @@ 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
|
||||
RETRIES = int(environ.get("RETRIES", "3")) if IS_CI else 1
|
||||
|
||||
|
||||
def get_docker_tag() -> str:
|
||||
@ -241,30 +240,10 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
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):
|
||||
def login(self):
|
||||
"""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]")))
|
||||
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(
|
||||
@ -274,16 +253,8 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
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]")))
|
||||
|
||||
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
|
||||
)
|
||||
|
10
uv.lock
generated
10
uv.lock
generated
@ -164,7 +164,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2025.4.1"
|
||||
version = "2025.4.0"
|
||||
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.2" },
|
||||
{ name = "django-pglock", specifier = "==1.7.1" },
|
||||
{ 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.2"
|
||||
version = "1.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-pgactivity" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
11
web/.storybook/authentikTheme.ts
Normal file
11
web/.storybook/authentikTheme.ts
Normal file
@ -0,0 +1,11 @@
|
||||
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",
|
||||
});
|
@ -1,63 +0,0 @@
|
||||
/**
|
||||
* @file Storybook configuration.
|
||||
* @import { StorybookConfig } from "@storybook/web-components-vite";
|
||||
* @import { InlineConfig, Plugin } from "vite";
|
||||
*/
|
||||
import { createBundleDefinitions } from "@goauthentik/web/scripts/esbuild/environment";
|
||||
import postcssLit from "rollup-plugin-postcss-lit";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
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: createBundleDefinitions(),
|
||||
plugins: [inlineCSSPlugin, ...plugins, postcssLit(), tsconfigPaths()],
|
||||
};
|
||||
|
||||
return mergedConfig;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
81
web/.storybook/main.ts
Normal file
81
web/.storybook/main.ts
Normal file
@ -0,0 +1,81 @@
|
||||
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;
|
@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @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,
|
||||
});
|
||||
});
|
9
web/.storybook/manager.ts
Normal file
9
web/.storybook/manager.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// .storybook/manager.js
|
||||
import { addons } from "@storybook/manager-api";
|
||||
|
||||
import authentikTheme from "./authentikTheme";
|
||||
|
||||
addons.setConfig({
|
||||
theme: authentikTheme,
|
||||
enableShortcuts: false,
|
||||
});
|
@ -1,3 +1,5 @@
|
||||
<link rel="stylesheet" href="@patternfly/patternfly/patternfly-base.css" />
|
||||
<link rel="stylesheet" href="@goauthentik/common/styles/authentik.css" />
|
||||
<style>
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
|
@ -1,32 +0,0 @@
|
||||
/// <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;
|
30
web/.storybook/preview.ts
Normal file
30
web/.storybook/preview.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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;
|
2779
web/package-lock.json
generated
2779
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -36,14 +36,7 @@
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
"./paths": "./paths.js",
|
||||
"./scripts/*": "./scripts/*.mjs",
|
||||
"./elements/*": "./src/elements/*",
|
||||
"./common/*": "./src/common/*",
|
||||
"./components/*": "./src/components/*",
|
||||
"./flow/*": "./src/flow/*",
|
||||
"./locales/*": "./src/locales/*",
|
||||
"./user/*": "./src/user/*",
|
||||
"./admin/*": "./src/admin/*"
|
||||
"./scripts/*": "./scripts/*.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@ -56,7 +49,7 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2025.4.1-1747332783",
|
||||
"@goauthentik/api": "^2025.4.0-1746018955",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
"@lit/reactive-element": "^2.0.4",
|
||||
@ -112,14 +105,14 @@
|
||||
"@hcaptcha/types": "^1.0.4",
|
||||
"@lit/localize-tools": "^0.8.0",
|
||||
"@rollup/plugin-replace": "^6.0.1",
|
||||
"@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",
|
||||
"@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",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/chart.js": "^2.9.41",
|
||||
"@types/codemirror": "^5.60.15",
|
||||
@ -151,8 +144,9 @@
|
||||
"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.6.12",
|
||||
"storybook": "^8.3.4",
|
||||
"storybook-addon-mock": "^5.0.0",
|
||||
"turnstile-types": "^1.2.3",
|
||||
"typescript": "^5.6.2",
|
||||
|
@ -21,7 +21,7 @@ const log = console.debug.bind(console, logPrefix);
|
||||
* ESBuild may tree-shake it out of production builds.
|
||||
*
|
||||
* ```ts
|
||||
* if (import.meta.env.NODE_ENV=== "development") {
|
||||
* if (process.env.NODE_ENV === "development") {
|
||||
* await import("@goauthentik/esbuild-plugin-live-reload/client")
|
||||
* .catch(() => console.warn("Failed to import watcher"))
|
||||
* }
|
||||
|
@ -4,20 +4,15 @@
|
||||
|
||||
export {};
|
||||
declare global {
|
||||
/**
|
||||
* Environment variables injected by ESBuild.
|
||||
*/
|
||||
interface ImportMetaEnv {
|
||||
/**
|
||||
* The injected watcher URL for ESBuild.
|
||||
* This is used for live reloading in development mode.
|
||||
*
|
||||
* @format url
|
||||
*/
|
||||
readonly ESBUILD_WATCHER_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
readonly env: {
|
||||
/**
|
||||
* The injected watcher URL for ESBuild.
|
||||
* This is used for live reloading in development mode.
|
||||
*
|
||||
* @format url
|
||||
*/
|
||||
ESBUILD_WATCHER_URL: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1,20 +1,6 @@
|
||||
/**
|
||||
* @file Utility functions for working with environment variables.
|
||||
* @file Utility functions for building and copying files.
|
||||
*/
|
||||
/// <reference types="./types/global.js" />
|
||||
|
||||
//#region Constants
|
||||
|
||||
/**
|
||||
* The current Node.js environment, defaulting to "development" when not set.
|
||||
*
|
||||
* Note, this should only be used during the build process.
|
||||
*
|
||||
* If you need to check the environment at runtime, use `process.env.NODE_ENV` to
|
||||
* ensure that module tree-shaking works correctly.
|
||||
*
|
||||
*/
|
||||
export const NodeEnvironment = process.env.NODE_ENV || "development";
|
||||
|
||||
/**
|
||||
* A source environment variable, which can be a string, number, boolean, null, or undefined.
|
||||
@ -28,26 +14,19 @@ export const NodeEnvironment = process.env.NODE_ENV || "development";
|
||||
* @typedef {T extends string ? `"${T}"` : T} JSONify
|
||||
*/
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Utilities
|
||||
|
||||
/**
|
||||
* Given an object of environment variables, returns a new object with the same keys and values, but
|
||||
* with the values serialized as strings.
|
||||
*
|
||||
* @template {Record<string, EnvironmentVariable>} EnvRecord
|
||||
* @template {string} [Prefix='import.meta.env.']
|
||||
* @template {string} [Prefix='process.env.']
|
||||
*
|
||||
* @param {EnvRecord} input
|
||||
* @param {Prefix} [prefix='import.meta.env.']
|
||||
* @param {Prefix} [prefix='process.env.']
|
||||
*
|
||||
* @returns {{[K in keyof EnvRecord as `${Prefix}${K}`]: JSONify<EnvRecord[K]>}}
|
||||
*/
|
||||
export function serializeEnvironmentVars(
|
||||
input,
|
||||
prefix = /** @type {Prefix} */ ("import.meta.env."),
|
||||
) {
|
||||
export function serializeEnvironmentVars(input, prefix = /** @type {Prefix} */ ("process.env.")) {
|
||||
/**
|
||||
* @type {Record<string, string>}
|
||||
*/
|
||||
@ -61,5 +40,3 @@ export function serializeEnvironmentVars(
|
||||
|
||||
return /** @type {any} */ (env);
|
||||
}
|
||||
|
||||
//#endregion
|
16
web/packages/monorepo/constants.js
Normal file
16
web/packages/monorepo/constants.js
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* @file Constants for JavaScript and TypeScript files.
|
||||
*/
|
||||
|
||||
/// <reference types="../../types/global.js" />
|
||||
|
||||
/**
|
||||
* The current Node.js environment, defaulting to "development" when not set.
|
||||
*
|
||||
* Note, this should only be used during the build process.
|
||||
*
|
||||
* If you need to check the environment at runtime, use `process.env.NODE_ENV` to
|
||||
* ensure that module tree-shaking works correctly.
|
||||
*
|
||||
*/
|
||||
export const NodeEnvironment = process.env.NODE_ENV || "development";
|
@ -1,6 +1,7 @@
|
||||
/// <reference types="./types/global.js" />
|
||||
|
||||
export * from "./paths.js";
|
||||
export * from "./environment.js";
|
||||
export * from "./constants.js";
|
||||
export * from "./build.js";
|
||||
export * from "./version.js";
|
||||
export * from "./scripting.js";
|
||||
|
@ -1,32 +1,17 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
|
||||
/**
|
||||
* @file Lit Localize build script.
|
||||
*
|
||||
* @remarks
|
||||
* Determines if all the Xliff translation source files are present and if the Typescript source files generated from those sources are up-to-date.
|
||||
*
|
||||
* If they are not, it runs the locale building script,
|
||||
* intercepting the long spew of "this string is not translated" and replacing it with a
|
||||
* Determines if all the Xliff translation source files are present and if the Typescript source
|
||||
* files generated from those sources are up-to-date. If they are not, it runs the locale building
|
||||
* script, intercepting the long spew of "this string is not translated" and replacing it with a
|
||||
* summary of how many strings are missing with respect to the source locale.
|
||||
*
|
||||
* @import { ConfigFile } from "@lit/localize-tools/lib/types/config"
|
||||
*/
|
||||
import { PackageRoot } from "@goauthentik/web/paths";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* @type {ConfigFile}
|
||||
*/
|
||||
const localizeRules = JSON.parse(
|
||||
readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8"),
|
||||
);
|
||||
const localizeRules = JSON.parse(fs.readFileSync("./lit-localize.json", "utf-8"));
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} loc
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function generatedFileIsUpToDateWithXliffSource(loc) {
|
||||
const xliff = path.join("./xliff", `${loc}.xlf`);
|
||||
const gened = path.join("./src/locales", `${loc}.ts`);
|
||||
@ -37,7 +22,7 @@ function generatedFileIsUpToDateWithXliffSource(loc) {
|
||||
// generates a unique error message and halts the build.
|
||||
|
||||
try {
|
||||
var xlfStat = statSync(xliff);
|
||||
var xlfStat = fs.statSync(xliff);
|
||||
} catch (_error) {
|
||||
console.error(`lit-localize expected '${loc}.xlf', but XLF file is not present`);
|
||||
process.exit(1);
|
||||
@ -45,7 +30,7 @@ function generatedFileIsUpToDateWithXliffSource(loc) {
|
||||
|
||||
// If the generated file doesn't exist, of course it's not up to date.
|
||||
try {
|
||||
var genedStat = statSync(gened);
|
||||
var genedStat = fs.statSync(gened);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
/// <reference types="../types/esbuild.js" />
|
||||
/**
|
||||
* @file ESBuild script for building the authentik web UI.
|
||||
*
|
||||
@ -10,6 +9,7 @@ import {
|
||||
NodeEnvironment,
|
||||
readBuildIdentifier,
|
||||
resolvePackage,
|
||||
serializeEnvironmentVars,
|
||||
} from "@goauthentik/monorepo";
|
||||
import { DistDirectory, DistDirectoryName, EntryPoint, PackageRoot } from "@goauthentik/web/paths";
|
||||
import { deepmerge } from "deepmerge-ts";
|
||||
@ -20,10 +20,15 @@ import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { mdxPlugin } from "./esbuild/build-mdx-plugin.mjs";
|
||||
import { createBundleDefinitions } from "./esbuild/environment.mjs";
|
||||
|
||||
const logPrefix = "[Build]";
|
||||
|
||||
const definitions = serializeEnvironmentVars({
|
||||
NODE_ENV: NodeEnvironment,
|
||||
CWD: process.cwd(),
|
||||
AK_API_BASE_PATH: process.env.AK_API_BASE_PATH,
|
||||
});
|
||||
|
||||
const patternflyPath = resolvePackage("@patternfly/patternfly");
|
||||
|
||||
/**
|
||||
@ -81,7 +86,7 @@ const BASE_ESBUILD_OPTIONS = {
|
||||
root: MonoRepoRoot,
|
||||
}),
|
||||
],
|
||||
define: createBundleDefinitions(),
|
||||
define: definitions,
|
||||
format: "esm",
|
||||
logOverride: {
|
||||
/**
|
||||
|
@ -1,29 +0,0 @@
|
||||
/**
|
||||
* @file ESBuild environment utilities.
|
||||
*/
|
||||
import { AuthentikVersion, NodeEnvironment, serializeEnvironmentVars } from "@goauthentik/monorepo";
|
||||
|
||||
/**
|
||||
* Creates a mapping of environment variables to their respective runtime constants.
|
||||
*/
|
||||
export function createBundleDefinitions() {
|
||||
const SerializedNodeEnvironment = /** @type {`"development"` | `"production"`} */ (
|
||||
JSON.stringify(NodeEnvironment)
|
||||
);
|
||||
|
||||
/**
|
||||
* @satisfies {Record<ESBuildImportEnvKey, string>}
|
||||
*/
|
||||
const envRecord = {
|
||||
AK_VERSION: AuthentikVersion,
|
||||
AK_API_BASE_PATH: process.env.AK_API_BASE_PATH ?? "",
|
||||
};
|
||||
|
||||
return {
|
||||
...serializeEnvironmentVars(envRecord),
|
||||
// We need to explicitly set this for NPM packages that use `process`
|
||||
// to determine their environment.
|
||||
"process.env.NODE_ENV": SerializedNodeEnvironment,
|
||||
"import.meta.env.NODE_ENV": SerializedNodeEnvironment,
|
||||
};
|
||||
}
|
@ -35,11 +35,6 @@ const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const projectRoot = path.join(__dirname, "..");
|
||||
process.chdir(projectRoot);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} flags
|
||||
* @returns
|
||||
*/
|
||||
const hasFlag = (flags) => process.argv.length > 1 && flags.includes(process.argv[2]);
|
||||
|
||||
const [configFile, files] = hasFlag(["-n", "--nightmare"])
|
||||
|
@ -1,36 +1,22 @@
|
||||
/**
|
||||
* @file Pseudo-localization script.
|
||||
*
|
||||
* @import { ConfigFile } from "@lit/localize-tools/lib/types/config.js"
|
||||
* @import { Config } from '@lit/localize-tools/lib/types/config.js';
|
||||
* @import { ProgramMessage } from "@lit/localize-tools/src/messages.js"
|
||||
* @import { Locale } from "@lit/localize-tools/src/types/locale.js"
|
||||
*/
|
||||
import { PackageRoot } from "@goauthentik/web/paths";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import pseudolocale from "pseudolocale";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
import { makeFormatter } from "@lit/localize-tools/lib/formatters/index.js";
|
||||
import { sortProgramMessages } from "@lit/localize-tools/lib/messages.js";
|
||||
import { TransformLitLocalizer } from "@lit/localize-tools/lib/modes/transform.js";
|
||||
|
||||
const pseudoLocale = /** @type {Locale} */ ("pseudo-LOCALE");
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const pseudoLocale = "pseudo-LOCALE";
|
||||
const targetLocales = [pseudoLocale];
|
||||
|
||||
/**
|
||||
* @type {ConfigFile}
|
||||
*/
|
||||
const baseConfig = JSON.parse(readFileSync(path.join(PackageRoot, "lit-localize.json"), "utf-8"));
|
||||
const baseConfig = JSON.parse(readFileSync(path.join(__dirname, "../lit-localize.json"), "utf-8"));
|
||||
|
||||
// Need to make some internal specifications to satisfy the transformer. It doesn't actually matter
|
||||
// which Localizer we use (transformer or runtime), because all of the functionality we care about
|
||||
// is in their common parent class, but I had to pick one. Everything else here is just pure
|
||||
// exploitation of the lit/localize-tools internals.
|
||||
|
||||
/**
|
||||
* @satisfies {Config}
|
||||
*/
|
||||
const config = {
|
||||
...baseConfig,
|
||||
baseDir: path.join(__dirname, ".."),
|
||||
@ -42,11 +28,6 @@ const config = {
|
||||
resolve: (path) => path,
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {ProgramMessage} message
|
||||
* @returns
|
||||
*/
|
||||
const pseudoMessagify = (message) => ({
|
||||
name: message.name,
|
||||
contents: message.contents.map((content) =>
|
||||
@ -55,7 +36,7 @@ const pseudoMessagify = (message) => ({
|
||||
});
|
||||
|
||||
const localizer = new TransformLitLocalizer(config);
|
||||
const { messages } = localizer.extractSourceMessages();
|
||||
const messages = localizer.extractSourceMessages().messages;
|
||||
const translations = messages.map(pseudoMessagify);
|
||||
const sorted = sortProgramMessages([...messages]);
|
||||
const formatter = makeFormatter(config);
|
||||
|
@ -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, eventOptions, property, query } from "lit/decorators.js";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
@ -43,7 +43,7 @@ import {
|
||||
renderSidebarItems,
|
||||
} from "./AdminSidebar.js";
|
||||
|
||||
if (import.meta.env.NODE_ENV === "development") {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
await import("@goauthentik/esbuild-plugin-live-reload/client");
|
||||
}
|
||||
|
||||
@ -52,33 +52,28 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
|
||||
//#region Properties
|
||||
|
||||
@property({ type: Boolean })
|
||||
public notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
|
||||
@property({ type: Boolean })
|
||||
public apiDrawerOpen = getURLParam("apiDrawerOpen", false);
|
||||
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
|
||||
|
||||
protected readonly ws: WebsocketClient;
|
||||
ws: WebsocketClient;
|
||||
|
||||
@property({
|
||||
type: Object,
|
||||
attribute: false,
|
||||
reflect: false,
|
||||
})
|
||||
public user?: SessionUser;
|
||||
@state()
|
||||
user?: SessionUser;
|
||||
|
||||
@query("ak-about-modal")
|
||||
public aboutModal?: AboutModal;
|
||||
aboutModal?: AboutModal;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public sidebarOpen: boolean;
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
protected sidebarListener(event: CustomEvent<SidebarToggleEventDetail>) {
|
||||
this.sidebarOpen = !!event.detail.open;
|
||||
}
|
||||
#toggleSidebar = () => {
|
||||
this.sidebarOpen = !this.sidebarOpen;
|
||||
};
|
||||
|
||||
#sidebarMatcher: MediaQueryList;
|
||||
#sidebarMediaQueryListener = (event: MediaQueryListEvent) => {
|
||||
#sidebarListener = (event: MediaQueryListEvent) => {
|
||||
this.sidebarOpen = event.matches;
|
||||
};
|
||||
|
||||
@ -86,48 +81,50 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
|
||||
|
||||
//#region Styles
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.pf-c-page {
|
||||
background-color: var(--pf-c-page--BackgroundColor) !important;
|
||||
}
|
||||
|
||||
:host([theme="dark"]) {
|
||||
/* Global page background colour */
|
||||
.pf-c-page {
|
||||
--pf-c-page--BackgroundColor: var(--ak-dark-background);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
ak-page-navbar {
|
||||
grid-area: header;
|
||||
}
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ak-sidebar {
|
||||
grid-area: nav;
|
||||
}
|
||||
.pf-c-page {
|
||||
background-color: var(--pf-c-page--BackgroundColor) !important;
|
||||
}
|
||||
|
||||
.pf-c-drawer__panel {
|
||||
z-index: var(--pf-global--ZIndex--xl);
|
||||
}
|
||||
`,
|
||||
];
|
||||
: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-sidebar {
|
||||
grid-area: nav;
|
||||
}
|
||||
|
||||
.pf-c-drawer__panel {
|
||||
z-index: var(--pf-global--ZIndex--xl);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
@ -144,6 +141,8 @@ 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({
|
||||
@ -158,14 +157,13 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
|
||||
});
|
||||
});
|
||||
|
||||
this.#sidebarMatcher.addEventListener("change", this.#sidebarMediaQueryListener, {
|
||||
passive: true,
|
||||
});
|
||||
this.#sidebarMatcher.addEventListener("change", this.#sidebarListener);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
|
||||
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
|
||||
this.#sidebarMatcher.removeEventListener("change", this.#sidebarListener);
|
||||
}
|
||||
|
||||
async firstUpdated(): Promise<void> {
|
||||
@ -198,7 +196,7 @@ export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
|
||||
|
||||
return html` <ak-locale-context>
|
||||
<div class="pf-c-page">
|
||||
<ak-page-navbar ?open=${this.sidebarOpen} @sidebar-toggle=${this.sidebarListener}>
|
||||
<ak-page-navbar>
|
||||
<ak-version-banner></ak-version-banner>
|
||||
<ak-enterprise-status interface="admin"></ak-enterprise-status>
|
||||
</ak-page-navbar>
|
||||
|
@ -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 = "";
|
||||
|
@ -45,9 +45,9 @@ const providerListArgs = (page: number, search = "") => ({
|
||||
});
|
||||
|
||||
const dualSelectPairMaker = (item: ProviderBase): DualSelectPair => {
|
||||
const label =
|
||||
item.assignedBackchannelApplicationName || item.assignedApplicationName || item.name;
|
||||
|
||||
const label = item.assignedBackchannelApplicationName
|
||||
? item.assignedBackchannelApplicationName
|
||||
: item.assignedApplicationName;
|
||||
return [
|
||||
`${item.pk}`,
|
||||
html`<div class="selection-main">${label}</div>
|
||||
|
@ -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, country.name];
|
||||
return [country.code, country.name];
|
||||
}
|
||||
|
||||
@customElement("ak-policy-geoip-form")
|
||||
@ -210,16 +210,17 @@ 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) => ({
|
||||
options: results.map(countryToPair),
|
||||
}));
|
||||
.then((results) => {
|
||||
return {
|
||||
options: results.map(countryToPair),
|
||||
};
|
||||
});
|
||||
}}
|
||||
.selected=${(this.instance?.countriesObj ?? []).map(countryToPair)}
|
||||
available-label="${msg("Available Countries")}"
|
||||
|
@ -1,14 +1,19 @@
|
||||
import {
|
||||
CSRFHeaderName,
|
||||
CSRFMiddleware,
|
||||
EventMiddleware,
|
||||
LoggingMiddleware,
|
||||
} from "@goauthentik/common/api/middleware.js";
|
||||
import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants.js";
|
||||
import { globalAK } from "@goauthentik/common/global.js";
|
||||
} from "@goauthentik/common/api/middleware";
|
||||
import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
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) {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants.js";
|
||||
import { getCookie } from "@goauthentik/common/utils.js";
|
||||
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
|
||||
import { getCookie } from "@goauthentik/common/utils";
|
||||
|
||||
import {
|
||||
CurrentBrand,
|
||||
|
@ -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.1";
|
||||
export const VERSION = "2025.4.0";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
@ -11,6 +11,7 @@ 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";
|
||||
|
@ -1,12 +1,3 @@
|
||||
/**
|
||||
* @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 {
|
||||
|
@ -1,48 +1,42 @@
|
||||
/**
|
||||
* @file Atom One Dark syntax highlighting theme.
|
||||
*
|
||||
* @see 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;
|
||||
}
|
||||
Atom One Dark by Daniel Gamage
|
||||
Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax
|
||||
|
||||
: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;
|
||||
}
|
||||
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
|
||||
|
||||
*/
|
||||
|
||||
.hljs {
|
||||
color: var(--one-dark-mono-1);
|
||||
background: var(--one-dark-base);
|
||||
color: #abb2bf;
|
||||
background: #282c34;
|
||||
}
|
||||
|
||||
pre:has(.hljs) {
|
||||
background: var(--one-dark-base);
|
||||
background: #282c34;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: var(--one-dark-mono-3);
|
||||
color: #5c6370;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-keyword,
|
||||
.hljs-formula {
|
||||
color: var(--one-dark-hue-3);
|
||||
color: #c678dd;
|
||||
}
|
||||
|
||||
.hljs-section,
|
||||
@ -50,11 +44,11 @@ pre:has(.hljs) {
|
||||
.hljs-selector-tag,
|
||||
.hljs-deletion,
|
||||
.hljs-subst {
|
||||
color: var(--one-dark-hue-5);
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: var(--one-dark-hue-1);
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
@ -62,7 +56,7 @@ pre:has(.hljs) {
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta .hljs-string {
|
||||
color: var(--one-dark-hue-4);
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
@ -73,7 +67,7 @@ pre:has(.hljs) {
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-number {
|
||||
color: var(--one-dark-hue-6);
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
.hljs-symbol,
|
||||
@ -82,13 +76,13 @@ pre:has(.hljs) {
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-title {
|
||||
color: var(--one-dark-hue-2);
|
||||
color: #61aeee;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-title.class_,
|
||||
.hljs-class .hljs-title {
|
||||
color: var(--one-dark-hue-6-2);
|
||||
color: #e6c07b;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
|
@ -1,12 +1,3 @@
|
||||
/**
|
||||
* @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 {
|
||||
@ -14,6 +5,9 @@
|
||||
--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 {
|
||||
@ -262,13 +256,8 @@ input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
color: var(--ak-dark-background-lighter);
|
||||
}
|
||||
|
||||
.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-plain:hover {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-control {
|
||||
|
@ -1,27 +1,17 @@
|
||||
/**
|
||||
* @file Stylesheet utilities.
|
||||
*/
|
||||
import { CSSResultOrNative, ReactiveElement, adoptStyles as adoptStyleSheetsShim, css } from "lit";
|
||||
import { CSSResult, CSSResultOrNative, ReactiveElement, css } from "lit";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Elements containing adoptable stylesheets.
|
||||
*/
|
||||
export type StyleRoot =
|
||||
| Document
|
||||
| ShadowRoot
|
||||
| DocumentFragment
|
||||
| HTMLElement
|
||||
| DocumentOrShadowRoot;
|
||||
export type StyleSheetParent = Pick<DocumentOrShadowRoot, "adoptedStyleSheets">;
|
||||
|
||||
/**
|
||||
* Type-predicate to determine if a given object has adoptable stylesheets.
|
||||
*/
|
||||
export function isStyleRoot(input: StyleRoot): input is ShadowRoot {
|
||||
export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheetParent {
|
||||
// Sanity check - Does the input have the right shape?
|
||||
|
||||
if (!input || typeof input !== "object") return false;
|
||||
@ -35,12 +25,39 @@ export function isStyleRoot(input: StyleRoot): input is ShadowRoot {
|
||||
// All we care about is that it's shaped like an array.
|
||||
if (!("length" in input.adoptedStyleSheets)) return false;
|
||||
|
||||
return typeof input.adoptedStyleSheets.length === "number";
|
||||
if (typeof input.adoptedStyleSheets.length !== "number") return false;
|
||||
|
||||
// Finally is the array mutable?
|
||||
return "push" in input.adoptedStyleSheets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lazy-loaded `CSSResult` compatible with Lit's
|
||||
* element lifecycle.
|
||||
* Assert that the given input can adopt stylesheets.
|
||||
*/
|
||||
export function assertAdoptableStyleSheetParent<T>(
|
||||
input: T,
|
||||
): asserts input is T & StyleSheetParent {
|
||||
if (isAdoptableStyleSheetParent(input)) return;
|
||||
|
||||
console.debug("Given input missing `adoptedStyleSheets`", input);
|
||||
|
||||
throw new TypeError("Assertion failed: `adoptedStyleSheets` missing in given input");
|
||||
}
|
||||
|
||||
export function resolveStyleSheetParent<T extends HTMLElement | DocumentFragment | Document>(
|
||||
renderRoot: T,
|
||||
) {
|
||||
const styleRoot = "ShadyDOM" in window ? document : renderRoot;
|
||||
|
||||
assertAdoptableStyleSheetParent(styleRoot);
|
||||
|
||||
return styleRoot;
|
||||
}
|
||||
|
||||
export type StyleSheetInit = string | CSSResult | CSSStyleSheet;
|
||||
|
||||
/**
|
||||
* Given a source of CSS, create a `CSSStyleSheet`.
|
||||
*
|
||||
* @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
|
||||
*
|
||||
@ -51,12 +68,8 @@ export function isStyleRoot(input: StyleRoot): input is ShadowRoot {
|
||||
*
|
||||
* 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 createCSSResult(input: string | CSSModule | CSSResultOrNative): CSSResultOrNative {
|
||||
if (typeof input !== "string") return input;
|
||||
|
||||
export function createStyleSheet(input: string): CSSResult {
|
||||
const inputTemplate = [input] as unknown as TemplateStringsArray;
|
||||
|
||||
const result = css(inputTemplate, []);
|
||||
@ -65,91 +78,74 @@ export function createCSSResult(input: string | CSSModule | CSSResultOrNative):
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `CSSStyleSheet` from the given input, if it is not already a `CSSStyleSheet`.
|
||||
* Given a source of CSS, create a `CSSStyleSheet`.
|
||||
*
|
||||
* @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
|
||||
*
|
||||
* @see {@linkcode createCSSResult} for the lazy-loaded `CSSResult` normalization.
|
||||
* @see {@linkcode createStyleSheet}
|
||||
*/
|
||||
export function createStyleSheetUnsafe(
|
||||
input: string | CSSModule | CSSResultOrNative,
|
||||
): CSSStyleSheet {
|
||||
const result = typeof input === "string" ? createCSSResult(input) : input;
|
||||
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);
|
||||
|
||||
if (result instanceof CSSStyleSheet) return result;
|
||||
|
||||
if (result.styleSheet) return result.styleSheet;
|
||||
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
|
||||
styleSheet.replaceSync(result.cssText);
|
||||
|
||||
return styleSheet;
|
||||
return input;
|
||||
}
|
||||
|
||||
export type StyleSheetsAction =
|
||||
| Iterable<CSSStyleSheet>
|
||||
| ((currentStyleSheets: CSSStyleSheet[]) => Iterable<CSSStyleSheet>);
|
||||
|
||||
/**
|
||||
* Set the adopted stylesheets of a given style parent.
|
||||
*
|
||||
* ```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.
|
||||
* Create a `CSSStyleSheet` from the given input.
|
||||
*/
|
||||
export function setAdoptedStyleSheets(styleRoot: StyleRoot, styleSheets: StyleSheetsAction): void {
|
||||
let changed = false;
|
||||
export function createStyleSheetUnsafe(input: StyleSheetInit): CSSStyleSheet {
|
||||
const result = normalizeCSSSource(input);
|
||||
if (result instanceof CSSStyleSheet) return result;
|
||||
|
||||
const currentAdoptedStyleSheets = isStyleRoot(styleRoot)
|
||||
? [...styleRoot.adoptedStyleSheets]
|
||||
: [];
|
||||
if (!result.styleSheet) {
|
||||
console.debug(
|
||||
"authentik/common/stylesheets: CSSResult missing styleSheet, returning empty",
|
||||
{ result, input },
|
||||
);
|
||||
|
||||
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);
|
||||
throw new TypeError("Expected a CSSStyleSheet");
|
||||
}
|
||||
|
||||
changed ||= nextAdoptedStyleSheets.length !== currentAdoptedStyleSheets.length;
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
if (styleRoot === document) {
|
||||
document.adoptedStyleSheets = nextAdoptedStyleSheets;
|
||||
return;
|
||||
}
|
||||
|
||||
adoptStyleSheetsShim(styleRoot as unknown as ShadowRoot, nextAdoptedStyleSheets);
|
||||
return result.styleSheet;
|
||||
}
|
||||
|
||||
//#region Debugging
|
||||
/**
|
||||
* Append stylesheet(s) to the given roots.
|
||||
*
|
||||
* @see {@linkcode removeStyleSheet} to remove a stylesheet from a given roots.
|
||||
*/
|
||||
export function appendStyleSheet(
|
||||
styleParent: StyleSheetParent,
|
||||
...insertions: CSSStyleSheet[]
|
||||
): void {
|
||||
insertions = Array.isArray(insertions) ? insertions : [insertions];
|
||||
|
||||
for (const styleSheetInsertion of insertions) {
|
||||
if (styleParent.adoptedStyleSheets.includes(styleSheetInsertion)) return;
|
||||
|
||||
styleParent.adoptedStyleSheets = [...styleParent.adoptedStyleSheets, styleSheetInsertion];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a stylesheet to a string.
|
||||
@ -163,8 +159,8 @@ export function serializeStyleSheet(stylesheet: CSSStyleSheet): string {
|
||||
/**
|
||||
* Inspect the adopted stylesheets of a given style parent, serializing them to strings.
|
||||
*/
|
||||
export function inspectStyleSheets(styleRoot: ShadowRoot): string[] {
|
||||
return styleRoot.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
|
||||
export function inspectStyleSheets(styleParent: StyleSheetParent): string[] {
|
||||
return styleParent.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
|
||||
}
|
||||
|
||||
interface InspectedStyleSheetEntry {
|
||||
@ -178,11 +174,8 @@ interface InspectedStyleSheetEntry {
|
||||
* Recursively inspect the adopted stylesheets of a given style parent, serializing them to strings.
|
||||
*/
|
||||
export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleSheetEntry {
|
||||
if (!isStyleRoot(element.renderRoot)) {
|
||||
throw new TypeError("Cannot inspect a render root that doesn't have adoptable stylesheets");
|
||||
}
|
||||
|
||||
const styles = inspectStyleSheets(element.renderRoot);
|
||||
const styleParent = resolveStyleSheetParent(element.renderRoot);
|
||||
const styles = inspectStyleSheets(styleParent);
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
const treewalker = document.createTreeWalker(element.renderRoot, NodeFilter.SHOW_ELEMENT, {
|
||||
@ -193,14 +186,12 @@ 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 (!isStyleRoot(childElement.renderRoot)) {
|
||||
if (!isAdoptableStyleSheetParent(childElement.renderRoot)) {
|
||||
currentNode = treewalker.nextNode();
|
||||
continue;
|
||||
}
|
||||
@ -223,12 +214,10 @@ export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleS
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.env.NODE_ENV === "development") {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
Object.assign(window, {
|
||||
inspectStyleSheetTree,
|
||||
serializeStyleSheet,
|
||||
inspectStyleSheets,
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
@ -1,47 +1,10 @@
|
||||
/**
|
||||
* @file Theme utilities.
|
||||
*/
|
||||
import {
|
||||
type StyleRoot,
|
||||
createStyleSheetUnsafe,
|
||||
setAdoptedStyleSheets,
|
||||
} from "@goauthentik/web/common/stylesheets.js";
|
||||
import { UIConfig } from "@goauthentik/web/common/ui/config.js";
|
||||
|
||||
import AKBase from "@goauthentik/web/common/styles/authentik.css";
|
||||
import AKBaseDark from "@goauthentik/web/common/styles/theme-dark.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
import { UIConfig } from "@goauthentik/common/ui/config";
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
@ -171,21 +134,15 @@ export function resolveUITheme(
|
||||
* Effect listener invoked when the color scheme changes.
|
||||
*/
|
||||
export type UIThemeListener = (currentUITheme: ResolvedUITheme) => void;
|
||||
|
||||
/**
|
||||
* Effect destructor invoked when cleanup is required.
|
||||
*/
|
||||
export type UIThemeDestructor = () => void;
|
||||
|
||||
/**
|
||||
* Create an effect that runs UI theme changes.
|
||||
* Create an effect that runs
|
||||
*
|
||||
* @returns A cleanup function that removes the effect.
|
||||
*/
|
||||
export function createUIThemeEffect(
|
||||
effect: UIThemeListener,
|
||||
listenerOptions?: AddEventListenerOptions,
|
||||
): UIThemeDestructor {
|
||||
): () => void {
|
||||
const colorSchemeTarget = resolveUITheme();
|
||||
const invertedColorSchemeTarget = UIThemeInversion[colorSchemeTarget];
|
||||
|
||||
@ -217,8 +174,6 @@ export function createUIThemeEffect(
|
||||
mediaQueryList.removeEventListener("change", changeListener);
|
||||
};
|
||||
|
||||
listenerOptions?.signal?.addEventListener("abort", cleanup);
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@ -226,96 +181,16 @@ 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 {
|
||||
/**
|
||||
* 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;
|
||||
brand?: CurrentBrand;
|
||||
uiConfig?: UIConfig;
|
||||
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]");
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { me } from "@goauthentik/common/users.js";
|
||||
import { isUserRoute } from "@goauthentik/elements/router/utils.js";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { isUserRoute } from "@goauthentik/elements/router/utils";
|
||||
|
||||
import { UiThemeEnum, UserSelf } from "@goauthentik/api";
|
||||
import { CurrentBrand } from "@goauthentik/api";
|
||||
|
@ -1,6 +1,6 @@
|
||||
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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
|
||||
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
|
||||
|
||||
import { CoreApi, SessionUser } from "@goauthentik/api";
|
||||
|
||||
|
@ -31,20 +31,19 @@ const container = (testItem: TemplateResult) =>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ak-hint {
|
||||
--ak-hint--Color: var(--pf-global--Color--dark-100);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
ak-hint {
|
||||
--ak-hint--Color: var(--pf-global--Color--light-100);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
color: black;
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
* {
|
||||
--ak-hint--Color: black !important;
|
||||
}
|
||||
ak-hint-title::part(ak-hint-title),
|
||||
ak-hint-footer::part(ak-hint-footer),
|
||||
slotted::(*) {
|
||||
color: black;
|
||||
}
|
||||
</style>
|
||||
|
||||
${testItem}
|
||||
|
@ -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 { css, html, nothing } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
@ -64,15 +64,7 @@ export class Alert extends AKElement implements IAlert {
|
||||
icon = "fa-exclamation-circle";
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFAlert,
|
||||
css`
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
return [PFBase, PFAlert];
|
||||
}
|
||||
|
||||
get classmap() {
|
||||
|
@ -1,24 +1,30 @@
|
||||
import { globalAK } from "@goauthentik/common/global.js";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import {
|
||||
StyleRoot,
|
||||
createCSSResult,
|
||||
StyleSheetInit,
|
||||
StyleSheetParent,
|
||||
appendStyleSheet,
|
||||
createStyleSheetUnsafe,
|
||||
} from "@goauthentik/common/stylesheets.js";
|
||||
removeStyleSheet,
|
||||
resolveStyleSheetParent,
|
||||
} from "@goauthentik/common/stylesheets";
|
||||
import {
|
||||
$AKBase,
|
||||
CSSColorSchemeValue,
|
||||
ResolvedUITheme,
|
||||
ThemedElement,
|
||||
applyUITheme,
|
||||
UIThemeListener,
|
||||
createUIThemeEffect,
|
||||
formatColorScheme,
|
||||
resolveUITheme,
|
||||
} from "@goauthentik/common/theme.js";
|
||||
} from "@goauthentik/common/theme";
|
||||
import { type ThemedElement } from "@goauthentik/common/theme";
|
||||
|
||||
import { localized } from "@lit/localize";
|
||||
import { CSSResult, CSSResultGroup, CSSResultOrNative, LitElement } from "lit";
|
||||
import { CSSResultGroup, CSSResultOrNative, LitElement } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
|
||||
import AKGlobal from "@goauthentik/common/styles/authentik.css";
|
||||
import OneDark from "@goauthentik/common/styles/one-dark.css";
|
||||
import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
|
||||
|
||||
import { UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
// Re-export the theme helpers
|
||||
@ -26,58 +32,6 @@ 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
|
||||
|
||||
/**
|
||||
@ -99,54 +53,87 @@ export class AKElement extends LitElement implements ThemedElement {
|
||||
|
||||
//#region Private Properties
|
||||
|
||||
/**
|
||||
* The preferred color scheme used to look up the UI theme.
|
||||
*/
|
||||
protected readonly preferredColorScheme: CSSColorSchemeValue;
|
||||
readonly #preferredColorScheme: CSSColorSchemeValue;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
#customCSSStyleSheet: CSSStyleSheet | null;
|
||||
#darkThemeStyleSheet: CSSStyleSheet | null = null;
|
||||
#themeAbortController: AbortController | null = null;
|
||||
/**
|
||||
* The style root to which the theme is applied.
|
||||
*/
|
||||
#styleRoot?: StyleRoot;
|
||||
|
||||
protected set styleRoot(nextStyleRoot: StyleRoot | undefined) {
|
||||
//#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();
|
||||
this.#themeAbortController?.abort();
|
||||
}
|
||||
|
||||
this.#styleRoot = nextStyleRoot;
|
||||
#styleRoot?: StyleSheetParent;
|
||||
|
||||
if (!nextStyleRoot) return;
|
||||
#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);
|
||||
}
|
||||
|
||||
this.#themeAbortController = new AbortController();
|
||||
|
||||
if (this.preferredColorScheme === "dark") {
|
||||
applyUITheme(nextStyleRoot, UiThemeEnum.Dark, this.#customCSSStyleSheet);
|
||||
|
||||
this.activeTheme = UiThemeEnum.Dark;
|
||||
} else if (this.preferredColorScheme === "auto") {
|
||||
createUIThemeEffect(
|
||||
(nextUITheme) => {
|
||||
applyUITheme(nextStyleRoot, nextUITheme, this.#customCSSStyleSheet);
|
||||
|
||||
this.activeTheme = nextUITheme;
|
||||
},
|
||||
{
|
||||
signal: this.#themeAbortController.signal,
|
||||
},
|
||||
);
|
||||
if (this.#preferredColorScheme === "dark") {
|
||||
this.#dispatchTheme(UiThemeEnum.Dark);
|
||||
} else if (this.#preferredColorScheme === "auto") {
|
||||
createUIThemeEffect(this.#dispatchTheme, {
|
||||
signal: this.#themeAbortController.signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected get styleRoot(): StyleRoot | undefined {
|
||||
return this.#styleRoot;
|
||||
return renderRoot;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
@ -1,45 +1,45 @@
|
||||
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 {
|
||||
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 { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
|
||||
|
||||
import { state } from "lit/decorators.js";
|
||||
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import {
|
||||
type Config,
|
||||
type CurrentBrand,
|
||||
type LicenseSummary,
|
||||
type Version,
|
||||
} from "@goauthentik/api";
|
||||
import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api";
|
||||
|
||||
import { BrandContextController } from "./BrandContextController.js";
|
||||
import { ConfigContextController } from "./ConfigContextController.js";
|
||||
import { EnterpriseContextController } from "./EnterpriseContextController.js";
|
||||
import { BrandContextController } from "./BrandContextController";
|
||||
import { ConfigContextController } from "./ConfigContextController";
|
||||
import { EnterpriseContextController } from "./EnterpriseContextController";
|
||||
|
||||
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();
|
||||
|
||||
if (!document.documentElement.dataset.theme) {
|
||||
applyDocumentTheme(globalAK().brand.uiTheme);
|
||||
}
|
||||
appendStyleSheet(styleParent, Interface.PFBaseStyleSheet);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Interface extends LightInterface implements ThemedElement {
|
||||
static styles = [PFBase];
|
||||
protected [configContext]: ConfigContextController;
|
||||
[configContext]: ConfigContextController;
|
||||
|
||||
protected [modalController]: ModalOrchestrationController;
|
||||
[modalController]: ModalOrchestrationController;
|
||||
|
||||
@state()
|
||||
public config?: Config;
|
||||
@ -49,7 +49,6 @@ 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);
|
||||
|
@ -1,4 +1,8 @@
|
||||
import { EVENT_WS_MESSAGE, TITLE_DEFAULT } from "@goauthentik/common/constants";
|
||||
import {
|
||||
EVENT_SIDEBAR_TOGGLE,
|
||||
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";
|
||||
@ -25,14 +29,6 @@ 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 {
|
||||
@ -49,10 +45,7 @@ export interface PageNavbarDetails {
|
||||
* dispatched by the `ak-page-header` component.
|
||||
*/
|
||||
@customElement("ak-page-navbar")
|
||||
export class AKPageNavbar
|
||||
extends WithBrandConfig(AKElement)
|
||||
implements PageNavbarDetails, SidebarToggleEventDetail
|
||||
{
|
||||
export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails {
|
||||
//#region Static Properties
|
||||
|
||||
private static elementRef: AKPageNavbar | null = null;
|
||||
@ -267,31 +260,29 @@ export class AKPageNavbar
|
||||
|
||||
//#region Properties
|
||||
|
||||
@state()
|
||||
@property({ type: String })
|
||||
icon?: string;
|
||||
|
||||
@state()
|
||||
@property({ type: Boolean })
|
||||
iconImage = false;
|
||||
|
||||
@state()
|
||||
@property({ type: String })
|
||||
header?: string;
|
||||
|
||||
@state()
|
||||
@property({ type: String })
|
||||
description?: string;
|
||||
|
||||
@state()
|
||||
@property({ type: Boolean })
|
||||
hasIcon = true;
|
||||
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public open?: boolean;
|
||||
@property({ type: Boolean })
|
||||
open = true;
|
||||
|
||||
@state()
|
||||
protected session?: SessionUser;
|
||||
session?: SessionUser;
|
||||
|
||||
@state()
|
||||
protected uiConfig!: UIConfig;
|
||||
uiConfig!: UIConfig;
|
||||
|
||||
//#endregion
|
||||
|
||||
@ -314,10 +305,9 @@ export class AKPageNavbar
|
||||
this.open = !this.open;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<SidebarToggleEventDetail>("sidebar-toggle", {
|
||||
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { open: this.open },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ 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 {
|
||||
/**
|
||||
@ -22,24 +23,20 @@ export class AkDualSelectDynamic extends AkDualSelectProvider {
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
selector: (_: DualSelectPair[]) => Promise<DualSelectPair[]> = () => Promise.resolve([]);
|
||||
selector: (_: DualSelectPair[]) => Promise<DualSelectPair[]> = async (_) => Promise.resolve([]);
|
||||
|
||||
#didFirstUpdate = false;
|
||||
private firstUpdateHasRun = 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.#didFirstUpdate) return;
|
||||
if (this.options.length === 0) return;
|
||||
|
||||
this.#didFirstUpdate = true;
|
||||
|
||||
this.selector(this.options).then((selected) => {
|
||||
this.selected = selected;
|
||||
});
|
||||
if (!this.firstUpdateHasRun && this.options.length > 0) {
|
||||
this.firstUpdateHasRun = true;
|
||||
this.selector(this.options).then((selected) => {
|
||||
this.selected = selected;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -1,16 +1,18 @@
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter.js";
|
||||
import { debounce } from "@goauthentik/elements/utils/debounce";
|
||||
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
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.js";
|
||||
import { AkDualSelect } from "./ak-dual-select.js";
|
||||
import { type DataProvider, DualSelectEventType, type DualSelectPair } from "./types.js";
|
||||
import "./ak-dual-select";
|
||||
import { AkDualSelect } from "./ak-dual-select";
|
||||
import type { DataProvider, DualSelectPair } from "./types";
|
||||
|
||||
/**
|
||||
* @element ak-dual-select-provider
|
||||
@ -20,19 +22,18 @@ import { type DataProvider, DualSelectEventType, type DualSelectPair } from "./t
|
||||
* 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) {
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* A function that takes a page and returns the {@linkcode DualSelectPair DualSelectPair[]}
|
||||
* collection with which to update the "Available" pane.
|
||||
/** A function that takes a page and returns the DualSelectPair[] collection with which to update
|
||||
* the "Available" pane.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Object })
|
||||
public provider!: DataProvider;
|
||||
provider!: DataProvider;
|
||||
|
||||
/**
|
||||
* The list of selected items. This is the *complete* list, not paginated, as presented by a
|
||||
@ -41,7 +42,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Array })
|
||||
public selected: DualSelectPair[] = [];
|
||||
selected: DualSelectPair[] = [];
|
||||
|
||||
/**
|
||||
* The label for the left ("available") pane
|
||||
@ -49,7 +50,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: "available-label" })
|
||||
public availableLabel = msg("Available options");
|
||||
availableLabel = msg("Available options");
|
||||
|
||||
/**
|
||||
* The label for the right ("selected") pane
|
||||
@ -57,7 +58,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: "selected-label" })
|
||||
public selectedLabel = msg("Selected options");
|
||||
selectedLabel = msg("Selected options");
|
||||
|
||||
/**
|
||||
* The debounce for the search as the user is typing in a request
|
||||
@ -65,125 +66,103 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: "search-delay", type: Number })
|
||||
public searchDelay = 250;
|
||||
|
||||
public get value() {
|
||||
return this.dualSelector.value!.selected.map(([k, _]) => k);
|
||||
}
|
||||
|
||||
public json() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region State
|
||||
searchDelay = 250;
|
||||
|
||||
@state()
|
||||
protected options: DualSelectPair[] = [];
|
||||
options: DualSelectPair[] = [];
|
||||
|
||||
#loading = false;
|
||||
protected dualSelector: Ref<AkDualSelect> = createRef();
|
||||
|
||||
#didFirstUpdate = false;
|
||||
#selected: DualSelectPair[] = [];
|
||||
protected isLoading = false;
|
||||
|
||||
#previousSearchValue = "";
|
||||
private doneFirstUpdate = false;
|
||||
private internalSelected: DualSelectPair[] = [];
|
||||
|
||||
protected pagination?: Pagination;
|
||||
|
||||
//#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);
|
||||
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);
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("selected") && !this.#didFirstUpdate) {
|
||||
this.#didFirstUpdate = true;
|
||||
this.#selected = this.selected;
|
||||
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("provider")) {
|
||||
this.pagination = undefined;
|
||||
this.#previousSearchValue = "";
|
||||
this.#fetch();
|
||||
this.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
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;
|
||||
}
|
||||
|
||||
//#region Private Methods
|
||||
onNav(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`);
|
||||
}
|
||||
this.fetch(event.detail);
|
||||
}
|
||||
|
||||
#fetch = async (page?: number, search = this.#previousSearchValue): Promise<void> => {
|
||||
if (this.#loading) return;
|
||||
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;
|
||||
}
|
||||
|
||||
this.#previousSearchValue = search;
|
||||
this.#loading = true;
|
||||
onSearch(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
|
||||
}
|
||||
this.doSearch(event.detail);
|
||||
}
|
||||
|
||||
page ??= this.pagination?.current ?? 1;
|
||||
doSearch(search: string) {
|
||||
this.pagination = undefined;
|
||||
this.fetch(undefined, search);
|
||||
}
|
||||
|
||||
return this.provider(page, search)
|
||||
.then((data) => {
|
||||
this.pagination = data.pagination;
|
||||
this.options = data.options;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.#loading = false;
|
||||
});
|
||||
};
|
||||
get value() {
|
||||
return this.dualSelector.value!.selected.map(([k, _]) => k);
|
||||
}
|
||||
|
||||
//#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
|
||||
json() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<ak-dual-select
|
||||
${ref(this.dualSelector)}
|
||||
.options=${this.options}
|
||||
.pages=${this.pagination}
|
||||
.selected=${this.#selected}
|
||||
.selected=${this.internalSelected}
|
||||
available-label=${this.availableLabel}
|
||||
selected-label=${this.selectedLabel}
|
||||
></ak-dual-select>`;
|
||||
|
@ -3,7 +3,6 @@ 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";
|
||||
@ -16,41 +15,34 @@ 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.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 "./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 {
|
||||
BasePagination,
|
||||
DualSelectEventType,
|
||||
DualSelectPair,
|
||||
SearchbarEventDetail,
|
||||
SearchbarEventSource,
|
||||
} from "./types.js";
|
||||
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";
|
||||
|
||||
function localeComparator(a: DualSelectPair, b: DualSelectPair) {
|
||||
const aSortBy = a[2];
|
||||
const bSortBy = b[2];
|
||||
|
||||
return aSortBy.localeCompare(bSortBy);
|
||||
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 keyfinder(key: string) {
|
||||
return ([k]: DualSelectPair) => k === key;
|
||||
function mapDualPairs(pairs: DualSelectPair[]) {
|
||||
return new Map(pairs.map(([k, v, _]) => [k, v]));
|
||||
}
|
||||
|
||||
const DelegatedEvents = [
|
||||
DualSelectEventType.AddSelected,
|
||||
DualSelectEventType.RemoveSelected,
|
||||
DualSelectEventType.AddAll,
|
||||
DualSelectEventType.RemoveAll,
|
||||
DualSelectEventType.DeleteAll,
|
||||
DualSelectEventType.AddOne,
|
||||
DualSelectEventType.RemoveOne,
|
||||
] as const satisfies DualSelectEventType[];
|
||||
const styles = [PFBase, PFButton, globalVariables, mainStyles];
|
||||
|
||||
/**
|
||||
* @element ak-dual-select
|
||||
@ -61,25 +53,24 @@ const DelegatedEvents = [
|
||||
*
|
||||
* @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 styles = [PFBase, PFButton, globalVariables, mainStyles];
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
//#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.
|
||||
*/
|
||||
/* 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[] = [];
|
||||
|
||||
@ -92,133 +83,138 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
@property({ attribute: "selected-label" })
|
||||
selectedLabel = msg("Selected options");
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region State
|
||||
|
||||
@state()
|
||||
protected selectedFilter: string = "";
|
||||
|
||||
#selectedKeys: Set<string> = new Set();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Refs
|
||||
selectedFilter: string = "";
|
||||
|
||||
availablePane: Ref<AkDualSelectAvailablePane> = createRef();
|
||||
|
||||
selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
selectedKeys: Set<string> = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
for (const eventName of DelegatedEvents) {
|
||||
this.addCustomListener(eventName, this.#moveListener);
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
this.addCustomListener("ak-dual-select-move", () => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
this.addCustomListener("ak-search", this.#searchListener);
|
||||
this.addCustomListener("ak-search", this.handleSearch);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
handleMove(eventName: string, event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expected move event here, got ${eventName}`);
|
||||
}
|
||||
|
||||
//#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 });
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
protected addOne(key: string) {
|
||||
addOne(key: string) {
|
||||
const requested = this.options.find(keyfinder(key));
|
||||
|
||||
if (!requested) return;
|
||||
if (this.selected.find(keyfinder(requested[0]))) return;
|
||||
|
||||
this.selected = [...this.selected, requested];
|
||||
if (requested && !this.selected.find(keyfinder(requested[0]))) {
|
||||
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;
|
||||
protected addAllVisible() {
|
||||
addAllVisible() {
|
||||
// Create a new array of all current options and selected, and de-dupe.
|
||||
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());
|
||||
|
||||
const selected = mapDualPairs([...this.options, ...this.selected]);
|
||||
this.selected = Array.from(selected.entries());
|
||||
this.availablePane.value!.clearMove();
|
||||
}
|
||||
|
||||
protected removeSelected() {
|
||||
if (this.selectedPane.value!.moveable.length === 0) return;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
protected removeOne(key: string) {
|
||||
removeOne(key: string) {
|
||||
this.selected = this.selected.filter(([k]) => k !== key);
|
||||
}
|
||||
|
||||
protected removeAllVisible() {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -227,25 +223,24 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
#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();
|
||||
|
||||
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);
|
||||
}
|
||||
event.stopPropagation();
|
||||
};
|
||||
}
|
||||
|
||||
//#endregion
|
||||
handleAvailableSearch(value: string) {
|
||||
this.dispatchCustomEvent("ak-dual-select-search", value);
|
||||
}
|
||||
|
||||
//#region Public Getters
|
||||
handleSelectedSearch(value: string) {
|
||||
this.selectedFilter = value;
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.selected;
|
||||
@ -256,7 +251,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;
|
||||
}
|
||||
@ -264,8 +259,7 @@ 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))
|
||||
);
|
||||
}
|
||||
|
||||
@ -273,10 +267,6 @@ 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 === ""
|
||||
@ -292,15 +282,11 @@ 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.`) : " ";
|
||||
|
||||
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`
|
||||
@ -324,7 +310,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>`
|
||||
@ -358,14 +344,12 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
|
||||
<ak-dual-select-selected-pane
|
||||
${ref(this.selectedPane)}
|
||||
.selected=${selected.toSorted(localeComparator)}
|
||||
.selected=${selected.toSorted(alphaSort)}
|
||||
></ak-dual-select-selected-pane>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
@ -1,24 +1,26 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { PropertyValues, html, nothing } from "lit";
|
||||
import { 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 { DualSelectEventType, DualSelectPair } from "../types.js";
|
||||
import { EVENT_ADD_ONE } from "../constants";
|
||||
import type { DualSelectPair } from "../types";
|
||||
|
||||
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
|
||||
|
||||
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
|
||||
@ -35,108 +37,80 @@ 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<DualSelectEventType>(
|
||||
AKElement,
|
||||
) {
|
||||
static styles = [PFBase, PFButton, PFDualListSelector, listStyles, availablePaneStyles];
|
||||
|
||||
//#region Properties
|
||||
export class AkDualSelectAvailablePane extends CustomEmitterElement(AKElement) {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
/* 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();
|
||||
|
||||
//#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
|
||||
/* 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();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Refs
|
||||
|
||||
protected listRef = createRef<HTMLDivElement>();
|
||||
|
||||
//#region Lifecycle
|
||||
constructor() {
|
||||
super();
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onMove = this.onMove.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
for (const [attr, value] of hostAttributes) {
|
||||
hostAttributes.forEach(([attr, value]) => {
|
||||
if (!this.hasAttribute(attr)) {
|
||||
this.setAttribute(attr, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("options")) {
|
||||
this.listRef.value?.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
//#region Public API
|
||||
|
||||
public clearMove() {
|
||||
clearMove() {
|
||||
this.toMove = new Set();
|
||||
}
|
||||
|
||||
get moveable() {
|
||||
return Array.from(this.toMove.values());
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
#clickListener(key: string): void {
|
||||
if (this.selected.has(key)) return;
|
||||
|
||||
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(
|
||||
DualSelectEventType.MoveChanged,
|
||||
"ak-dual-select-available-move-changed",
|
||||
Array.from(this.toMove.values()).sort(),
|
||||
);
|
||||
|
||||
this.dispatchCustomEvent(DualSelectEventType.Move);
|
||||
|
||||
this.dispatchCustomEvent("ak-dual-select-move");
|
||||
// Necessary because updating a map won't trigger a state change
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
#moveListener(key: string): void {
|
||||
onMove(key: string) {
|
||||
this.toMove.delete(key);
|
||||
|
||||
this.dispatchCustomEvent(DualSelectEventType.AddOne, key);
|
||||
this.dispatchCustomEvent(EVENT_ADD_ONE, key);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
//#region Render
|
||||
get moveable() {
|
||||
return Array.from(this.toMove.values());
|
||||
}
|
||||
|
||||
// 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
|
||||
@ -145,18 +119,17 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div ${ref(this.listRef)} class="pf-c-dual-list-selector__menu">
|
||||
<div 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.#clickListener(key)}
|
||||
@dblclick=${() => this.#moveListener(key)}
|
||||
@click=${() => this.onClick(key)}
|
||||
@dblclick=${() => this.onMove(key)}
|
||||
role="option"
|
||||
data-ak-key=${key}
|
||||
tabindex="-1"
|
||||
@ -181,8 +154,6 @@ export class AkDualSelectAvailablePane extends CustomEmitterElement<DualSelectEv
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export default AkDualSelectAvailablePane;
|
||||
|
@ -8,7 +8,34 @@ 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 { DualSelectEventType } from "../types.js";
|
||||
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%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/**
|
||||
* @element ak-dual-select-controls
|
||||
@ -16,84 +43,64 @@ import { DualSelectEventType } from "../types.js";
|
||||
* 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<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.
|
||||
@customElement("ak-dual-select-controls")
|
||||
export class AkDualSelectControls extends CustomEmitterElement(AKElement) {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
renderButton(
|
||||
label: string,
|
||||
eventType: DualSelectEventType,
|
||||
active: boolean,
|
||||
direction: string,
|
||||
) {
|
||||
constructor() {
|
||||
super();
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick(eventName: string) {
|
||||
this.dispatchCustomEvent(eventName);
|
||||
}
|
||||
|
||||
renderButton(label: string, event: string, active: boolean, direction: string) {
|
||||
return html`
|
||||
<div class="pf-c-dual-list-selector__controls-item">
|
||||
<button
|
||||
@ -102,7 +109,7 @@ export class AkDualSelectControls extends CustomEmitterElement<DualSelectEventTy
|
||||
aria-label=${label}
|
||||
class="pf-c-button pf-m-plain"
|
||||
type="button"
|
||||
@click=${() => this.dispatchCustomEvent(eventType)}
|
||||
@click=${() => this.onClick(event)}
|
||||
data-ouia-component-type="AK/Button"
|
||||
>
|
||||
<i class="fa ${direction}"></i>
|
||||
@ -116,7 +123,7 @@ export class AkDualSelectControls extends CustomEmitterElement<DualSelectEventTy
|
||||
<div class="ak-dual-list-selector__controls">
|
||||
${this.renderButton(
|
||||
msg("Add"),
|
||||
DualSelectEventType.AddSelected,
|
||||
EVENT_ADD_SELECTED,
|
||||
this.addActive,
|
||||
"fa-angle-right",
|
||||
)}
|
||||
@ -124,13 +131,13 @@ export class AkDualSelectControls extends CustomEmitterElement<DualSelectEventTy
|
||||
? html`
|
||||
${this.renderButton(
|
||||
msg("Add All Available"),
|
||||
DualSelectEventType.AddAll,
|
||||
EVENT_ADD_ALL,
|
||||
this.addAllActive,
|
||||
"fa-angle-double-right",
|
||||
)}
|
||||
${this.renderButton(
|
||||
msg("Remove All Available"),
|
||||
DualSelectEventType.RemoveAll,
|
||||
EVENT_REMOVE_ALL,
|
||||
this.removeAllActive,
|
||||
"fa-angle-double-left",
|
||||
)}
|
||||
@ -138,14 +145,14 @@ export class AkDualSelectControls extends CustomEmitterElement<DualSelectEventTy
|
||||
: nothing}
|
||||
${this.renderButton(
|
||||
msg("Remove"),
|
||||
DualSelectEventType.RemoveSelected,
|
||||
EVENT_REMOVE_SELECTED,
|
||||
this.removeActive,
|
||||
"fa-angle-left",
|
||||
)}
|
||||
${this.deleteAll
|
||||
? html`${this.renderButton(
|
||||
msg("Remove All"),
|
||||
DualSelectEventType.DeleteAll,
|
||||
EVENT_DELETE_ALL,
|
||||
this.enableDeleteAll,
|
||||
"fa-times",
|
||||
)}`
|
||||
|
@ -11,13 +11,16 @@ 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 { DualSelectEventType, DualSelectPair } from "../types";
|
||||
import { EVENT_REMOVE_ONE } from "../constants";
|
||||
import type { DualSelectPair } from "../types";
|
||||
|
||||
const styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
|
||||
|
||||
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
|
||||
@ -35,86 +38,68 @@ const hostAttributes = [
|
||||
*
|
||||
*/
|
||||
@customElement("ak-dual-select-selected-pane")
|
||||
export class AkDualSelectSelectedPane extends CustomEmitterElement<DualSelectEventType>(AKElement) {
|
||||
static styles = [PFBase, PFButton, PFDualListSelector, listStyles, selectedPaneStyles];
|
||||
export class AkDualSelectSelectedPane extends CustomEmitterElement(AKElement) {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
//#region Properties
|
||||
|
||||
/* The array of key/value pairs that are in the selected list. ALL of them. */
|
||||
/* The array of key/value pairs that are in the selected list. ALL of them. */
|
||||
@property({ type: Array })
|
||||
readonly selected: DualSelectPair[] = [];
|
||||
|
||||
//#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
|
||||
/*
|
||||
* 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();
|
||||
|
||||
//#endregion
|
||||
constructor() {
|
||||
super();
|
||||
this.onClick = this.onClick.bind(this);
|
||||
this.onMove = this.onMove.bind(this);
|
||||
}
|
||||
|
||||
//#region Lifecycle
|
||||
public connectedCallback() {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
for (const [attr, value] of hostAttributes) {
|
||||
hostAttributes.forEach(([attr, value]) => {
|
||||
if (!this.hasAttribute(attr)) {
|
||||
this.setAttribute(attr, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public API
|
||||
|
||||
public clearMove() {
|
||||
clearMove() {
|
||||
this.toMove = new Set();
|
||||
}
|
||||
|
||||
public get moveable() {
|
||||
return Array.from(this.toMove.values());
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
#clickListener = (key: string): void => {
|
||||
onClick(key: string) {
|
||||
if (this.toMove.has(key)) {
|
||||
this.toMove.delete(key);
|
||||
} else {
|
||||
this.toMove.add(key);
|
||||
}
|
||||
|
||||
this.dispatchCustomEvent(
|
||||
DualSelectEventType.MoveChanged,
|
||||
"ak-dual-select-selected-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();
|
||||
};
|
||||
}
|
||||
|
||||
#moveListener = (key: string): void => {
|
||||
onMove(key: string) {
|
||||
this.toMove.delete(key);
|
||||
|
||||
this.dispatchCustomEvent(DualSelectEventType.RemoveOne, key);
|
||||
this.dispatchCustomEvent(EVENT_REMOVE_ONE, key);
|
||||
this.requestUpdate();
|
||||
};
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Render
|
||||
get moveable() {
|
||||
return Array.from(this.toMove.values());
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
@ -128,8 +113,8 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement<DualSelectEve
|
||||
class="pf-c-dual-list-selector__list-item"
|
||||
aria-selected="false"
|
||||
id="dual-list-selector-basic-selected-pane-list-option-0"
|
||||
@click=${() => this.#clickListener(key)}
|
||||
@dblclick=${() => this.#moveListener(key)}
|
||||
@click=${() => this.onClick(key)}
|
||||
@dblclick=${() => this.onMove(key)}
|
||||
role="option"
|
||||
data-ak-key=${key}
|
||||
tabindex="-1"
|
||||
@ -149,8 +134,6 @@ export class AkDualSelectSelectedPane extends CustomEmitterElement<DualSelectEve
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
export default AkDualSelectSelectedPane;
|
||||
|
@ -9,77 +9,85 @@ 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 { BasePagination, DualSelectEventType } from "../types.js";
|
||||
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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@customElement("ak-pagination")
|
||||
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);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
export class AkPagination extends CustomEmitterElement(AKElement) {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
pages?: BasePagination;
|
||||
|
||||
#clickListener = (nav: number = 0) => {
|
||||
this.dispatchCustomEvent(DualSelectEventType.NavigateTo, nav);
|
||||
};
|
||||
constructor() {
|
||||
super();
|
||||
this.onClick = this.onClick.bind(this);
|
||||
}
|
||||
|
||||
onClick(nav: number | undefined) {
|
||||
this.dispatchCustomEvent("ak-pagination-nav-to", nav ?? 0);
|
||||
}
|
||||
|
||||
render() {
|
||||
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>`;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4,45 +4,47 @@ 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 { SearchbarEventDetail, SearchbarEventSource } from "../types.ts";
|
||||
import { globalVariables, searchStyles } from "./search.css.js";
|
||||
import type { SearchbarEvent } from "../types";
|
||||
|
||||
const styles = [PFBase, globalVariables, searchStyles];
|
||||
|
||||
@customElement("ak-search-bar")
|
||||
export class AkSearchbar extends CustomEmitterElement(AKElement) {
|
||||
static styles = [PFBase, globalVariables, searchStyles];
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public value = "";
|
||||
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 })
|
||||
public name?: SearchbarEventSource;
|
||||
name = "";
|
||||
|
||||
protected inputRef = createRef<HTMLInputElement>();
|
||||
input: Ref<HTMLInputElement> = createRef();
|
||||
|
||||
#changeListener = () => {
|
||||
const inputElement = this.inputRef.value;
|
||||
constructor() {
|
||||
super();
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
if (inputElement) {
|
||||
this.value = inputElement.value;
|
||||
onChange(_event: Event) {
|
||||
if (this.input.value) {
|
||||
this.value = this.input.value.value;
|
||||
}
|
||||
|
||||
if (!this.name) {
|
||||
console.warn("ak-search-bar: no name provided, event will not be dispatched");
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchCustomEvent<SearchbarEventDetail>("ak-search", {
|
||||
this.dispatchCustomEvent<SearchbarEvent>("ak-search", {
|
||||
source: this.name,
|
||||
value: this.value,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
@ -54,8 +56,8 @@ export class AkSearchbar extends CustomEmitterElement(AKElement) {
|
||||
><input
|
||||
type="search"
|
||||
class="pf-c-text-input-group__text-input"
|
||||
${ref(this.inputRef)}
|
||||
@input=${this.#changeListener}
|
||||
${ref(this.input)}
|
||||
@input=${this.onChange}
|
||||
value="${this.value}"
|
||||
/></span>
|
||||
</div>
|
||||
|
7
web/src/elements/ak-dual-select/constants.ts
Normal file
7
web/src/elements/ak-dual-select/constants.ts
Normal file
@ -0,0 +1,7 @@
|
||||
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";
|
@ -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;
|
||||
|
@ -9,7 +9,7 @@ import { Pagination } from "@goauthentik/api";
|
||||
|
||||
import "../ak-dual-select";
|
||||
import { AkDualSelect } from "../ak-dual-select";
|
||||
import { DualSelectEventType, type DualSelectPair } from "../types";
|
||||
import type { DualSelectPair } from "../types";
|
||||
|
||||
const goodForYouRaw = `
|
||||
Apple, Arrowroot, Artichoke, Arugula, Asparagus, Avocado, Bamboo, Banana, Basil, Beet Root,
|
||||
@ -24,8 +24,7 @@ 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, key];
|
||||
|
||||
const keyToPair = (key: string): DualSelectPair => [slug(key), key];
|
||||
const goodForYou: DualSelectPair[] = goodForYouRaw
|
||||
.split("\n")
|
||||
.join(" ")
|
||||
@ -84,7 +83,7 @@ export class AkSbFruity extends LitElement {
|
||||
totalPages: Math.ceil(this.options.length / this.pageLength),
|
||||
};
|
||||
this.onNavigation = this.onNavigation.bind(this);
|
||||
this.addEventListener(DualSelectEventType.NavigateTo, this.onNavigation);
|
||||
this.addEventListener("ak-pagination-nav-to", this.onNavigation);
|
||||
}
|
||||
|
||||
onNavigation(evt: Event) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import { debounce } from "@goauthentik/elements/utils/debounce";
|
||||
import { Meta, StoryObj } from "@storybook/web-components";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
@ -44,24 +45,20 @@ const displayMessage = (result: any) => {
|
||||
target!.replaceChildren(doc.firstChild!);
|
||||
};
|
||||
|
||||
const displayMessage2 = (result: string) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const displayMessage2 = (result: any) => {
|
||||
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!);
|
||||
};
|
||||
|
||||
let displayMessage2bTimeoutID: ReturnType<typeof setTimeout>;
|
||||
const displayMessage2b = debounce(displayMessage2, 250);
|
||||
|
||||
window.addEventListener("input", (event: Event) => {
|
||||
const message = (event.target as HTMLInputElement | undefined)?.value ?? "-- undefined --";
|
||||
displayMessage(message);
|
||||
|
||||
clearTimeout(displayMessage2bTimeoutID);
|
||||
|
||||
displayMessage2bTimeoutID = setTimeout(() => {
|
||||
displayMessage2(message);
|
||||
}, 250);
|
||||
displayMessage2b(message);
|
||||
});
|
||||
|
||||
type Story = StoryObj;
|
||||
|
@ -5,7 +5,6 @@ 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",
|
||||
@ -55,7 +54,7 @@ const handleMoveChanged = (result: any) => {
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener(DualSelectEventType.NavigateTo, handleMoveChanged);
|
||||
window.addEventListener("ak-pagination-nav-to", handleMoveChanged);
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
|
@ -12,7 +12,9 @@ import { globalVariables } from "../components/styles.css";
|
||||
|
||||
@customElement("sb-dual-select-host-provider")
|
||||
export class SbHostProvider extends LitElement {
|
||||
static styles = globalVariables;
|
||||
static get styles() {
|
||||
return globalVariables;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
|
@ -2,44 +2,19 @@ import { TemplateResult } from "lit";
|
||||
|
||||
import { Pagination } from "@goauthentik/api";
|
||||
|
||||
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>;
|
||||
//
|
||||
// - 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 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.
|
||||
*/
|
||||
export type DualSelectPair<T = never> = [
|
||||
key: string,
|
||||
/**
|
||||
* A human-readable label for the object.
|
||||
*/
|
||||
label: string | TemplateResult,
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
sortBy?: string,
|
||||
localMapping?: T,
|
||||
];
|
||||
|
||||
@ -55,14 +30,9 @@ 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: SearchbarEventSource;
|
||||
source: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type SearchbarEvent = CustomEvent<SearchbarEventDetail>;
|
||||
|
@ -27,14 +27,11 @@ import remarkGFM from "remark-gfm";
|
||||
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
|
||||
import remarkParse from "remark-parse";
|
||||
|
||||
import { css } from "lit";
|
||||
import { CSSResult, 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";
|
||||
|
||||
@ -70,96 +67,80 @@ export class AKMDX extends AKElement {
|
||||
|
||||
resolvedHTML = "";
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
.messageText {
|
||||
stroke-width: 4;
|
||||
fill: var(--ak-mermaid-message-text) !important;
|
||||
paint-order: stroke;
|
||||
/*
|
||||
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,
|
||||
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();
|
||||
|
@ -16,5 +16,5 @@ export const MDXWrapper: React.FC<MDXWrapperProps> = ({ children, frontmatter })
|
||||
nextChildren.unshift(<h1 key="header-title">{title}</h1>);
|
||||
}
|
||||
|
||||
return <div className="pf-c-content">{nextChildren}</div>;
|
||||
return <>{nextChildren}</>;
|
||||
};
|
||||
|
@ -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-c-button--m-plain--hover--Color) !important;
|
||||
color: var(--pf-global--Color--100) !important;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,16 +1,21 @@
|
||||
import { applyDocumentTheme } from "@goauthentik/common/theme.js";
|
||||
import {
|
||||
appendStyleSheet,
|
||||
assertAdoptableStyleSheetParent,
|
||||
createStyleSheetUnsafe,
|
||||
} from "@goauthentik/common/stylesheets.js";
|
||||
|
||||
import { TemplateResult, render as litRender } from "lit";
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
applyDocumentTheme();
|
||||
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.
|
||||
|
||||
export const render = (body: TemplateResult) => {
|
||||
assertAdoptableStyleSheetParent(document);
|
||||
|
||||
appendStyleSheet(document, ...[PFBase, AKGlobal].map(createStyleSheetUnsafe));
|
||||
return litRender(body, document.body);
|
||||
};
|
||||
|
@ -1,11 +1,6 @@
|
||||
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.
|
||||
*
|
||||
@ -13,12 +8,11 @@ export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
*
|
||||
* This type is derived from an internal type in Lit.
|
||||
*/
|
||||
export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & Writeable<T>> & HTMLElement;
|
||||
export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & T> & HTMLElement;
|
||||
|
||||
export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type LitElementConstructor = new (...args: any[]) => LitElement;
|
||||
export type LitElementConstructor = new (...args: never[]) => LitElement;
|
||||
|
||||
/**
|
||||
* A constructor that has been extended with a mixin.
|
||||
|
13
web/src/elements/utils/debounce.ts
Normal file
13
web/src/elements/utils/debounce.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Callback = (...args: any[]) => any;
|
||||
export function debounce<F extends Callback, T extends object>(callback: F, wait: number) {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<F>) => {
|
||||
// @ts-ignore
|
||||
const context: T = this satisfies object;
|
||||
if (timeout !== undefined) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => callback.apply(context, args), wait);
|
||||
};
|
||||
}
|
@ -1,28 +1,18 @@
|
||||
import {
|
||||
ConstructorWithMixin,
|
||||
LitElementConstructor,
|
||||
createMixin,
|
||||
} from "@goauthentik/elements/types";
|
||||
import { createMixin } from "@goauthentik/elements/types";
|
||||
import { CustomEventDetail, isCustomEvent } from "@goauthentik/elements/utils/customEvents";
|
||||
|
||||
export interface CustomEventEmitterMixin<EventType extends string = string> {
|
||||
dispatchCustomEvent<D extends CustomEventDetail>(
|
||||
eventType: EventType,
|
||||
detail?: D,
|
||||
export interface EmmiterElementHandler {
|
||||
dispatchCustomEvent<T>(
|
||||
eventName: string,
|
||||
detail?: T extends CustomEvent<infer U> ? U : T,
|
||||
eventInit?: EventInit,
|
||||
): void;
|
||||
}
|
||||
|
||||
export function CustomEmitterElement<
|
||||
EventType extends string = string,
|
||||
T extends LitElementConstructor = LitElementConstructor,
|
||||
>(SuperClass: T) {
|
||||
abstract class CustomEventEmmiter
|
||||
extends SuperClass
|
||||
implements CustomEventEmitterMixin<EventType>
|
||||
{
|
||||
export const CustomEmitterElement = createMixin<EmmiterElementHandler>(({ SuperClass }) => {
|
||||
return class EmmiterElementHandler extends SuperClass {
|
||||
public dispatchCustomEvent<D extends CustomEventDetail>(
|
||||
eventType: string,
|
||||
eventName: string,
|
||||
detail: D = {} as D,
|
||||
eventInit: EventInit = {},
|
||||
) {
|
||||
@ -36,7 +26,7 @@ export function CustomEmitterElement<
|
||||
}
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(eventType, {
|
||||
new CustomEvent(eventName, {
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
...eventInit,
|
||||
@ -44,20 +34,34 @@ export function CustomEmitterElement<
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return CustomEventEmmiter as unknown as ConstructorWithMixin<
|
||||
T,
|
||||
CustomEventEmitterMixin<EventType>
|
||||
>;
|
||||
/**
|
||||
* Mixin that enables Lit Elements to handle custom events in a more straightforward manner.
|
||||
*
|
||||
*/
|
||||
|
||||
// This is a neat trick: this static "class" is just a namespace for these unique symbols. Because
|
||||
// of all the constraints on them, they're legal field names in Typescript objects! Which means that
|
||||
// we can use them as identifiers for internal references in a Typescript class with absolutely no
|
||||
// risk that a future user who wants a name like 'addHandler' or 'removeHandler' will override any
|
||||
// of those, either in this mixin or in any class that this is mixed into, past or present along the
|
||||
// chain of inheritance.
|
||||
|
||||
class HK {
|
||||
public static readonly listenHandlers: unique symbol = Symbol();
|
||||
public static readonly addHandler: unique symbol = Symbol();
|
||||
public static readonly removeHandler: unique symbol = Symbol();
|
||||
public static readonly getHandler: unique symbol = Symbol();
|
||||
}
|
||||
|
||||
type CustomEventListener<D = unknown> = (ev: CustomEvent<D>) => void;
|
||||
type EventMap<D = unknown> = WeakMap<CustomEventListener<D>, CustomEventListener<D>>;
|
||||
type EventHandler = (ev: CustomEvent) => void;
|
||||
type EventMap = WeakMap<EventHandler, EventHandler>;
|
||||
|
||||
export interface CustomEventTarget<EventType extends string = string> {
|
||||
addCustomListener<D = unknown>(eventType: EventType, handler: CustomEventListener<D>): void;
|
||||
removeCustomListener<D = unknown>(eventType: EventType, handler: CustomEventListener<D>): void;
|
||||
export interface CustomEventTarget {
|
||||
addCustomListener(eventName: string, handler: EventHandler): void;
|
||||
removeCustomListener(eventName: string, handler: EventHandler): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -68,15 +72,11 @@ export interface CustomEventTarget<EventType extends string = string> {
|
||||
*/
|
||||
export const CustomListenerElement = createMixin<CustomEventTarget>(({ SuperClass }) => {
|
||||
return class ListenerElementHandler extends SuperClass implements CustomEventTarget {
|
||||
#listenHandlers = new Map<string, EventMap>();
|
||||
private [HK.listenHandlers] = new Map<string, EventMap>();
|
||||
|
||||
#getListener<D = unknown>(
|
||||
eventType: string,
|
||||
handler: CustomEventListener<D>,
|
||||
): CustomEventListener<D> | undefined {
|
||||
const internalMap = this.#listenHandlers.get(eventType) as EventMap<D> | undefined;
|
||||
|
||||
return internalMap?.get(handler);
|
||||
private [HK.getHandler](eventName: string, handler: EventHandler) {
|
||||
const internalMap = this[HK.listenHandlers].get(eventName);
|
||||
return internalMap ? internalMap.get(handler) : undefined;
|
||||
}
|
||||
|
||||
// For every event NAME, we create a WeakMap that pairs the event handler given to us by the
|
||||
@ -85,58 +85,50 @@ export const CustomListenerElement = createMixin<CustomEventTarget>(({ SuperClas
|
||||
// meanwhile, this allows us to remove it from the event listeners if it's still around
|
||||
// using the original handler's identity as the key.
|
||||
//
|
||||
#addListener<D = unknown>(
|
||||
eventType: string,
|
||||
handler: CustomEventListener<D>,
|
||||
internalHandler: CustomEventListener<D>,
|
||||
private [HK.addHandler](
|
||||
eventName: string,
|
||||
handler: EventHandler,
|
||||
internalHandler: EventHandler,
|
||||
) {
|
||||
let internalMap = this.#listenHandlers.get(eventType) as EventMap<D> | undefined;
|
||||
|
||||
if (!internalMap) {
|
||||
internalMap = new WeakMap();
|
||||
|
||||
this.#listenHandlers.set(eventType, internalMap as EventMap);
|
||||
if (!this[HK.listenHandlers].has(eventName)) {
|
||||
this[HK.listenHandlers].set(eventName, new WeakMap());
|
||||
}
|
||||
|
||||
internalMap.set(handler, internalHandler);
|
||||
}
|
||||
|
||||
#removeListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
|
||||
const internalMap = this.#listenHandlers.get(eventType) as EventMap<D> | undefined;
|
||||
|
||||
const internalMap = this[HK.listenHandlers].get(eventName);
|
||||
if (internalMap) {
|
||||
internalMap.delete(listener);
|
||||
internalMap.set(handler, internalHandler);
|
||||
}
|
||||
}
|
||||
|
||||
addCustomListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
|
||||
const internalHandler = (event: Event) => {
|
||||
if (!isCustomEvent<D>(event)) {
|
||||
console.error(
|
||||
`Received a standard event for custom event ${eventType}; event will not be handled.`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return listener(event);
|
||||
};
|
||||
|
||||
this.#addListener(eventType, listener, internalHandler);
|
||||
this.addEventListener(eventType, internalHandler);
|
||||
private [HK.removeHandler](eventName: string, handler: EventHandler) {
|
||||
const internalMap = this[HK.listenHandlers].get(eventName);
|
||||
if (internalMap) {
|
||||
internalMap.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
removeCustomListener<D = unknown>(eventType: string, listener: CustomEventListener<D>) {
|
||||
const realHandler = this.#getListener(eventType, listener);
|
||||
addCustomListener(eventName: string, handler: EventHandler) {
|
||||
const internalHandler = (event: Event) => {
|
||||
if (!isCustomEvent(event)) {
|
||||
console.error(
|
||||
`Received a standard event for custom event ${eventName}; event will not be handled.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
handler(event);
|
||||
};
|
||||
this[HK.addHandler](eventName, handler, internalHandler);
|
||||
this.addEventListener(eventName, internalHandler);
|
||||
}
|
||||
|
||||
removeCustomListener(eventName: string, handler: EventHandler) {
|
||||
const realHandler = this[HK.getHandler](eventName, handler);
|
||||
if (realHandler) {
|
||||
this.removeEventListener(
|
||||
eventType,
|
||||
eventName,
|
||||
realHandler as EventListenerOrEventListenerObject,
|
||||
);
|
||||
}
|
||||
|
||||
this.#removeListener<D>(eventType, listener);
|
||||
this[HK.removeHandler](eventName, handler);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
@ -14,6 +14,6 @@ import "@goauthentik/flow/stages/password/PasswordStage";
|
||||
|
||||
// end of stage import
|
||||
|
||||
if (import.meta.env.NODE_ENV === "development") {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
await import("@goauthentik/esbuild-plugin-live-reload/client");
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ export const PasswordManagerPrefill: {
|
||||
totp: undefined,
|
||||
};
|
||||
|
||||
export const OR_LIST_FORMATTERS: Intl.ListFormat = new Intl.ListFormat("default", {
|
||||
export const OR_LIST_FORMATTERS = new Intl.ListFormat("default", {
|
||||
style: "short",
|
||||
type: "disjunction",
|
||||
});
|
||||
|
9
web/types/mdx.d.ts → web/src/global.d.ts
vendored
9
web/types/mdx.d.ts → web/src/global.d.ts
vendored
@ -1,3 +1,5 @@
|
||||
declare module "*.css";
|
||||
|
||||
declare module "*.md" {
|
||||
/**
|
||||
* The serialized JSON content of an MD file.
|
||||
@ -13,3 +15,10 @@ declare module "*.mdx" {
|
||||
const serializedJSON: string;
|
||||
export default serializedJSON;
|
||||
}
|
||||
|
||||
declare namespace Intl {
|
||||
class ListFormat {
|
||||
constructor(locale: string, args: { [key: string]: string });
|
||||
public format: (items: string[]) => string;
|
||||
}
|
||||
}
|
@ -1,13 +1,13 @@
|
||||
// sort-imports-ignore
|
||||
import "rapidoc";
|
||||
import "@goauthentik/elements/ak-locale-context/index.js";
|
||||
|
||||
import { CSRFHeaderName } from "@goauthentik/common/api/middleware.js";
|
||||
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants.js";
|
||||
import { getCookie } from "@goauthentik/common/utils.js";
|
||||
import { Interface } from "@goauthentik/elements/Interface/Interface.js";
|
||||
import { DefaultBrand } from "@goauthentik/common/ui/config.js";
|
||||
import { themeImage } from "@goauthentik/elements/utils/images.js";
|
||||
import { CSRFHeaderName } from "@goauthentik/common/api/config";
|
||||
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
|
||||
import { getCookie } from "@goauthentik/common/utils";
|
||||
import { Interface } from "@goauthentik/elements/Interface";
|
||||
import "@goauthentik/elements/ak-locale-context";
|
||||
import { DefaultBrand } from "@goauthentik/common/ui/config";
|
||||
import { themeImage } from "@goauthentik/elements/utils/images";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { LightInterface } from "@goauthentik/elements/Interface";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, css, html } from "lit";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
|
||||
@ -11,17 +11,19 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
@customElement("ak-loading")
|
||||
export class Loading extends LightInterface {
|
||||
static styles = [
|
||||
PFBase,
|
||||
PFPage,
|
||||
PFSpinner,
|
||||
PFEmptyState,
|
||||
css`
|
||||
:host([theme="dark"]) h1 {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
`,
|
||||
];
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
PFBase,
|
||||
PFPage,
|
||||
PFSpinner,
|
||||
PFEmptyState,
|
||||
css`
|
||||
:host([theme="dark"]) h1 {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html` <section
|
||||
|
@ -43,7 +43,7 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
|
||||
|
||||
import { CurrentBrand, EventsApi, SessionUser } from "@goauthentik/api";
|
||||
|
||||
if (import.meta.env.NODE_ENV === "development") {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
await import("@goauthentik/esbuild-plugin-live-reload/client");
|
||||
}
|
||||
|
||||
|
@ -2,9 +2,6 @@
|
||||
{
|
||||
"extends": "@goauthentik/tsconfig",
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"experimentalDecorators": true,
|
||||
|
23
web/types/css.d.ts
vendored
23
web/types/css.d.ts
vendored
@ -1,23 +0,0 @@
|
||||
/**
|
||||
* @file ESBuild CSS module type definitions.
|
||||
*/
|
||||
|
||||
declare module "*.css" {
|
||||
import { CSSResult } from "lit";
|
||||
|
||||
global {
|
||||
/**
|
||||
* A branded type representing a CSS file imported by ESBuild.
|
||||
*
|
||||
* While this is a `string`, this is typed as a {@linkcode CSSResult}
|
||||
* to satisfy LitElement's `static styles` property.
|
||||
*/
|
||||
export type CSSModule = CSSResult & { readonly __brand?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* The text content of a CSS file imported by ESBuild.
|
||||
*/
|
||||
const css: CSSModule;
|
||||
export default css;
|
||||
}
|
39
web/types/esbuild.d.ts
vendored
39
web/types/esbuild.d.ts
vendored
@ -1,39 +0,0 @@
|
||||
/**
|
||||
* @file Import meta environment variables available via ESBuild.
|
||||
*/
|
||||
|
||||
export {};
|
||||
declare global {
|
||||
interface ESBuildImportEnv {
|
||||
/**
|
||||
* The authentik version injected by ESBuild during build time.
|
||||
*
|
||||
* @format semver
|
||||
*/
|
||||
readonly AK_VERSION: string;
|
||||
|
||||
/**
|
||||
* @todo Determine where this is used and if it is needed,
|
||||
* give it a better name.
|
||||
* @deprecated
|
||||
*/
|
||||
readonly AK_API_BASE_PATH: string;
|
||||
}
|
||||
|
||||
type ESBuildImportEnvKey = keyof ESBuildImportEnv;
|
||||
|
||||
/**
|
||||
* Environment variables injected by ESBuild.
|
||||
*/
|
||||
interface ImportMetaEnv extends ESBuildImportEnv {
|
||||
/**
|
||||
* An environment variable used to determine
|
||||
* whether Node.js is running in production mode.
|
||||
*/
|
||||
readonly NODE_ENV: "development" | "production";
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
}
|
13
web/types/node.d.ts → web/types/global.d.ts
vendored
13
web/types/node.d.ts → web/types/global.d.ts
vendored
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file Global variables provided by Node.js
|
||||
* @file Environment variables available via ESBuild.
|
||||
*/
|
||||
|
||||
declare module "module" {
|
||||
@ -14,8 +14,8 @@ declare module "module" {
|
||||
* const relativeDirname = dirname(fileURLToPath(import.meta.url));
|
||||
* ```
|
||||
*/
|
||||
|
||||
const __dirname: string;
|
||||
// eslint-disable-next-line no-var
|
||||
var __dirname: string;
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,16 +23,13 @@ declare module "process" {
|
||||
global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* Node environment, if any.
|
||||
*/
|
||||
readonly NODE_ENV?: "development" | "production";
|
||||
CWD: string;
|
||||
/**
|
||||
* @todo Determine where this is used and if it is needed,
|
||||
* give it a better name.
|
||||
* @deprecated
|
||||
*/
|
||||
readonly AK_API_BASE_PATH?: string;
|
||||
AK_API_BASE_PATH: string;
|
||||
}
|
||||
}
|
||||
}
|
21
web/types/lit.d.ts
vendored
21
web/types/lit.d.ts
vendored
@ -1,21 +0,0 @@
|
||||
/**
|
||||
* @file Lit-specific globals applied to the Window object.
|
||||
*/
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
/**
|
||||
* A property defined by Lit to track the element part.
|
||||
*/
|
||||
_$litPart$?: unknown;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
/**
|
||||
* A possible nonce to use create a CSP-safe style element.
|
||||
*/
|
||||
litNonce?: string;
|
||||
}
|
||||
}
|
@ -1,15 +1,17 @@
|
||||
/// <reference types="@wdio/browser-runner" />
|
||||
import replace from "@rollup/plugin-replace";
|
||||
import { browser } from "@wdio/globals";
|
||||
import type { Options } from "@wdio/types";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createBundleDefinitions } from "scripts/esbuild/environment.mjs";
|
||||
import type { InlineConfig } from "vite";
|
||||
import litCSS from "vite-plugin-lit-css";
|
||||
import path from "path";
|
||||
import { cwd } from "process";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { UserConfig } from "vite";
|
||||
import litCss from "vite-plugin-lit-css";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
const isProdBuild = process.env.NODE_ENV === "production";
|
||||
const apiBasePath = process.env.AK_API_BASE_PATH || "";
|
||||
const runHeadless = process.env.CI !== undefined;
|
||||
|
||||
const testSafari = process.env.WDIO_TEST_SAFARI !== undefined;
|
||||
@ -70,9 +72,21 @@ export const config: Options.Testrunner = {
|
||||
runner: [
|
||||
"browser",
|
||||
{
|
||||
viteConfig: {
|
||||
define: createBundleDefinitions(),
|
||||
plugins: [litCSS(), tsconfigPaths()],
|
||||
viteConfig: (userConfig: UserConfig = { plugins: [] }) => ({
|
||||
...userConfig,
|
||||
plugins: [
|
||||
litCss(),
|
||||
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,
|
||||
}),
|
||||
...(userConfig?.plugins ?? []),
|
||||
tsconfigPaths(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@goauthentik/admin": path.resolve(__dirname, "src/admin"),
|
||||
@ -87,7 +101,7 @@ export const config: Options.Testrunner = {
|
||||
"@goauthentik/user": path.resolve(__dirname, "src/user"),
|
||||
},
|
||||
},
|
||||
} satisfies InlineConfig,
|
||||
}),
|
||||
},
|
||||
],
|
||||
|
||||
|
@ -8701,6 +8701,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s931754aabd4a6a47">
|
||||
<source>Create a Policy/User/Group Binding</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s10ba15343fe64890">
|
||||
<source>Choose A Provider</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb7be945aa799b8d7">
|
||||
<source>Please choose a provider type before proceeding.</source>
|
||||
</trans-unit>
|
||||
@ -9171,9 +9174,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sdd66c5a2e706fb81">
|
||||
<source>Toggle sidebar</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7d4ec232535a36f0">
|
||||
<source>Choose a Provider</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
@ -7227,6 +7227,9 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<trans-unit id="s931754aabd4a6a47">
|
||||
<source>Create a Policy/User/Group Binding</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s10ba15343fe64890">
|
||||
<source>Choose A Provider</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb7be945aa799b8d7">
|
||||
<source>Please choose a provider type before proceeding.</source>
|
||||
</trans-unit>
|
||||
@ -7694,9 +7697,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sdd66c5a2e706fb81">
|
||||
<source>Toggle sidebar</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7d4ec232535a36f0">
|
||||
<source>Choose a Provider</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
@ -8786,6 +8786,9 @@ Las vinculaciones a grupos o usuarios se comparan con el usuario del evento.</ta
|
||||
<trans-unit id="s931754aabd4a6a47">
|
||||
<source>Create a Policy/User/Group Binding</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s10ba15343fe64890">
|
||||
<source>Choose A Provider</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb7be945aa799b8d7">
|
||||
<source>Please choose a provider type before proceeding.</source>
|
||||
</trans-unit>
|
||||
@ -9253,9 +9256,6 @@ Las vinculaciones a grupos o usuarios se comparan con el usuario del evento.</ta
|
||||
</trans-unit>
|
||||
<trans-unit id="sdd66c5a2e706fb81">
|
||||
<source>Toggle sidebar</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7d4ec232535a36f0">
|
||||
<source>Choose a Provider</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
@ -9180,6 +9180,10 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
|
||||
<source>Create a Policy/User/Group Binding</source>
|
||||
<target>Créer une liaison vers politique/utilisateur/groupe</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s10ba15343fe64890">
|
||||
<source>Choose A Provider</source>
|
||||
<target>Choisir un fournisseur</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb7be945aa799b8d7">
|
||||
<source>Please choose a provider type before proceeding.</source>
|
||||
<target>Veuillez choisir un type de fournisseur avant de continuer.</target>
|
||||
@ -9804,9 +9808,6 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
|
||||
</trans-unit>
|
||||
<trans-unit id="sdd66c5a2e706fb81">
|
||||
<source>Toggle sidebar</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7d4ec232535a36f0">
|
||||
<source>Choose a Provider</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
@ -9181,6 +9181,10 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<source>Create a Policy/User/Group Binding</source>
|
||||
<target>Crea una associazione Policy/User/Group</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="s10ba15343fe64890">
|
||||
<source>Choose A Provider</source>
|
||||
<target>Scegli un provider</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="sb7be945aa799b8d7">
|
||||
<source>Please choose a provider type before proceeding.</source>
|
||||
<target>Selezionare un tipo di provider prima di procedere.</target>
|
||||
@ -9778,9 +9782,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="sdd66c5a2e706fb81">
|
||||
<source>Toggle sidebar</source>
|
||||
</trans-unit>
|
||||
<trans-unit id="s7d4ec232535a36f0">
|
||||
<source>Choose a Provider</source>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user