Compare commits
37 Commits
core/refac
...
tests/e2e/
Author | SHA1 | Date | |
---|---|---|---|
4b0d641a51 | |||
99b559893b | |||
8014088c3a | |||
3ee353126f | |||
db76c5d9e2 | |||
61bff69b7d | |||
69651323e3 | |||
75a0ac9588 | |||
941a697397 | |||
4a74db17a1 | |||
0cf6bff93c | |||
814e438422 | |||
2db77a37dd | |||
e40c5ac617 | |||
7440900dac | |||
ca96b27825 | |||
ad4a765a80 | |||
4dcd481010 | |||
d0dc14d84d | |||
7bf960352b | |||
c07d01661b | |||
427597ec14 | |||
7cc77bd387 | |||
381a1a2c49 | |||
08f8222224 | |||
1211c34a18 | |||
22efb57369 | |||
3eeda53be6 | |||
82ace18703 | |||
8589079252 | |||
ae2af6e58e | |||
86a7f98ff6 | |||
3af45371d3 | |||
b01ffd934f | |||
f11ba94603 | |||
7d2aa43364 | |||
f1351a7577 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2025.4.0
|
||||
current_version = 2025.4.1
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
3
.github/workflows/ci-main.yml
vendored
3
.github/workflows/ci-main.yml
vendored
@ -200,7 +200,7 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web
|
||||
@ -208,6 +208,7 @@ jobs:
|
||||
npm ci
|
||||
make -C .. gen-client-ts
|
||||
npm run build
|
||||
npm run build:sfe
|
||||
- name: run e2e
|
||||
run: |
|
||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
||||
|
@ -40,7 +40,8 @@ COPY ./web /work/web/
|
||||
COPY ./website /work/website/
|
||||
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
|
||||
|
||||
RUN npm run build
|
||||
RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 3: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
|
||||
@ -93,7 +94,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 5: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.7.3 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.7.4 AS uv
|
||||
# Stage 6: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2025.4.0"
|
||||
__version__ = "2025.4.1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -5,10 +5,10 @@ from typing import Any
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Value as V
|
||||
from django.http.request import HttpRequest
|
||||
from sentry_sdk import get_current_span
|
||||
|
||||
from authentik import get_full_version
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.lib.sentry import get_http_meta
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
_q_default = Q(default=True)
|
||||
@ -32,13 +32,9 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
|
||||
"""Context Processor that injects brand object into every template"""
|
||||
brand = getattr(request, "brand", DEFAULT_BRAND)
|
||||
tenant = getattr(request, "tenant", Tenant())
|
||||
trace = ""
|
||||
span = get_current_span()
|
||||
if span:
|
||||
trace = span.to_traceparent()
|
||||
return {
|
||||
"brand": brand,
|
||||
"footer_links": tenant.footer_links,
|
||||
"sentry_trace": trace,
|
||||
"html_meta": {**get_http_meta()},
|
||||
"version": get_full_version(),
|
||||
}
|
||||
|
@ -99,18 +99,17 @@ class GroupSerializer(ModelSerializer):
|
||||
if superuser
|
||||
else "authentik_core.disable_group_superuser"
|
||||
)
|
||||
has_perm = user.has_perm(perm)
|
||||
if self.instance and not has_perm:
|
||||
has_perm = user.has_perm(perm, self.instance)
|
||||
if not has_perm:
|
||||
raise ValidationError(
|
||||
_(
|
||||
(
|
||||
"User does not have permission to set "
|
||||
"superuser status to {superuser_status}."
|
||||
).format_map({"superuser_status": superuser})
|
||||
if self.instance or superuser:
|
||||
has_perm = user.has_perm(perm) or user.has_perm(perm, self.instance)
|
||||
if not has_perm:
|
||||
raise ValidationError(
|
||||
_(
|
||||
(
|
||||
"User does not have permission to set "
|
||||
"superuser status to {superuser_status}."
|
||||
).format_map({"superuser_status": superuser})
|
||||
)
|
||||
)
|
||||
)
|
||||
return superuser
|
||||
|
||||
class Meta:
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.management import create_permissions
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from guardian.management import create_anonymous_user
|
||||
|
||||
@ -16,6 +17,10 @@ class Command(BaseCommand):
|
||||
"""Check permissions for all apps"""
|
||||
for tenant in Tenant.objects.filter(ready=True):
|
||||
with tenant:
|
||||
# See https://code.djangoproject.com/ticket/28417
|
||||
# Remove potential lingering old permissions
|
||||
call_command("remove_stale_contenttypes", "--no-input")
|
||||
|
||||
for app in apps.get_app_configs():
|
||||
self.stdout.write(f"Checking app {app.name} ({app.label})\n")
|
||||
create_permissions(app, verbosity=0)
|
||||
|
@ -31,7 +31,10 @@ class PickleSerializer:
|
||||
|
||||
def loads(self, data):
|
||||
"""Unpickle data to be loaded from redis"""
|
||||
return pickle.loads(data) # nosec
|
||||
try:
|
||||
return pickle.loads(data) # nosec
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _migrate_session(
|
||||
|
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.9 on 2025-05-14 11:15
|
||||
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def remove_old_authenticated_session_content_type(
|
||||
apps: Apps, schema_editor: BaseDatabaseSchemaEditor
|
||||
):
|
||||
db_alias = schema_editor.connection.alias
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
|
||||
ContentType.objects.using(db_alias).filter(model="oldauthenticatedsession").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0047_delete_oldauthenticatedsession"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=remove_old_authenticated_session_content_type,
|
||||
),
|
||||
]
|
@ -21,7 +21,9 @@
|
||||
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
|
||||
{% block head %}
|
||||
{% endblock %}
|
||||
<meta name="sentry-trace" content="{{ sentry_trace }}" />
|
||||
{% for key, value in html_meta.items %}
|
||||
<meta name="{{key}}" content="{{ value }}" />
|
||||
{% endfor %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}
|
||||
|
@ -124,6 +124,16 @@ class TestGroupsAPI(APITestCase):
|
||||
{"is_superuser": ["User does not have permission to set superuser status to True."]},
|
||||
)
|
||||
|
||||
def test_superuser_no_perm_no_superuser(self):
|
||||
"""Test creating a group without permission and without superuser flag"""
|
||||
assign_perm("authentik_core.add_group", self.login_user)
|
||||
self.client.force_login(self.login_user)
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:group-list"),
|
||||
data={"name": generate_id(), "is_superuser": False},
|
||||
)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
|
||||
def test_superuser_update_no_perm(self):
|
||||
"""Test updating a superuser group without permission"""
|
||||
group = Group.objects.create(name=generate_id(), is_superuser=True)
|
||||
|
@ -132,13 +132,14 @@ class LicenseKey:
|
||||
"""Get a summarized version of all (not expired) licenses"""
|
||||
total = LicenseKey(get_license_aud(), 0, "Summarized license", 0, 0)
|
||||
for lic in License.objects.all():
|
||||
total.internal_users += lic.internal_users
|
||||
total.external_users += lic.external_users
|
||||
if lic.is_valid:
|
||||
total.internal_users += lic.internal_users
|
||||
total.external_users += lic.external_users
|
||||
total.license_flags.extend(lic.status.license_flags)
|
||||
exp_ts = int(mktime(lic.expiry.timetuple()))
|
||||
if total.exp == 0:
|
||||
total.exp = exp_ts
|
||||
total.exp = max(total.exp, exp_ts)
|
||||
total.license_flags.extend(lic.status.license_flags)
|
||||
return total
|
||||
|
||||
@staticmethod
|
||||
|
@ -39,6 +39,10 @@ class License(SerializerModel):
|
||||
internal_users = models.BigIntegerField()
|
||||
external_users = models.BigIntegerField()
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return self.expiry >= now()
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
from authentik.enterprise.api import LicenseSerializer
|
||||
|
@ -8,6 +8,7 @@ from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import (
|
||||
THRESHOLD_READ_ONLY_WEEKS,
|
||||
@ -71,9 +72,9 @@ class TestEnterpriseLicense(TestCase):
|
||||
)
|
||||
def test_valid_multiple(self):
|
||||
"""Check license verification"""
|
||||
lic = License.objects.create(key=generate_id())
|
||||
lic = License.objects.create(key=generate_id(), expiry=expiry_valid)
|
||||
self.assertTrue(lic.status.status().is_valid)
|
||||
lic2 = License.objects.create(key=generate_id())
|
||||
lic2 = License.objects.create(key=generate_id(), expiry=expiry_valid)
|
||||
self.assertTrue(lic2.status.status().is_valid)
|
||||
total = LicenseKey.get_total()
|
||||
self.assertEqual(total.internal_users, 200)
|
||||
@ -232,7 +233,9 @@ class TestEnterpriseLicense(TestCase):
|
||||
)
|
||||
def test_expiry_expired(self):
|
||||
"""Check license verification"""
|
||||
License.objects.create(key=generate_id())
|
||||
User.objects.all().delete()
|
||||
License.objects.all().delete()
|
||||
License.objects.create(key=generate_id(), expiry=expiry_expired)
|
||||
self.assertEqual(LicenseKey.get_total().summary().status, LicenseUsageStatus.EXPIRED)
|
||||
|
||||
@patch(
|
||||
|
@ -7,7 +7,7 @@
|
||||
{{ block.super }}
|
||||
<link rel="prefetch" href="{{ flow_background_url }}" />
|
||||
{% if flow.compatibility_mode and not inspector %}
|
||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
||||
<script>ShadyDOM = { force: true };</script>
|
||||
{% endif %}
|
||||
{% include "base/header_js.html" %}
|
||||
<script>
|
||||
|
@ -363,6 +363,9 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
|
||||
pool_options = config.get_dict_from_b64_json("postgresql.pool_options", True)
|
||||
if not pool_options:
|
||||
pool_options = True
|
||||
# FIXME: Temporarily force pool to be deactivated.
|
||||
# See https://github.com/goauthentik/authentik/issues/14320
|
||||
pool_options = False
|
||||
|
||||
db = {
|
||||
"default": {
|
||||
|
@ -17,7 +17,7 @@ from ldap3.core.exceptions import LDAPException
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
from redis.exceptions import RedisError, ResponseError
|
||||
from rest_framework.exceptions import APIException
|
||||
from sentry_sdk import HttpTransport
|
||||
from sentry_sdk import HttpTransport, get_current_scope
|
||||
from sentry_sdk import init as sentry_sdk_init
|
||||
from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.integrations.argv import ArgvIntegration
|
||||
@ -27,6 +27,7 @@ from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from sentry_sdk.integrations.socket import SocketIntegration
|
||||
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
||||
from sentry_sdk.integrations.threading import ThreadingIntegration
|
||||
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME
|
||||
from structlog.stdlib import get_logger
|
||||
from websockets.exceptions import WebSocketException
|
||||
|
||||
@ -95,6 +96,8 @@ def traces_sampler(sampling_context: dict) -> float:
|
||||
return 0
|
||||
if _type == "websocket":
|
||||
return 0
|
||||
if CONFIG.get_bool("debug"):
|
||||
return 1
|
||||
return float(CONFIG.get("error_reporting.sample_rate", 0.1))
|
||||
|
||||
|
||||
@ -167,3 +170,14 @@ def before_send(event: dict, hint: dict) -> dict | None:
|
||||
if settings.DEBUG:
|
||||
return None
|
||||
return event
|
||||
|
||||
|
||||
def get_http_meta():
|
||||
"""Get sentry-related meta key-values"""
|
||||
scope = get_current_scope()
|
||||
meta = {
|
||||
SENTRY_TRACE_HEADER_NAME: scope.get_traceparent() or "",
|
||||
}
|
||||
if bag := scope.get_baggage():
|
||||
meta[BAGGAGE_HEADER_NAME] = bag.serialize()
|
||||
return meta
|
||||
|
@ -494,86 +494,88 @@ class TestConfig(TestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_db_pool(self):
|
||||
"""Test DB Config with pool"""
|
||||
config = ConfigLoader()
|
||||
config.set("postgresql.host", "foo")
|
||||
config.set("postgresql.name", "foo")
|
||||
config.set("postgresql.user", "foo")
|
||||
config.set("postgresql.password", "foo")
|
||||
config.set("postgresql.port", "foo")
|
||||
config.set("postgresql.test.name", "foo")
|
||||
config.set("postgresql.use_pool", True)
|
||||
conf = django_db_config(config)
|
||||
self.assertEqual(
|
||||
conf,
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "foo",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"pool": True,
|
||||
"sslcert": None,
|
||||
"sslkey": None,
|
||||
"sslmode": None,
|
||||
"sslrootcert": None,
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
"CONN_MAX_AGE": 0,
|
||||
"CONN_HEALTH_CHECKS": False,
|
||||
"DISABLE_SERVER_SIDE_CURSORS": False,
|
||||
}
|
||||
},
|
||||
)
|
||||
# FIXME: Temporarily force pool to be deactivated.
|
||||
# See https://github.com/goauthentik/authentik/issues/14320
|
||||
# def test_db_pool(self):
|
||||
# """Test DB Config with pool"""
|
||||
# config = ConfigLoader()
|
||||
# config.set("postgresql.host", "foo")
|
||||
# config.set("postgresql.name", "foo")
|
||||
# config.set("postgresql.user", "foo")
|
||||
# config.set("postgresql.password", "foo")
|
||||
# config.set("postgresql.port", "foo")
|
||||
# config.set("postgresql.test.name", "foo")
|
||||
# config.set("postgresql.use_pool", True)
|
||||
# conf = django_db_config(config)
|
||||
# self.assertEqual(
|
||||
# conf,
|
||||
# {
|
||||
# "default": {
|
||||
# "ENGINE": "authentik.root.db",
|
||||
# "HOST": "foo",
|
||||
# "NAME": "foo",
|
||||
# "OPTIONS": {
|
||||
# "pool": True,
|
||||
# "sslcert": None,
|
||||
# "sslkey": None,
|
||||
# "sslmode": None,
|
||||
# "sslrootcert": None,
|
||||
# },
|
||||
# "PASSWORD": "foo",
|
||||
# "PORT": "foo",
|
||||
# "TEST": {"NAME": "foo"},
|
||||
# "USER": "foo",
|
||||
# "CONN_MAX_AGE": 0,
|
||||
# "CONN_HEALTH_CHECKS": False,
|
||||
# "DISABLE_SERVER_SIDE_CURSORS": False,
|
||||
# }
|
||||
# },
|
||||
# )
|
||||
|
||||
def test_db_pool_options(self):
|
||||
"""Test DB Config with pool"""
|
||||
config = ConfigLoader()
|
||||
config.set("postgresql.host", "foo")
|
||||
config.set("postgresql.name", "foo")
|
||||
config.set("postgresql.user", "foo")
|
||||
config.set("postgresql.password", "foo")
|
||||
config.set("postgresql.port", "foo")
|
||||
config.set("postgresql.test.name", "foo")
|
||||
config.set("postgresql.use_pool", True)
|
||||
config.set(
|
||||
"postgresql.pool_options",
|
||||
base64.b64encode(
|
||||
dumps(
|
||||
{
|
||||
"max_size": 15,
|
||||
}
|
||||
).encode()
|
||||
).decode(),
|
||||
)
|
||||
conf = django_db_config(config)
|
||||
self.assertEqual(
|
||||
conf,
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "foo",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"pool": {
|
||||
"max_size": 15,
|
||||
},
|
||||
"sslcert": None,
|
||||
"sslkey": None,
|
||||
"sslmode": None,
|
||||
"sslrootcert": None,
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
"CONN_MAX_AGE": 0,
|
||||
"CONN_HEALTH_CHECKS": False,
|
||||
"DISABLE_SERVER_SIDE_CURSORS": False,
|
||||
}
|
||||
},
|
||||
)
|
||||
# def test_db_pool_options(self):
|
||||
# """Test DB Config with pool"""
|
||||
# config = ConfigLoader()
|
||||
# config.set("postgresql.host", "foo")
|
||||
# config.set("postgresql.name", "foo")
|
||||
# config.set("postgresql.user", "foo")
|
||||
# config.set("postgresql.password", "foo")
|
||||
# config.set("postgresql.port", "foo")
|
||||
# config.set("postgresql.test.name", "foo")
|
||||
# config.set("postgresql.use_pool", True)
|
||||
# config.set(
|
||||
# "postgresql.pool_options",
|
||||
# base64.b64encode(
|
||||
# dumps(
|
||||
# {
|
||||
# "max_size": 15,
|
||||
# }
|
||||
# ).encode()
|
||||
# ).decode(),
|
||||
# )
|
||||
# conf = django_db_config(config)
|
||||
# self.assertEqual(
|
||||
# conf,
|
||||
# {
|
||||
# "default": {
|
||||
# "ENGINE": "authentik.root.db",
|
||||
# "HOST": "foo",
|
||||
# "NAME": "foo",
|
||||
# "OPTIONS": {
|
||||
# "pool": {
|
||||
# "max_size": 15,
|
||||
# },
|
||||
# "sslcert": None,
|
||||
# "sslkey": None,
|
||||
# "sslmode": None,
|
||||
# "sslrootcert": None,
|
||||
# },
|
||||
# "PASSWORD": "foo",
|
||||
# "PORT": "foo",
|
||||
# "TEST": {"NAME": "foo"},
|
||||
# "USER": "foo",
|
||||
# "CONN_MAX_AGE": 0,
|
||||
# "CONN_HEALTH_CHECKS": False,
|
||||
# "DISABLE_SERVER_SIDE_CURSORS": False,
|
||||
# }
|
||||
# },
|
||||
# )
|
||||
|
@ -11,7 +11,7 @@ from django.test.runner import DiscoverRunner
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.sentry import sentry_init
|
||||
from authentik.root.signals import post_startup, pre_startup, startup
|
||||
from tests.e2e.utils import get_docker_tag
|
||||
from tests.docker import get_docker_tag
|
||||
|
||||
# globally set maxDiff to none to show full assert error
|
||||
TestCase.maxDiff = None
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2025.4.0 Blueprint schema",
|
||||
"title": "authentik 2025.4.1 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.0}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.1}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -55,7 +55,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.0}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.4.1}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
4
go.mod
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.32.0
|
||||
github.com/getsentry/sentry-go v0.33.0
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
github.com/go-ldap/ldap/v3 v3.4.11
|
||||
github.com/go-openapi/runtime v0.28.0
|
||||
@ -27,7 +27,7 @@ require (
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2025040.1
|
||||
goauthentik.io/api/v3 v3.2025041.1
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.14.0
|
||||
|
8
go.sum
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.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY=
|
||||
github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
|
||||
github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg=
|
||||
github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
@ -290,8 +290,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
goauthentik.io/api/v3 v3.2025040.1 h1:rQEcMNpz84/LPX8LVFteOJuserrd4PnU4k1Iu/wWqhs=
|
||||
goauthentik.io/api/v3 v3.2025040.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
goauthentik.io/api/v3 v3.2025041.1 h1:GAN6AoTmfnCGgx1SyM07jP4/LR/T3rkTEyShSBd3Co8=
|
||||
goauthentik.io/api/v3 v3.2025041.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2025.4.0"
|
||||
const VERSION = "2025.4.1"
|
||||
|
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.1014.0",
|
||||
"aws-cdk": "^2.1015.0",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -17,9 +17,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1014.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1014.0.tgz",
|
||||
"integrity": "sha512-es101rtRAClix9BncNL54iW90MiOyRv4iCC5tv/firGDnidS6pPinuK0IIFt0RO6w0+3heRxWBXg8HY+f9877w==",
|
||||
"version": "2.1015.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1015.0.tgz",
|
||||
"integrity": "sha512-txd+yMVVybtLfiwT409+fahbP0SkiwhmQvQf6PVVYnWzDPSknxYlUNJHisHV4tJEcbHWn1QPsLmqqMT0bw8hBg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
@ -10,7 +10,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1014.0",
|
||||
"aws-cdk": "^2.1015.0",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.4.0
|
||||
Default: 2025.4.1
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.4.0",
|
||||
"version": "2025.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
182
pyproject.toml
182
pyproject.toml
@ -1,104 +1,104 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2025.4.0"
|
||||
version = "2025.4.1"
|
||||
description = ""
|
||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||
requires-python = "==3.13.*"
|
||||
dependencies = [
|
||||
"argon2-cffi",
|
||||
"celery",
|
||||
"channels",
|
||||
"channels-redis",
|
||||
"cryptography",
|
||||
"dacite",
|
||||
"deepmerge",
|
||||
"defusedxml",
|
||||
"django",
|
||||
"django-countries",
|
||||
"django-cte",
|
||||
"django-filter",
|
||||
"django-guardian",
|
||||
"django-model-utils",
|
||||
"django-pglock",
|
||||
"django-prometheus",
|
||||
"django-redis",
|
||||
"django-storages[s3]",
|
||||
"django-tenants",
|
||||
"djangorestframework",
|
||||
"djangorestframework-guardian",
|
||||
"docker",
|
||||
"drf-orjson-renderer",
|
||||
"drf-spectacular",
|
||||
"dumb-init",
|
||||
"duo-client",
|
||||
"fido2",
|
||||
"flower",
|
||||
"geoip2",
|
||||
"geopy",
|
||||
"google-api-python-client",
|
||||
"gssapi",
|
||||
"gunicorn",
|
||||
"jsonpatch",
|
||||
"jwcrypto",
|
||||
"kubernetes",
|
||||
"ldap3",
|
||||
"lxml",
|
||||
"msgraph-sdk",
|
||||
"opencontainers",
|
||||
"packaging",
|
||||
"paramiko",
|
||||
"psycopg[c, pool]",
|
||||
"pydantic",
|
||||
"pydantic-scim",
|
||||
"pyjwt",
|
||||
"pyrad",
|
||||
"python-kadmin-rs",
|
||||
"pyyaml",
|
||||
"requests-oauthlib",
|
||||
"scim2-filter-parser",
|
||||
"sentry-sdk",
|
||||
"service_identity",
|
||||
"setproctitle",
|
||||
"structlog",
|
||||
"swagger-spec-validator",
|
||||
"tenant-schemas-celery",
|
||||
"twilio",
|
||||
"ua-parser",
|
||||
"unidecode",
|
||||
"urllib3 <3",
|
||||
"uvicorn[standard]",
|
||||
"watchdog",
|
||||
"webauthn",
|
||||
"wsproto",
|
||||
"xmlsec",
|
||||
"zxcvbn",
|
||||
"argon2-cffi==23.1.0",
|
||||
"celery==5.5.2",
|
||||
"channels==4.2.2",
|
||||
"channels-redis==4.2.1",
|
||||
"cryptography==44.0.3",
|
||||
"dacite==1.9.2",
|
||||
"deepmerge==2.0",
|
||||
"defusedxml==0.7.1",
|
||||
"django==5.1.9",
|
||||
"django-countries==7.6.1",
|
||||
"django-cte==1.3.3",
|
||||
"django-filter==25.1",
|
||||
"django-guardian<3.0.0",
|
||||
"django-model-utils==5.0.0",
|
||||
"django-pglock==1.7.2",
|
||||
"django-prometheus==2.3.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-tenants==3.7.0",
|
||||
"djangorestframework==3.16.0",
|
||||
"djangorestframework-guardian==0.3.0",
|
||||
"docker==7.1.0",
|
||||
"drf-orjson-renderer==1.7.3",
|
||||
"drf-spectacular==0.28.0",
|
||||
"dumb-init==1.2.5.post1",
|
||||
"duo-client==5.5.0",
|
||||
"fido2==1.2.0",
|
||||
"flower==2.0.1",
|
||||
"geoip2==5.1.0",
|
||||
"geopy==2.4.1",
|
||||
"google-api-python-client==2.169.0",
|
||||
"gssapi==1.9.0",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonpatch==1.33",
|
||||
"jwcrypto==1.5.6",
|
||||
"kubernetes==32.0.1",
|
||||
"ldap3==2.9.1",
|
||||
"lxml==5.4.0",
|
||||
"msgraph-sdk==1.30.0",
|
||||
"opencontainers==0.0.14",
|
||||
"packaging==25.0",
|
||||
"paramiko==3.5.1",
|
||||
"psycopg[c,pool]==3.2.9",
|
||||
"pydantic==2.11.4",
|
||||
"pydantic-scim==0.0.8",
|
||||
"pyjwt==2.10.1",
|
||||
"pyrad==2.4",
|
||||
"python-kadmin-rs==0.6.0",
|
||||
"pyyaml==6.0.2",
|
||||
"requests-oauthlib==2.0.0",
|
||||
"scim2-filter-parser==0.7.0",
|
||||
"sentry-sdk==2.28.0",
|
||||
"service-identity==24.2.0",
|
||||
"setproctitle==1.3.6",
|
||||
"structlog==25.3.0",
|
||||
"swagger-spec-validator==3.0.4",
|
||||
"tenant-schemas-celery==4.0.1",
|
||||
"twilio==9.6.1",
|
||||
"ua-parser==1.0.1",
|
||||
"unidecode==1.4.0",
|
||||
"urllib3<3",
|
||||
"uvicorn[standard]==0.34.2",
|
||||
"watchdog==6.0.0",
|
||||
"webauthn==2.5.2",
|
||||
"wsproto==1.2.0",
|
||||
"xmlsec==1.3.15",
|
||||
"zxcvbn==4.5.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"aws-cdk-lib",
|
||||
"bandit",
|
||||
"black",
|
||||
"bump2version",
|
||||
"channels[daphne]",
|
||||
"codespell",
|
||||
"colorama",
|
||||
"constructs",
|
||||
"coverage[toml]",
|
||||
"debugpy",
|
||||
"drf-jsonschema-serializer",
|
||||
"freezegun",
|
||||
"importlib-metadata",
|
||||
"k5test",
|
||||
"pdoc",
|
||||
"pytest",
|
||||
"pytest-django",
|
||||
"pytest-github-actions-annotate-failures",
|
||||
"pytest-randomly",
|
||||
"pytest-timeout",
|
||||
"requests-mock",
|
||||
"ruff",
|
||||
"selenium",
|
||||
"aws-cdk-lib==2.188.0",
|
||||
"bandit==1.8.3",
|
||||
"black==25.1.0",
|
||||
"bump2version==1.0.1",
|
||||
"channels[daphne]==4.2.2",
|
||||
"codespell==2.4.1",
|
||||
"colorama==0.4.6",
|
||||
"constructs==10.4.2",
|
||||
"coverage[toml]==7.8.0",
|
||||
"debugpy==1.8.14",
|
||||
"drf-jsonschema-serializer==3.0.0",
|
||||
"freezegun==1.5.1",
|
||||
"importlib-metadata==8.6.1",
|
||||
"k5test==0.10.4",
|
||||
"pdoc==15.0.3",
|
||||
"pytest==8.3.5",
|
||||
"pytest-django==4.11.1",
|
||||
"pytest-github-actions-annotate-failures==0.3.0",
|
||||
"pytest-randomly==3.16.0",
|
||||
"pytest-timeout==2.4.0",
|
||||
"requests-mock==1.12.1",
|
||||
"ruff==0.11.9",
|
||||
"selenium==4.32.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2025.4.0
|
||||
version: 2025.4.1
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
|
@ -0,0 +1,12 @@
|
||||
import socket
|
||||
from os import environ
|
||||
|
||||
IS_CI = "CI" in environ
|
||||
RETRIES = int(environ.get("RETRIES", "3")) if IS_CI else 1
|
||||
|
||||
|
||||
def get_local_ip() -> str:
|
||||
"""Get the local machine's IP"""
|
||||
hostname = socket.gethostname()
|
||||
ip_addr = socket.gethostbyname(hostname)
|
||||
return ip_addr
|
||||
|
190
tests/browser.py
Normal file
190
tests/browser.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""authentik e2e testing utilities"""
|
||||
|
||||
# This file cannot import anything django or anything that will load django
|
||||
|
||||
import json
|
||||
from sys import stderr
|
||||
from time import sleep
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.case import TestCase
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
||||
from django.urls import reverse
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.remote.command import Command
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.support import expected_conditions as ec
|
||||
from selenium.webdriver.support.wait import WebDriverWait
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from tests import IS_CI, RETRIES, get_local_ip
|
||||
from tests.websocket import BaseWebsocketTestCase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.core.models import User
|
||||
|
||||
|
||||
class BaseSeleniumTestCase(TestCase):
|
||||
"""Mixin which adds helpers for spinning up Selenium"""
|
||||
|
||||
host = get_local_ip()
|
||||
wait_timeout: int
|
||||
user: "User"
|
||||
|
||||
def setUp(self):
|
||||
if IS_CI:
|
||||
print("::group::authentik Logs", file=stderr)
|
||||
from django.apps import apps
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
|
||||
apps.get_app_config("authentik_tenants").ready()
|
||||
self.wait_timeout = 60
|
||||
self.driver = self._get_driver()
|
||||
self.driver.implicitly_wait(30)
|
||||
self.wait = WebDriverWait(self.driver, self.wait_timeout)
|
||||
self.logger = get_logger()
|
||||
self.user = create_test_admin_user()
|
||||
super().setUp()
|
||||
|
||||
def _get_driver(self) -> WebDriver:
|
||||
count = 0
|
||||
try:
|
||||
opts = webdriver.ChromeOptions()
|
||||
opts.add_argument("--disable-search-engine-choice-screen")
|
||||
return webdriver.Chrome(options=opts)
|
||||
except WebDriverException:
|
||||
pass
|
||||
while count < RETRIES:
|
||||
try:
|
||||
driver = webdriver.Remote(
|
||||
command_executor="http://localhost:4444/wd/hub",
|
||||
options=webdriver.ChromeOptions(),
|
||||
)
|
||||
driver.maximize_window()
|
||||
return driver
|
||||
except WebDriverException:
|
||||
count += 1
|
||||
raise ValueError(f"Webdriver failed after {RETRIES}.")
|
||||
|
||||
def tearDown(self):
|
||||
if IS_CI:
|
||||
print("::endgroup::", file=stderr)
|
||||
super().tearDown()
|
||||
if IS_CI:
|
||||
print("::group::Browser logs")
|
||||
# Very verbose way to get browser logs
|
||||
# https://github.com/SeleniumHQ/selenium/pull/15641
|
||||
# for some reason this removes the `get_log` API from Remote Webdriver
|
||||
# and only keeps it on the local Chrome web driver, even when using
|
||||
# a remote chrome driver...? (nvm the fact this was released as a minor version)
|
||||
for line in self.driver.execute(Command.GET_LOG, {"type": "browser"})["value"]:
|
||||
print(line["message"])
|
||||
if IS_CI:
|
||||
print("::endgroup::")
|
||||
self.driver.quit()
|
||||
|
||||
def wait_for_url(self, desired_url):
|
||||
"""Wait until URL is `desired_url`."""
|
||||
self.wait.until(
|
||||
lambda driver: driver.current_url == desired_url,
|
||||
f"URL {self.driver.current_url} doesn't match expected URL {desired_url}",
|
||||
)
|
||||
|
||||
def url(self, view, query: dict | None = None, **kwargs) -> str:
|
||||
"""reverse `view` with `**kwargs` into full URL using live_server_url"""
|
||||
url = self.live_server_url + reverse(view, kwargs=kwargs)
|
||||
if query:
|
||||
return url + "?" + urlencode(query)
|
||||
return url
|
||||
|
||||
def if_user_url(self, path: str | None = None) -> str:
|
||||
"""same as self.url() but show URL in shell"""
|
||||
url = self.url("authentik_core:if-user")
|
||||
if path:
|
||||
return f"{url}#{path}"
|
||||
return url
|
||||
|
||||
def get_shadow_root(
|
||||
self, selector: str, container: WebElement | WebDriver | None = None
|
||||
) -> WebElement:
|
||||
"""Get shadow root element's inner shadowRoot"""
|
||||
if not container:
|
||||
container = self.driver
|
||||
shadow_root = container.find_element(By.CSS_SELECTOR, selector)
|
||||
element = self.driver.execute_script("return arguments[0].shadowRoot", shadow_root)
|
||||
return element
|
||||
|
||||
def shady_dom(self) -> WebElement:
|
||||
class wrapper:
|
||||
def __init__(self, container: WebDriver):
|
||||
self.container = container
|
||||
|
||||
def find_element(self, by: str, selector: str) -> WebElement:
|
||||
return self.container.execute_script(
|
||||
"return document.__shady_native_querySelector(arguments[0])", selector
|
||||
)
|
||||
|
||||
return wrapper(self.driver)
|
||||
|
||||
def login(self, shadow_dom=True):
|
||||
"""Do entire login flow"""
|
||||
|
||||
if shadow_dom:
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
|
||||
else:
|
||||
flow_executor = self.shady_dom()
|
||||
identification_stage = self.shady_dom()
|
||||
|
||||
wait = WebDriverWait(identification_stage, self.wait_timeout)
|
||||
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "input[name=uidField]")))
|
||||
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").click()
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys(
|
||||
self.user.username
|
||||
)
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys(
|
||||
Keys.ENTER
|
||||
)
|
||||
|
||||
if shadow_dom:
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
password_stage = self.get_shadow_root("ak-stage-password", flow_executor)
|
||||
else:
|
||||
flow_executor = self.shady_dom()
|
||||
password_stage = self.shady_dom()
|
||||
|
||||
wait = WebDriverWait(password_stage, self.wait_timeout)
|
||||
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "input[name=password]")))
|
||||
|
||||
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(
|
||||
self.user.username
|
||||
)
|
||||
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER)
|
||||
sleep(1)
|
||||
|
||||
def assert_user(self, expected_user: "User"):
|
||||
"""Check users/me API and assert it matches expected_user"""
|
||||
from authentik.core.api.users import UserSerializer
|
||||
|
||||
self.driver.get(self.url("authentik_api:user-me") + "?format=json")
|
||||
user_json = self.driver.find_element(By.CSS_SELECTOR, "pre").text
|
||||
user = UserSerializer(data=json.loads(user_json)["user"])
|
||||
user.is_valid()
|
||||
self.assertEqual(user["username"].value, expected_user.username)
|
||||
self.assertEqual(user["name"].value, expected_user.name)
|
||||
self.assertEqual(user["email"].value, expected_user.email)
|
||||
|
||||
|
||||
class SeleniumTestCase(BaseSeleniumTestCase, StaticLiveServerTestCase):
|
||||
"""Test case which spins up a selenium instance and a HTTP-only test server"""
|
||||
|
||||
|
||||
class WebsocketSeleniumTestCase(BaseSeleniumTestCase, BaseWebsocketTestCase):
|
||||
"""Test case which spins up a selenium instance and a Websocket/HTTP test server"""
|
48
tests/decorators.py
Normal file
48
tests/decorators.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""authentik e2e testing utilities"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
|
||||
from django.test.testcases import TransactionTestCase
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from tests import RETRIES
|
||||
|
||||
|
||||
def retry(max_retires=RETRIES, exceptions=None):
|
||||
"""Retry test multiple times. Default to catching Selenium Timeout Exception"""
|
||||
|
||||
if not exceptions:
|
||||
exceptions = [WebDriverException, TimeoutException, NoSuchElementException]
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
def retry_actual(func: Callable):
|
||||
"""Retry test multiple times"""
|
||||
count = 1
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self: TransactionTestCase, *args, **kwargs):
|
||||
"""Run test again if we're below max_retries, including tearDown and
|
||||
setUp. Otherwise raise the error"""
|
||||
nonlocal count
|
||||
try:
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
except tuple(exceptions) as exc:
|
||||
count += 1
|
||||
if count > max_retires:
|
||||
logger.debug("Exceeded retry count", exc=exc, test=self)
|
||||
|
||||
raise exc
|
||||
logger.debug("Retrying on error", exc=exc, test=self)
|
||||
self.tearDown()
|
||||
self._post_teardown()
|
||||
self._pre_setup()
|
||||
self.setUp()
|
||||
return wrapper(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return retry_actual
|
139
tests/docker.py
Normal file
139
tests/docker.py
Normal file
@ -0,0 +1,139 @@
|
||||
"""Docker testing helpers"""
|
||||
|
||||
import os
|
||||
from time import sleep
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.case import TestCase
|
||||
|
||||
from docker import DockerClient, from_env
|
||||
from docker.errors import DockerException
|
||||
from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
|
||||
from authentik.lib.generators import generate_id
|
||||
from tests import IS_CI
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.outposts.models import Outpost
|
||||
|
||||
|
||||
def get_docker_tag() -> str:
|
||||
"""Get docker-tag based off of CI variables"""
|
||||
env_pr_branch = "GITHUB_HEAD_REF"
|
||||
default_branch = "GITHUB_REF"
|
||||
branch_name = os.environ.get(default_branch, "main")
|
||||
if os.environ.get(env_pr_branch, "") != "":
|
||||
branch_name = os.environ[env_pr_branch]
|
||||
branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
|
||||
return f"gh-{branch_name}"
|
||||
|
||||
|
||||
class DockerTestCase(TestCase):
|
||||
"""Mixin for dealing with containers"""
|
||||
|
||||
max_healthcheck_attempts = 30
|
||||
|
||||
__client: DockerClient
|
||||
__network: Network
|
||||
|
||||
__label_id = generate_id()
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.__client = from_env()
|
||||
self.__network = self.docker_client.networks.create(
|
||||
name=f"authentik-test-{self.__label_id}"
|
||||
)
|
||||
super().setUp()
|
||||
|
||||
@property
|
||||
def docker_client(self) -> DockerClient:
|
||||
return self.__client
|
||||
|
||||
@property
|
||||
def docker_network(self) -> Network:
|
||||
return self.__network
|
||||
|
||||
@property
|
||||
def docker_labels(self) -> dict:
|
||||
return {"io.goauthentik.test": self.__label_id}
|
||||
|
||||
def get_container_image(self, base: str) -> str:
|
||||
"""Try to pull docker image based on git branch, fallback to main if not found."""
|
||||
image = f"{base}:gh-main"
|
||||
if not IS_CI:
|
||||
return image
|
||||
try:
|
||||
branch_image = f"{base}:{get_docker_tag()}"
|
||||
self.docker_client.images.pull(branch_image)
|
||||
return branch_image
|
||||
except DockerException:
|
||||
self.docker_client.images.pull(image)
|
||||
return image
|
||||
|
||||
def run_container(self, **specs: dict[str, Any]) -> Container:
|
||||
if "network_mode" not in specs:
|
||||
specs["network"] = self.__network.name
|
||||
specs["labels"] = self.docker_labels
|
||||
specs["detach"] = True
|
||||
if hasattr(self, "live_server_url"):
|
||||
specs.setdefault("environment", {})
|
||||
specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url
|
||||
container = self.docker_client.containers.run(**specs)
|
||||
container.reload()
|
||||
state = container.attrs.get("State", {})
|
||||
if "Health" not in state:
|
||||
return container
|
||||
self.wait_for_container(container)
|
||||
return container
|
||||
|
||||
def output_container_logs(self, container: Container | None = None):
|
||||
"""Output the container logs to our STDOUT"""
|
||||
if IS_CI:
|
||||
image = container.image
|
||||
tags = image.tags[0] if len(image.tags) > 0 else str(image)
|
||||
print(f"::group::Container logs - {tags}")
|
||||
for log in container.logs().decode().split("\n"):
|
||||
print(log)
|
||||
if IS_CI:
|
||||
print("::endgroup::")
|
||||
|
||||
def tearDown(self):
|
||||
containers: list[Container] = self.docker_client.containers.list(
|
||||
filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())}
|
||||
)
|
||||
for container in containers:
|
||||
self.output_container_logs(container)
|
||||
try:
|
||||
container.stop()
|
||||
except DockerException:
|
||||
pass
|
||||
try:
|
||||
container.remove(force=True)
|
||||
except DockerException:
|
||||
pass
|
||||
self.__network.remove()
|
||||
super().tearDown()
|
||||
|
||||
def wait_for_container(self, container: Container):
|
||||
"""Check that container is health"""
|
||||
attempt = 0
|
||||
while attempt < self.max_healthcheck_attempts:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
attempt += 1
|
||||
sleep(0.5)
|
||||
self.failureException("Container failed to start")
|
||||
|
||||
def wait_for_outpost(self, outpost: "Outpost"):
|
||||
# Wait until outpost healthcheck succeeds
|
||||
attempt = 0
|
||||
while attempt < self.max_healthcheck_attempts:
|
||||
if len(outpost.state) > 0:
|
||||
state = outpost.state[0]
|
||||
if state.last_seen:
|
||||
return
|
||||
attempt += 1
|
||||
sleep(0.5)
|
||||
self.failureException("Outpost failed to become healthy")
|
@ -18,10 +18,12 @@ from authentik.stages.authenticator_static.models import (
|
||||
StaticToken,
|
||||
)
|
||||
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDevice
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestFlowsAuthenticator(SeleniumTestCase):
|
||||
class TestFlowsAuthenticator(DockerTestCase, SeleniumTestCase):
|
||||
"""test flow with otp stages"""
|
||||
|
||||
@retry()
|
||||
|
@ -11,10 +11,12 @@ from authentik.core.models import User
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.stages.identification.models import IdentificationStage
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestFlowsEnroll(SeleniumTestCase):
|
||||
class TestFlowsEnroll(DockerTestCase, SeleniumTestCase):
|
||||
"""Test Enroll flow"""
|
||||
|
||||
@retry()
|
||||
|
@ -1,12 +1,21 @@
|
||||
"""test default login flow"""
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from authentik.flows.models import Flow
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestFlowsLogin(SeleniumTestCase):
|
||||
class TestFlowsLogin(DockerTestCase, SeleniumTestCase):
|
||||
"""test default login flow"""
|
||||
|
||||
def tearDown(self):
|
||||
# Reset authentication flow's compatibility mode; we need to do this as its
|
||||
# not specified in the blueprint
|
||||
Flow.objects.filter(slug="default-authentication-flow").update(compatibility_mode=False)
|
||||
return super().tearDown()
|
||||
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
@ -23,3 +32,21 @@ class TestFlowsLogin(SeleniumTestCase):
|
||||
self.login()
|
||||
self.wait_for_url(self.if_user_url("/library"))
|
||||
self.assert_user(self.user)
|
||||
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
"default/flow-default-invalidation-flow.yaml",
|
||||
)
|
||||
def test_login_compatibility_mode(self):
|
||||
"""test default login flow with compatibility mode enabled"""
|
||||
Flow.objects.filter(slug="default-authentication-flow").update(compatibility_mode=True)
|
||||
self.driver.get(
|
||||
self.url(
|
||||
"authentik_core:if-flow",
|
||||
flow_slug="default-authentication-flow",
|
||||
)
|
||||
)
|
||||
self.login(shadow_dom=False)
|
||||
self.wait_for_url(self.if_user_url("/library"))
|
||||
self.assert_user(self.user)
|
||||
|
53
tests/e2e/test_flows_login_sfe.py
Normal file
53
tests/e2e/test_flows_login_sfe.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""test default login (using SFE interface) flow"""
|
||||
|
||||
from time import sleep
|
||||
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestFlowsLoginSFE(DockerTestCase, SeleniumTestCase):
|
||||
"""test default login flow"""
|
||||
|
||||
def login(self):
|
||||
"""Do entire login flow adjusted for SFE"""
|
||||
flow_executor = self.driver.find_element(By.ID, "flow-sfe-container")
|
||||
identification_stage = flow_executor.find_element(By.ID, "ident-form")
|
||||
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").click()
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").send_keys(
|
||||
self.user.username
|
||||
)
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uid_field]").send_keys(
|
||||
Keys.ENTER
|
||||
)
|
||||
|
||||
password_stage = flow_executor.find_element(By.ID, "password-form")
|
||||
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(
|
||||
self.user.username
|
||||
)
|
||||
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER)
|
||||
sleep(1)
|
||||
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
"default/flow-default-invalidation-flow.yaml",
|
||||
)
|
||||
def test_login(self):
|
||||
"""test default login flow"""
|
||||
self.driver.get(
|
||||
self.url(
|
||||
"authentik_core:if-flow",
|
||||
flow_slug="default-authentication-flow",
|
||||
query={"sfe": True},
|
||||
)
|
||||
)
|
||||
self.login()
|
||||
self.wait_for_url(self.if_user_url("/library"))
|
||||
self.assert_user(self.user)
|
@ -13,10 +13,12 @@ from authentik.flows.models import Flow
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.identification.models import IdentificationStage
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestFlowsRecovery(SeleniumTestCase):
|
||||
class TestFlowsRecovery(DockerTestCase, SeleniumTestCase):
|
||||
"""Test Recovery flow"""
|
||||
|
||||
def initial_stages(self, user: User):
|
||||
|
@ -8,10 +8,12 @@ from authentik.core.models import User
|
||||
from authentik.flows.models import Flow, FlowDesignation
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.stages.password.models import PasswordStage
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestFlowsStageSetup(SeleniumTestCase):
|
||||
class TestFlowsStageSetup(DockerTestCase, SeleniumTestCase):
|
||||
"""test stage setup flows"""
|
||||
|
||||
@retry()
|
||||
|
@ -16,10 +16,12 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
from authentik.outposts.models import Outpost, OutpostConfig, OutpostType
|
||||
from authentik.providers.ldap.models import APIAccessMode, LDAPProvider
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
from tests.websocket import WebsocketTestCase
|
||||
|
||||
|
||||
class TestProviderLDAP(SeleniumTestCase):
|
||||
class TestProviderLDAP(DockerTestCase, WebsocketTestCase):
|
||||
"""LDAP and Outpost e2e tests"""
|
||||
|
||||
def start_ldap(self, outpost: Outpost):
|
||||
|
@ -18,10 +18,12 @@ from authentik.providers.oauth2.models import (
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
)
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestProviderOAuth2Github(SeleniumTestCase):
|
||||
class TestProviderOAuth2Github(DockerTestCase, SeleniumTestCase):
|
||||
"""test OAuth Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestProviderOAuth2OAuth(SeleniumTestCase):
|
||||
class TestProviderOAuth2OAuth(DockerTestCase, SeleniumTestCase):
|
||||
"""test OAuth with OAuth Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestProviderOAuth2OIDC(SeleniumTestCase):
|
||||
class TestProviderOAuth2OIDC(DockerTestCase, SeleniumTestCase):
|
||||
"""test OAuth with OpenID Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -26,10 +26,12 @@ from authentik.providers.oauth2.models import (
|
||||
RedirectURIMatchingMode,
|
||||
ScopeMapping,
|
||||
)
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
|
||||
class TestProviderOAuth2OIDCImplicit(DockerTestCase, SeleniumTestCase):
|
||||
"""test OAuth with OpenID Provider flow"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -3,11 +3,8 @@
|
||||
from base64 import b64encode
|
||||
from dataclasses import asdict
|
||||
from json import loads
|
||||
from sys import platform
|
||||
from time import sleep
|
||||
from unittest.case import skip, skipUnless
|
||||
|
||||
from channels.testing import ChannelsLiveServerTestCase
|
||||
from jwt import decode
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
@ -18,10 +15,13 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import DockerServiceConnection, Outpost, OutpostConfig, OutpostType
|
||||
from authentik.outposts.tasks import outpost_connection_discovery
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
from tests.websocket import WebsocketTestCase
|
||||
|
||||
|
||||
class TestProviderProxy(SeleniumTestCase):
|
||||
class TestProviderProxy(DockerTestCase, SeleniumTestCase):
|
||||
"""Proxy and Outpost e2e tests"""
|
||||
|
||||
def setUp(self):
|
||||
@ -37,13 +37,41 @@ class TestProviderProxy(SeleniumTestCase):
|
||||
"""Start proxy container based on outpost created"""
|
||||
self.run_container(
|
||||
image=self.get_container_image("ghcr.io/goauthentik/dev-proxy"),
|
||||
ports={
|
||||
"9000": "9000",
|
||||
},
|
||||
environment={
|
||||
"AUTHENTIK_TOKEN": outpost.token.key,
|
||||
},
|
||||
ports={"9000": "9000"},
|
||||
environment={"AUTHENTIK_TOKEN": outpost.token.key},
|
||||
)
|
||||
self.wait_for_outpost(outpost)
|
||||
|
||||
def _prepare(self):
|
||||
# set additionalHeaders to test later
|
||||
self.user.attributes["additionalHeaders"] = {"X-Foo": "bar"}
|
||||
self.user.save()
|
||||
|
||||
proxy: ProxyProvider = ProxyProvider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=Flow.objects.get(
|
||||
slug="default-provider-authorization-implicit-consent"
|
||||
),
|
||||
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
|
||||
internal_host=f"http://{self.host}",
|
||||
external_host="http://localhost:9000",
|
||||
basic_auth_enabled=True,
|
||||
basic_auth_user_attribute="basic-username",
|
||||
basic_auth_password_attribute="basic-password", # nosec
|
||||
)
|
||||
# Ensure OAuth2 Params are set
|
||||
proxy.set_oauth_defaults()
|
||||
proxy.save()
|
||||
# we need to create an application to actually access the proxy
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
|
||||
outpost: Outpost = Outpost.objects.create(
|
||||
name=generate_id(),
|
||||
type=OutpostType.PROXY,
|
||||
)
|
||||
outpost.providers.add(proxy)
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
|
||||
self.start_proxy(outpost)
|
||||
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
@ -61,44 +89,7 @@ class TestProviderProxy(SeleniumTestCase):
|
||||
@reconcile_app("authentik_crypto")
|
||||
def test_proxy_simple(self):
|
||||
"""Test simple outpost setup with single provider"""
|
||||
# set additionalHeaders to test later
|
||||
self.user.attributes["additionalHeaders"] = {"X-Foo": "bar"}
|
||||
self.user.save()
|
||||
|
||||
proxy: ProxyProvider = ProxyProvider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=Flow.objects.get(
|
||||
slug="default-provider-authorization-implicit-consent"
|
||||
),
|
||||
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
|
||||
internal_host=f"http://{self.host}",
|
||||
external_host="http://localhost:9000",
|
||||
)
|
||||
# Ensure OAuth2 Params are set
|
||||
proxy.set_oauth_defaults()
|
||||
proxy.save()
|
||||
# we need to create an application to actually access the proxy
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
|
||||
outpost: Outpost = Outpost.objects.create(
|
||||
name=generate_id(),
|
||||
type=OutpostType.PROXY,
|
||||
)
|
||||
outpost.providers.add(proxy)
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
|
||||
self.start_proxy(outpost)
|
||||
|
||||
# Wait until outpost healthcheck succeeds
|
||||
healthcheck_retries = 0
|
||||
while healthcheck_retries < 50: # noqa: PLR2004
|
||||
if len(outpost.state) > 0:
|
||||
state = outpost.state[0]
|
||||
if state.last_seen:
|
||||
break
|
||||
healthcheck_retries += 1
|
||||
sleep(0.5)
|
||||
sleep(5)
|
||||
|
||||
self._prepare()
|
||||
self.driver.get("http://localhost:9000/api")
|
||||
self.login()
|
||||
sleep(1)
|
||||
@ -137,49 +128,13 @@ class TestProviderProxy(SeleniumTestCase):
|
||||
@reconcile_app("authentik_crypto")
|
||||
def test_proxy_basic_auth(self):
|
||||
"""Test simple outpost setup with single provider"""
|
||||
self._prepare()
|
||||
# Setup basic auth
|
||||
cred = generate_id()
|
||||
attr = "basic-password" # nosec
|
||||
self.user.attributes["basic-username"] = cred
|
||||
self.user.attributes[attr] = cred
|
||||
self.user.attributes["basic-password"] = cred
|
||||
self.user.save()
|
||||
|
||||
proxy: ProxyProvider = ProxyProvider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=Flow.objects.get(
|
||||
slug="default-provider-authorization-implicit-consent"
|
||||
),
|
||||
invalidation_flow=Flow.objects.get(slug="default-provider-invalidation-flow"),
|
||||
internal_host=f"http://{self.host}",
|
||||
external_host="http://localhost:9000",
|
||||
basic_auth_enabled=True,
|
||||
basic_auth_user_attribute="basic-username",
|
||||
basic_auth_password_attribute=attr,
|
||||
)
|
||||
# Ensure OAuth2 Params are set
|
||||
proxy.set_oauth_defaults()
|
||||
proxy.save()
|
||||
# we need to create an application to actually access the proxy
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=proxy)
|
||||
outpost: Outpost = Outpost.objects.create(
|
||||
name=generate_id(),
|
||||
type=OutpostType.PROXY,
|
||||
)
|
||||
outpost.providers.add(proxy)
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
|
||||
self.start_proxy(outpost)
|
||||
|
||||
# Wait until outpost healthcheck succeeds
|
||||
healthcheck_retries = 0
|
||||
while healthcheck_retries < 50: # noqa: PLR2004
|
||||
if len(outpost.state) > 0:
|
||||
state = outpost.state[0]
|
||||
if state.last_seen:
|
||||
break
|
||||
healthcheck_retries += 1
|
||||
sleep(0.5)
|
||||
sleep(5)
|
||||
|
||||
self.driver.get("http://localhost:9000/api")
|
||||
self.login()
|
||||
sleep(1)
|
||||
@ -187,9 +142,9 @@ class TestProviderProxy(SeleniumTestCase):
|
||||
full_body_text = self.driver.find_element(By.CSS_SELECTOR, "pre").text
|
||||
body = loads(full_body_text)
|
||||
|
||||
self.assertEqual(body["headers"]["X-Authentik-Username"], [self.user.username])
|
||||
self.assertEqual(body.get("headers").get("X-Authentik-Username"), [self.user.username])
|
||||
auth_header = b64encode(f"{cred}:{cred}".encode()).decode()
|
||||
self.assertEqual(body["headers"]["Authorization"], [f"Basic {auth_header}"])
|
||||
self.assertEqual(body.get("headers").get("Authorization"), [f"Basic {auth_header}"])
|
||||
|
||||
self.driver.get("http://localhost:9000/outpost.goauthentik.io/sign_out")
|
||||
sleep(2)
|
||||
@ -199,10 +154,7 @@ class TestProviderProxy(SeleniumTestCase):
|
||||
self.assertIn("You've logged out of", title)
|
||||
|
||||
|
||||
# TODO: Fix flaky test
|
||||
@skip("Flaky test")
|
||||
@skipUnless(platform.startswith("linux"), "requires local docker")
|
||||
class TestProviderProxyConnect(ChannelsLiveServerTestCase):
|
||||
class TestProviderProxyConnect(DockerTestCase, WebsocketTestCase):
|
||||
"""Test Proxy connectivity over websockets"""
|
||||
|
||||
@retry(exceptions=[AssertionError])
|
||||
@ -241,14 +193,7 @@ class TestProviderProxyConnect(ChannelsLiveServerTestCase):
|
||||
outpost.build_user_permissions(outpost.user)
|
||||
|
||||
# Wait until outpost healthcheck succeeds
|
||||
healthcheck_retries = 0
|
||||
while healthcheck_retries < 50: # noqa: PLR2004
|
||||
if len(outpost.state) > 0:
|
||||
state = outpost.state[0]
|
||||
if state.last_seen and state.version:
|
||||
break
|
||||
healthcheck_retries += 1
|
||||
sleep(0.5)
|
||||
self.wait_for_outpost(outpost)
|
||||
|
||||
state = outpost.state
|
||||
self.assertGreaterEqual(len(state), 1)
|
||||
|
@ -13,10 +13,12 @@ from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestProviderProxyForward(SeleniumTestCase):
|
||||
class TestProviderProxyForward(DockerTestCase, SeleniumTestCase):
|
||||
"""Proxy and Outpost e2e tests"""
|
||||
|
||||
def setUp(self):
|
||||
@ -30,14 +32,11 @@ class TestProviderProxyForward(SeleniumTestCase):
|
||||
"""Start proxy container based on outpost created"""
|
||||
self.run_container(
|
||||
image=self.get_container_image("ghcr.io/goauthentik/dev-proxy"),
|
||||
ports={
|
||||
"9000": "9000",
|
||||
},
|
||||
environment={
|
||||
"AUTHENTIK_TOKEN": outpost.token.key,
|
||||
},
|
||||
ports={"9000": "9000"},
|
||||
environment={"AUTHENTIK_TOKEN": outpost.token.key},
|
||||
name="ak-test-outpost",
|
||||
)
|
||||
self.wait_for_outpost(outpost)
|
||||
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
@ -77,17 +76,6 @@ class TestProviderProxyForward(SeleniumTestCase):
|
||||
|
||||
self.start_outpost(outpost)
|
||||
|
||||
# Wait until outpost healthcheck succeeds
|
||||
healthcheck_retries = 0
|
||||
while healthcheck_retries < 50: # noqa: PLR2004
|
||||
if len(outpost.state) > 0:
|
||||
state = outpost.state[0]
|
||||
if state.last_seen:
|
||||
break
|
||||
healthcheck_retries += 1
|
||||
sleep(0.5)
|
||||
sleep(5)
|
||||
|
||||
@retry()
|
||||
def test_traefik(self):
|
||||
"""Test traefik"""
|
||||
|
@ -1,7 +1,6 @@
|
||||
"""Radius e2e tests"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from time import sleep
|
||||
|
||||
from pyrad.client import Client
|
||||
from pyrad.dictionary import Dictionary
|
||||
@ -9,14 +8,17 @@ from pyrad.packet import AccessAccept, AccessReject, AccessRequest
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.outposts.models import Outpost, OutpostConfig, OutpostType
|
||||
from authentik.providers.radius.models import RadiusProvider
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
from tests.websocket import WebsocketTestCase
|
||||
|
||||
|
||||
class TestProviderRadius(SeleniumTestCase):
|
||||
class TestProviderRadius(DockerTestCase, WebsocketTestCase):
|
||||
"""Radius Outpost e2e tests"""
|
||||
|
||||
def setUp(self):
|
||||
@ -28,13 +30,13 @@ class TestProviderRadius(SeleniumTestCase):
|
||||
self.run_container(
|
||||
image=self.get_container_image("ghcr.io/goauthentik/dev-radius"),
|
||||
ports={"1812/udp": "1812/udp"},
|
||||
environment={
|
||||
"AUTHENTIK_TOKEN": outpost.token.key,
|
||||
},
|
||||
environment={"AUTHENTIK_TOKEN": outpost.token.key},
|
||||
)
|
||||
self.wait_for_outpost(outpost)
|
||||
|
||||
def _prepare(self) -> User:
|
||||
"""prepare user, provider, app and container"""
|
||||
self.user = create_test_user()
|
||||
radius: RadiusProvider = RadiusProvider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=Flow.objects.get(slug="default-authentication-flow"),
|
||||
@ -50,17 +52,6 @@ class TestProviderRadius(SeleniumTestCase):
|
||||
outpost.providers.add(radius)
|
||||
|
||||
self.start_radius(outpost)
|
||||
|
||||
# Wait until outpost healthcheck succeeds
|
||||
healthcheck_retries = 0
|
||||
while healthcheck_retries < 50: # noqa: PLR2004
|
||||
if len(outpost.state) > 0:
|
||||
state = outpost.state[0]
|
||||
if state.last_seen:
|
||||
break
|
||||
healthcheck_retries += 1
|
||||
sleep(0.5)
|
||||
sleep(5)
|
||||
return outpost
|
||||
|
||||
@retry()
|
||||
|
@ -14,10 +14,12 @@ from authentik.policies.expression.models import ExpressionPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.sources.saml.processors.constants import SAML_BINDING_POST
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestProviderSAML(SeleniumTestCase):
|
||||
class TestProviderSAML(DockerTestCase, SeleniumTestCase):
|
||||
"""test SAML Provider flow"""
|
||||
|
||||
def setup_client(self, provider: SAMLProvider, force_post: bool = False):
|
||||
|
@ -11,10 +11,12 @@ from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
|
||||
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestSourceLDAPSamba(SeleniumTestCase):
|
||||
class TestSourceLDAPSamba(DockerTestCase, SeleniumTestCase):
|
||||
"""test LDAP Source"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -16,7 +16,9 @@ from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.registry import SourceType, registry
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.stages.identification.models import IdentificationStage
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class OAuth1Callback(OAuthCallback):
|
||||
@ -48,7 +50,7 @@ class OAUth1Type(SourceType):
|
||||
}
|
||||
|
||||
|
||||
class TestSourceOAuth1(SeleniumTestCase):
|
||||
class TestSourceOAuth1(DockerTestCase, SeleniumTestCase):
|
||||
"""Test OAuth1 Source"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
|
@ -16,10 +16,12 @@ from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.stages.identification.models import IdentificationStage
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
|
||||
class TestSourceOAuth2(SeleniumTestCase):
|
||||
class TestSourceOAuth2(DockerTestCase, SeleniumTestCase):
|
||||
"""test OAuth Source flow"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -16,7 +16,9 @@ from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
|
||||
from authentik.stages.identification.models import IdentificationStage
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
IDP_CERT = """-----BEGIN CERTIFICATE-----
|
||||
MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
||||
@ -70,7 +72,7 @@ Sm75WXsflOxuTn08LbgGc4s=
|
||||
-----END PRIVATE KEY-----"""
|
||||
|
||||
|
||||
class TestSourceSAML(SeleniumTestCase):
|
||||
class TestSourceSAML(DockerTestCase, SeleniumTestCase):
|
||||
"""test SAML Source flow"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -8,12 +8,14 @@ from docker.types import Healthcheck
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.sources.scim.models import SCIMSource
|
||||
from tests.e2e.utils import SeleniumTestCase, retry
|
||||
from tests.browser import SeleniumTestCase
|
||||
from tests.decorators import retry
|
||||
from tests.docker import DockerTestCase
|
||||
|
||||
TEST_POLL_MAX = 25
|
||||
|
||||
|
||||
class TestSourceSCIM(SeleniumTestCase):
|
||||
class TestSourceSCIM(DockerTestCase, SeleniumTestCase):
|
||||
"""test SCIM Source flow"""
|
||||
|
||||
def setUp(self):
|
||||
|
@ -1,317 +0,0 @@
|
||||
"""authentik e2e testing utilities"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache, wraps
|
||||
from os import environ
|
||||
from sys import stderr
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
from unittest.case import TestCase
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
||||
from django.db import connection
|
||||
from django.db.migrations.loader import MigrationLoader
|
||||
from django.test.testcases import TransactionTestCase
|
||||
from django.urls import reverse
|
||||
from docker import DockerClient, from_env
|
||||
from docker.errors import DockerException
|
||||
from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException, WebDriverException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
from selenium.webdriver.remote.command import Command
|
||||
from selenium.webdriver.remote.webdriver import WebDriver
|
||||
from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.support.wait import WebDriverWait
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
RETRIES = int(environ.get("RETRIES", "3"))
|
||||
IS_CI = "CI" in environ
|
||||
|
||||
|
||||
def get_docker_tag() -> str:
|
||||
"""Get docker-tag based off of CI variables"""
|
||||
env_pr_branch = "GITHUB_HEAD_REF"
|
||||
default_branch = "GITHUB_REF"
|
||||
branch_name = os.environ.get(default_branch, "main")
|
||||
if os.environ.get(env_pr_branch, "") != "":
|
||||
branch_name = os.environ[env_pr_branch]
|
||||
branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
|
||||
return f"gh-{branch_name}"
|
||||
|
||||
|
||||
def get_local_ip() -> str:
|
||||
"""Get the local machine's IP"""
|
||||
hostname = socket.gethostname()
|
||||
ip_addr = socket.gethostbyname(hostname)
|
||||
return ip_addr
|
||||
|
||||
|
||||
class DockerTestCase(TestCase):
|
||||
"""Mixin for dealing with containers"""
|
||||
|
||||
max_healthcheck_attempts = 30
|
||||
|
||||
__client: DockerClient
|
||||
__network: Network
|
||||
|
||||
__label_id = generate_id()
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.__client = from_env()
|
||||
self.__network = self.docker_client.networks.create(name=f"authentik-test-{generate_id()}")
|
||||
|
||||
@property
|
||||
def docker_client(self) -> DockerClient:
|
||||
return self.__client
|
||||
|
||||
@property
|
||||
def docker_network(self) -> Network:
|
||||
return self.__network
|
||||
|
||||
@property
|
||||
def docker_labels(self) -> dict:
|
||||
return {"io.goauthentik.test": self.__label_id}
|
||||
|
||||
def wait_for_container(self, container: Container):
|
||||
"""Check that container is health"""
|
||||
attempt = 0
|
||||
while True:
|
||||
container.reload()
|
||||
status = container.attrs.get("State", {}).get("Health", {}).get("Status")
|
||||
if status == "healthy":
|
||||
return container
|
||||
sleep(1)
|
||||
attempt += 1
|
||||
if attempt >= self.max_healthcheck_attempts:
|
||||
self.failureException("Container failed to start")
|
||||
|
||||
def get_container_image(self, base: str) -> str:
|
||||
"""Try to pull docker image based on git branch, fallback to main if not found."""
|
||||
image = f"{base}:gh-main"
|
||||
try:
|
||||
branch_image = f"{base}:{get_docker_tag()}"
|
||||
self.docker_client.images.pull(branch_image)
|
||||
return branch_image
|
||||
except DockerException:
|
||||
self.docker_client.images.pull(image)
|
||||
return image
|
||||
|
||||
def run_container(self, **specs: dict[str, Any]) -> Container:
|
||||
if "network_mode" not in specs:
|
||||
specs["network"] = self.__network.name
|
||||
specs["labels"] = self.docker_labels
|
||||
specs["detach"] = True
|
||||
if hasattr(self, "live_server_url"):
|
||||
specs.setdefault("environment", {})
|
||||
specs["environment"]["AUTHENTIK_HOST"] = self.live_server_url
|
||||
container = self.docker_client.containers.run(**specs)
|
||||
container.reload()
|
||||
state = container.attrs.get("State", {})
|
||||
if "Health" not in state:
|
||||
return container
|
||||
self.wait_for_container(container)
|
||||
return container
|
||||
|
||||
def output_container_logs(self, container: Container | None = None):
|
||||
"""Output the container logs to our STDOUT"""
|
||||
if IS_CI:
|
||||
image = container.image
|
||||
tags = image.tags[0] if len(image.tags) > 0 else str(image)
|
||||
print(f"::group::Container logs - {tags}")
|
||||
for log in container.logs().decode().split("\n"):
|
||||
print(log)
|
||||
if IS_CI:
|
||||
print("::endgroup::")
|
||||
|
||||
def tearDown(self):
|
||||
containers: list[Container] = self.docker_client.containers.list(
|
||||
filters={"label": ",".join(f"{x}={y}" for x, y in self.docker_labels.items())}
|
||||
)
|
||||
for container in containers:
|
||||
self.output_container_logs(container)
|
||||
try:
|
||||
container.kill()
|
||||
except DockerException:
|
||||
pass
|
||||
try:
|
||||
container.remove(force=True)
|
||||
except DockerException:
|
||||
pass
|
||||
self.__network.remove()
|
||||
|
||||
|
||||
class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
"""StaticLiveServerTestCase which automatically creates a Webdriver instance"""
|
||||
|
||||
host = get_local_ip()
|
||||
wait_timeout: int
|
||||
user: User
|
||||
|
||||
def setUp(self):
|
||||
if IS_CI:
|
||||
print("::group::authentik Logs", file=stderr)
|
||||
apps.get_app_config("authentik_tenants").ready()
|
||||
self.wait_timeout = 60
|
||||
self.driver = self._get_driver()
|
||||
self.driver.implicitly_wait(30)
|
||||
self.wait = WebDriverWait(self.driver, self.wait_timeout)
|
||||
self.logger = get_logger()
|
||||
self.user = create_test_admin_user()
|
||||
super().setUp()
|
||||
|
||||
def _get_driver(self) -> WebDriver:
|
||||
count = 0
|
||||
try:
|
||||
opts = webdriver.ChromeOptions()
|
||||
opts.add_argument("--disable-search-engine-choice-screen")
|
||||
return webdriver.Chrome(options=opts)
|
||||
except WebDriverException:
|
||||
pass
|
||||
while count < RETRIES:
|
||||
try:
|
||||
driver = webdriver.Remote(
|
||||
command_executor="http://localhost:4444/wd/hub",
|
||||
options=webdriver.ChromeOptions(),
|
||||
)
|
||||
driver.maximize_window()
|
||||
return driver
|
||||
except WebDriverException:
|
||||
count += 1
|
||||
raise ValueError(f"Webdriver failed after {RETRIES}.")
|
||||
|
||||
def tearDown(self):
|
||||
if IS_CI:
|
||||
print("::endgroup::", file=stderr)
|
||||
super().tearDown()
|
||||
if IS_CI:
|
||||
print("::group::Browser logs")
|
||||
# Very verbose way to get browser logs
|
||||
# https://github.com/SeleniumHQ/selenium/pull/15641
|
||||
# for some reason this removes the `get_log` API from Remote Webdriver
|
||||
# and only keeps it on the local Chrome web driver, even when using
|
||||
# a remote chrome driver...? (nvm the fact this was released as a minor version)
|
||||
for line in self.driver.execute(Command.GET_LOG, {"type": "browser"})["value"]:
|
||||
print(line["message"])
|
||||
if IS_CI:
|
||||
print("::endgroup::")
|
||||
self.driver.quit()
|
||||
|
||||
def wait_for_url(self, desired_url):
|
||||
"""Wait until URL is `desired_url`."""
|
||||
self.wait.until(
|
||||
lambda driver: driver.current_url == desired_url,
|
||||
f"URL {self.driver.current_url} doesn't match expected URL {desired_url}",
|
||||
)
|
||||
|
||||
def url(self, view, query: dict | None = None, **kwargs) -> str:
|
||||
"""reverse `view` with `**kwargs` into full URL using live_server_url"""
|
||||
url = self.live_server_url + reverse(view, kwargs=kwargs)
|
||||
if query:
|
||||
return url + "?" + urlencode(query)
|
||||
return url
|
||||
|
||||
def if_user_url(self, path: str | None = None) -> str:
|
||||
"""same as self.url() but show URL in shell"""
|
||||
url = self.url("authentik_core:if-user")
|
||||
if path:
|
||||
return f"{url}#{path}"
|
||||
return url
|
||||
|
||||
def get_shadow_root(
|
||||
self, selector: str, container: WebElement | WebDriver | None = None
|
||||
) -> WebElement:
|
||||
"""Get shadow root element's inner shadowRoot"""
|
||||
if not container:
|
||||
container = self.driver
|
||||
shadow_root = container.find_element(By.CSS_SELECTOR, selector)
|
||||
element = self.driver.execute_script("return arguments[0].shadowRoot", shadow_root)
|
||||
return element
|
||||
|
||||
def login(self):
|
||||
"""Do entire login flow and check user afterwards"""
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
|
||||
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").click()
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys(
|
||||
self.user.username
|
||||
)
|
||||
identification_stage.find_element(By.CSS_SELECTOR, "input[name=uidField]").send_keys(
|
||||
Keys.ENTER
|
||||
)
|
||||
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
password_stage = self.get_shadow_root("ak-stage-password", flow_executor)
|
||||
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(
|
||||
self.user.username
|
||||
)
|
||||
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(Keys.ENTER)
|
||||
sleep(1)
|
||||
|
||||
def assert_user(self, expected_user: User):
|
||||
"""Check users/me API and assert it matches expected_user"""
|
||||
self.driver.get(self.url("authentik_api:user-me") + "?format=json")
|
||||
user_json = self.driver.find_element(By.CSS_SELECTOR, "pre").text
|
||||
user = UserSerializer(data=json.loads(user_json)["user"])
|
||||
user.is_valid()
|
||||
self.assertEqual(user["username"].value, expected_user.username)
|
||||
self.assertEqual(user["name"].value, expected_user.name)
|
||||
self.assertEqual(user["email"].value, expected_user.email)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_loader():
|
||||
"""Thin wrapper to lazily get a Migration Loader, only when it's needed
|
||||
and only once"""
|
||||
return MigrationLoader(connection)
|
||||
|
||||
|
||||
def retry(max_retires=RETRIES, exceptions=None):
|
||||
"""Retry test multiple times. Default to catching Selenium Timeout Exception"""
|
||||
|
||||
if not exceptions:
|
||||
exceptions = [WebDriverException, TimeoutException, NoSuchElementException]
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
def retry_actual(func: Callable):
|
||||
"""Retry test multiple times"""
|
||||
count = 1
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(self: TransactionTestCase, *args, **kwargs):
|
||||
"""Run test again if we're below max_retries, including tearDown and
|
||||
setUp. Otherwise raise the error"""
|
||||
nonlocal count
|
||||
try:
|
||||
return func(self, *args, **kwargs)
|
||||
|
||||
except tuple(exceptions) as exc:
|
||||
count += 1
|
||||
if count > max_retires:
|
||||
logger.debug("Exceeded retry count", exc=exc, test=self)
|
||||
|
||||
raise exc
|
||||
logger.debug("Retrying on error", exc=exc, test=self)
|
||||
self.tearDown()
|
||||
self._post_teardown()
|
||||
self._pre_setup()
|
||||
self.setUp()
|
||||
return wrapper(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return retry_actual
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@ -19,7 +19,7 @@ from authentik.outposts.models import (
|
||||
)
|
||||
from authentik.outposts.tasks import outpost_connection_discovery
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from tests.e2e.utils import DockerTestCase, get_docker_tag
|
||||
from tests.docker import DockerTestCase, get_docker_tag
|
||||
|
||||
|
||||
class OutpostDockerTests(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
|
@ -19,7 +19,7 @@ from authentik.outposts.models import (
|
||||
from authentik.outposts.tasks import outpost_connection_discovery
|
||||
from authentik.providers.proxy.controllers.docker import DockerController
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from tests.e2e.utils import DockerTestCase, get_docker_tag
|
||||
from tests.docker import DockerTestCase, get_docker_tag
|
||||
|
||||
|
||||
class TestProxyDocker(DockerTestCase, ChannelsLiveServerTestCase):
|
||||
|
52
tests/websocket.py
Normal file
52
tests/websocket.py
Normal file
@ -0,0 +1,52 @@
|
||||
# This file cannot import anything django or anything that will load django
|
||||
from sys import stderr
|
||||
|
||||
from channels.testing import ChannelsLiveServerTestCase
|
||||
from daphne.testing import DaphneProcess
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from tests import IS_CI, get_local_ip
|
||||
|
||||
|
||||
def set_database_connection():
|
||||
from django.conf import settings
|
||||
|
||||
settings.DATABASES["default"]["NAME"] = settings.DATABASES["default"]["TEST"]["NAME"]
|
||||
settings.TEST = True
|
||||
|
||||
|
||||
class DatabasePatchDaphneProcess(DaphneProcess):
|
||||
# See https://github.com/django/channels/issues/2048
|
||||
# See https://github.com/django/channels/pull/2033
|
||||
|
||||
def __init__(self, host, get_application, kwargs=None, setup=None, teardown=None):
|
||||
super().__init__(host, get_application, kwargs, setup, teardown)
|
||||
self.setup = set_database_connection
|
||||
|
||||
|
||||
class BaseWebsocketTestCase(ChannelsLiveServerTestCase):
|
||||
"""Base channels test case"""
|
||||
|
||||
host = get_local_ip()
|
||||
ProtocolServerProcess = DatabasePatchDaphneProcess
|
||||
|
||||
|
||||
class WebsocketTestCase(BaseWebsocketTestCase):
|
||||
"""Test case to allow testing against a running Websocket/HTTP server"""
|
||||
|
||||
def setUp(self):
|
||||
if IS_CI:
|
||||
print("::group::authentik Logs", file=stderr)
|
||||
from django.apps import apps
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
|
||||
apps.get_app_config("authentik_tenants").ready()
|
||||
self.logger = get_logger()
|
||||
self.user = create_test_admin_user()
|
||||
super().setUp()
|
||||
|
||||
def tearDown(self):
|
||||
if IS_CI:
|
||||
print("::endgroup::", file=stderr)
|
||||
super().tearDown()
|
259
uv.lock
generated
259
uv.lock
generated
@ -164,7 +164,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2025.4.0"
|
||||
version = "2025.4.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
@ -265,100 +265,100 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "argon2-cffi" },
|
||||
{ name = "celery" },
|
||||
{ name = "channels" },
|
||||
{ name = "channels-redis" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "dacite" },
|
||||
{ name = "deepmerge" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "django" },
|
||||
{ name = "django-countries" },
|
||||
{ name = "django-cte" },
|
||||
{ name = "django-filter" },
|
||||
{ name = "django-guardian" },
|
||||
{ name = "django-model-utils" },
|
||||
{ name = "django-pglock" },
|
||||
{ name = "django-prometheus" },
|
||||
{ name = "django-redis" },
|
||||
{ name = "django-storages", extras = ["s3"] },
|
||||
{ name = "argon2-cffi", specifier = "==23.1.0" },
|
||||
{ name = "celery", specifier = "==5.5.2" },
|
||||
{ name = "channels", specifier = "==4.2.2" },
|
||||
{ name = "channels-redis", specifier = "==4.2.1" },
|
||||
{ name = "cryptography", specifier = "==44.0.3" },
|
||||
{ name = "dacite", specifier = "==1.9.2" },
|
||||
{ name = "deepmerge", specifier = "==2.0" },
|
||||
{ name = "defusedxml", specifier = "==0.7.1" },
|
||||
{ name = "django", specifier = "==5.1.9" },
|
||||
{ name = "django-countries", specifier = "==7.6.1" },
|
||||
{ name = "django-cte", specifier = "==1.3.3" },
|
||||
{ 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-prometheus", specifier = "==2.3.1" },
|
||||
{ name = "django-redis", specifier = "==5.4.0" },
|
||||
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
|
||||
{ name = "django-tenants", git = "https://github.com/rissson/django-tenants.git?branch=authentik-fixes" },
|
||||
{ name = "djangorestframework", git = "https://github.com/authentik-community/django-rest-framework?rev=896722bab969fabc74a08b827da59409cf9f1a4e" },
|
||||
{ name = "djangorestframework-guardian" },
|
||||
{ name = "docker" },
|
||||
{ name = "drf-orjson-renderer" },
|
||||
{ name = "drf-spectacular" },
|
||||
{ name = "dumb-init" },
|
||||
{ name = "duo-client" },
|
||||
{ name = "fido2" },
|
||||
{ name = "flower" },
|
||||
{ name = "geoip2" },
|
||||
{ name = "geopy" },
|
||||
{ name = "google-api-python-client" },
|
||||
{ name = "gssapi" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "jsonpatch" },
|
||||
{ name = "jwcrypto" },
|
||||
{ name = "kubernetes" },
|
||||
{ name = "ldap3" },
|
||||
{ name = "lxml" },
|
||||
{ name = "msgraph-sdk" },
|
||||
{ name = "djangorestframework-guardian", specifier = "==0.3.0" },
|
||||
{ name = "docker", specifier = "==7.1.0" },
|
||||
{ name = "drf-orjson-renderer", specifier = "==1.7.3" },
|
||||
{ name = "drf-spectacular", specifier = "==0.28.0" },
|
||||
{ name = "dumb-init", specifier = "==1.2.5.post1" },
|
||||
{ name = "duo-client", specifier = "==5.5.0" },
|
||||
{ name = "fido2", specifier = "==1.2.0" },
|
||||
{ name = "flower", specifier = "==2.0.1" },
|
||||
{ name = "geoip2", specifier = "==5.1.0" },
|
||||
{ name = "geopy", specifier = "==2.4.1" },
|
||||
{ name = "google-api-python-client", specifier = "==2.169.0" },
|
||||
{ name = "gssapi", specifier = "==1.9.0" },
|
||||
{ name = "gunicorn", specifier = "==23.0.0" },
|
||||
{ name = "jsonpatch", specifier = "==1.33" },
|
||||
{ name = "jwcrypto", specifier = "==1.5.6" },
|
||||
{ name = "kubernetes", specifier = "==32.0.1" },
|
||||
{ name = "ldap3", specifier = "==2.9.1" },
|
||||
{ name = "lxml", specifier = "==5.4.0" },
|
||||
{ name = "msgraph-sdk", specifier = "==1.30.0" },
|
||||
{ name = "opencontainers", git = "https://github.com/BeryJu/oci-python?rev=c791b19056769cd67957322806809ab70f5bead8" },
|
||||
{ name = "packaging" },
|
||||
{ name = "paramiko" },
|
||||
{ name = "psycopg", extras = ["c", "pool"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-scim" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pyrad" },
|
||||
{ name = "python-kadmin-rs" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests-oauthlib" },
|
||||
{ name = "scim2-filter-parser" },
|
||||
{ name = "sentry-sdk" },
|
||||
{ name = "service-identity" },
|
||||
{ name = "setproctitle" },
|
||||
{ name = "structlog" },
|
||||
{ name = "swagger-spec-validator" },
|
||||
{ name = "tenant-schemas-celery" },
|
||||
{ name = "twilio" },
|
||||
{ name = "ua-parser" },
|
||||
{ name = "unidecode" },
|
||||
{ name = "packaging", specifier = "==25.0" },
|
||||
{ name = "paramiko", specifier = "==3.5.1" },
|
||||
{ name = "psycopg", extras = ["c", "pool"], specifier = "==3.2.9" },
|
||||
{ name = "pydantic", specifier = "==2.11.4" },
|
||||
{ name = "pydantic-scim", specifier = "==0.0.8" },
|
||||
{ name = "pyjwt", specifier = "==2.10.1" },
|
||||
{ name = "pyrad", specifier = "==2.4" },
|
||||
{ name = "python-kadmin-rs", specifier = "==0.6.0" },
|
||||
{ name = "pyyaml", specifier = "==6.0.2" },
|
||||
{ name = "requests-oauthlib", specifier = "==2.0.0" },
|
||||
{ name = "scim2-filter-parser", specifier = "==0.7.0" },
|
||||
{ name = "sentry-sdk", specifier = "==2.28.0" },
|
||||
{ name = "service-identity", specifier = "==24.2.0" },
|
||||
{ name = "setproctitle", specifier = "==1.3.6" },
|
||||
{ name = "structlog", specifier = "==25.3.0" },
|
||||
{ name = "swagger-spec-validator", specifier = "==3.0.4" },
|
||||
{ name = "tenant-schemas-celery", specifier = "==4.0.1" },
|
||||
{ name = "twilio", specifier = "==9.6.1" },
|
||||
{ name = "ua-parser", specifier = "==1.0.1" },
|
||||
{ name = "unidecode", specifier = "==1.4.0" },
|
||||
{ name = "urllib3", specifier = "<3" },
|
||||
{ name = "uvicorn", extras = ["standard"] },
|
||||
{ name = "watchdog" },
|
||||
{ name = "webauthn" },
|
||||
{ name = "wsproto" },
|
||||
{ name = "xmlsec" },
|
||||
{ name = "zxcvbn" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = "==0.34.2" },
|
||||
{ name = "watchdog", specifier = "==6.0.0" },
|
||||
{ name = "webauthn", specifier = "==2.5.2" },
|
||||
{ name = "wsproto", specifier = "==1.2.0" },
|
||||
{ name = "xmlsec", specifier = "==1.3.15" },
|
||||
{ name = "zxcvbn", specifier = "==4.5.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "aws-cdk-lib" },
|
||||
{ name = "bandit" },
|
||||
{ name = "black" },
|
||||
{ name = "bump2version" },
|
||||
{ name = "channels", extras = ["daphne"] },
|
||||
{ name = "codespell" },
|
||||
{ name = "colorama" },
|
||||
{ name = "constructs" },
|
||||
{ name = "coverage", extras = ["toml"] },
|
||||
{ name = "debugpy" },
|
||||
{ name = "drf-jsonschema-serializer" },
|
||||
{ name = "freezegun" },
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "k5test" },
|
||||
{ name = "pdoc" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "pytest-github-actions-annotate-failures" },
|
||||
{ name = "pytest-randomly" },
|
||||
{ name = "pytest-timeout" },
|
||||
{ name = "requests-mock" },
|
||||
{ name = "ruff" },
|
||||
{ name = "selenium" },
|
||||
{ name = "aws-cdk-lib", specifier = "==2.188.0" },
|
||||
{ name = "bandit", specifier = "==1.8.3" },
|
||||
{ name = "black", specifier = "==25.1.0" },
|
||||
{ name = "bump2version", specifier = "==1.0.1" },
|
||||
{ name = "channels", extras = ["daphne"], specifier = "==4.2.2" },
|
||||
{ name = "codespell", specifier = "==2.4.1" },
|
||||
{ name = "colorama", specifier = "==0.4.6" },
|
||||
{ name = "constructs", specifier = "==10.4.2" },
|
||||
{ name = "coverage", extras = ["toml"], specifier = "==7.8.0" },
|
||||
{ name = "debugpy", specifier = "==1.8.14" },
|
||||
{ name = "drf-jsonschema-serializer", specifier = "==3.0.0" },
|
||||
{ name = "freezegun", specifier = "==1.5.1" },
|
||||
{ name = "importlib-metadata", specifier = "==8.6.1" },
|
||||
{ name = "k5test", specifier = "==0.10.4" },
|
||||
{ name = "pdoc", specifier = "==15.0.3" },
|
||||
{ name = "pytest", specifier = "==8.3.5" },
|
||||
{ name = "pytest-django", specifier = "==4.11.1" },
|
||||
{ name = "pytest-github-actions-annotate-failures", specifier = "==0.3.0" },
|
||||
{ name = "pytest-randomly", specifier = "==3.16.0" },
|
||||
{ name = "pytest-timeout", specifier = "==2.4.0" },
|
||||
{ name = "requests-mock", specifier = "==1.12.1" },
|
||||
{ name = "ruff", specifier = "==0.11.9" },
|
||||
{ name = "selenium", specifier = "==4.32.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -387,16 +387,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-cdk-asset-awscli-v1"
|
||||
version = "2.2.231"
|
||||
version = "2.2.235"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsii" },
|
||||
{ name = "publication" },
|
||||
{ name = "typeguard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/b2/4a142d1d8093691c1b54b7b35f463f6defa1d0a8a08b7be2277eae73c726/aws_cdk_asset_awscli_v1-2.2.231.tar.gz", hash = "sha256:859d99e0fcdc2f6ada44090ad9f921743da3ca3a6d9f39ab06836d4c8e0fbc23", size = 17960944, upload-time = "2025-04-07T16:48:17.423Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/e8/6706ee98e9ba436aa07ca3a65d79cf40c50005f4f760f139bec0f6c3606a/aws_cdk_asset_awscli_v1-2.2.235.tar.gz", hash = "sha256:0a2023f9d32158ae86d43dfeac2ba7679e8a050cb99b7565b26192e60e57a91c", size = 19130124, upload-time = "2025-05-05T15:24:02.938Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/2d/dae06874ab3a66ad898d9c2d792c863b8b8249b203a1d8e3b36dfca44a93/aws_cdk_asset_awscli_v1-2.2.231-py3-none-any.whl", hash = "sha256:06d6b1d9e52272c315b944320f7039b47c6a6058f063fa33ab0ec06fea17bfbe", size = 17959325, upload-time = "2025-04-07T16:48:14.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/27/b167173d7fb784848563d596085dc8e95cabbe7b01f8a5c0ac1ed6a80c36/aws_cdk_asset_awscli_v1-2.2.235-py3-none-any.whl", hash = "sha256:701a47a97419b917ce73cf9c922a26c2895943b4b18b191e1285572b8584ae1e", size = 19128489, upload-time = "2025-05-05T15:23:59.87Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -571,30 +571,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.38.10"
|
||||
version = "1.38.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/26/c4a2f1c64efb5ae6b47b94cb543282ab5770aa2c4562aba6934af628cf76/boto3-1.38.10.tar.gz", hash = "sha256:af4c78a3faa1a56cbaeb9e06cd5580772138be519fc6e740b81db586d5d1910c", size = 111837, upload-time = "2025-05-06T19:29:55.088Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/89/a47f62b3f81a2e3484d2a2b8dd4906c5b6e57da0af0bd59d36f99ba20baf/boto3-1.38.13.tar.gz", hash = "sha256:6633bce2b73284acce1453ca85834c7c5a59e0dbcce1170be461cc079bdcdfcf", size = 111812, upload-time = "2025-05-09T19:33:02.962Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/fe/2b69dcdd433c32ba80b36eabfe799e8c3e0b08ff3e0fc06bc2e1cc065a19/boto3-1.38.10-py3-none-any.whl", hash = "sha256:26113a47d549bc3c46dbf56c8ab74f272c3da55df23e2c460fcf3c6c64d54dce", size = 139911, upload-time = "2025-05-06T19:29:51.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/25/79e219648f10d060d152542fcf3be0093120471774b99c1a7f41ceaeca9b/boto3-1.38.13-py3-none-any.whl", hash = "sha256:668400d13889d2d2fcd66ce785cc0b0fc040681f58a9c7f67daa9149a52b6c63", size = 139934, upload-time = "2025-05-09T19:33:00.855Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.38.10"
|
||||
version = "1.38.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/18/c03b763c831e269d76a7c0fcba53802f99bf68f8d4530af672ae96a6d343/botocore-1.38.10.tar.gz", hash = "sha256:c531c13803e0fad5b395c5ccab4c11ac88acfccde71c9b998df6fa841392a8fc", size = 13881598, upload-time = "2025-05-06T19:29:41.315Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/36/5b0faba074684744244e1e030e73fd5612bc2c38f557eec0a7f1a3d7ddd2/botocore-1.38.13.tar.gz", hash = "sha256:22feee15753cd3f9f7179d041604078a1024701497d27b22be7c6707e8d13ccb", size = 13882010, upload-time = "2025-05-09T19:32:51.172Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/92/2c522e277c95d35b4b83bff6a3839875d91b0d835a93545828a7046013c4/botocore-1.38.10-py3-none-any.whl", hash = "sha256:5244454bb9e8fbb6510145d1554e82fd243e8583507d83077ecf4f8efb66cb46", size = 13539530, upload-time = "2025-05-06T19:29:35.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/df/a7a8097471d5a3bc7d408850222292d874ffc190aef7e1cacf9af770339e/botocore-1.38.13-py3-none-any.whl", hash = "sha256:de29fee43a1f02787fb5b3756ec09917d5661ed95b2b2d64797ab04196f69e14", size = 13544507, upload-time = "2025-05-09T19:32:37.727Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -750,14 +750,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.8"
|
||||
version = "8.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/0f/62ca20172d4f87d93cf89665fbaedcd560ac48b465bd1d92bfc7ea6b0a41/click-8.2.0.tar.gz", hash = "sha256:f5452aeddd9988eefa20f90f05ab66f17fce1ee2a36907fd30b05bbb5953814d", size = 235857, upload-time = "2025-05-10T22:21:03.111Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/58/1f37bf81e3c689cc74ffa42102fa8915b59085f54a6e4a80bc6265c0f6bf/click-8.2.0-py3-none-any.whl", hash = "sha256:6b303f0b2aa85f1cb4e5303078fadcbcd4e476f114fab9b5007005711839325c", size = 102156, upload-time = "2025-05-10T22:21:01.352Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -979,16 +979,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.1.8"
|
||||
version = "5.1.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/00/40/45adc1b93435d1b418654a734b68351bb6ce0a0e5e37b2f0e9aeb1a2e233/Django-5.1.8.tar.gz", hash = "sha256:42e92a1dd2810072bcc40a39a212b693f94406d0ba0749e68eb642f31dc770b4", size = 10723602, upload-time = "2025-04-02T11:19:56.028Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/08/2e6f05494b3fc0a3c53736846034f882b82ee6351791a7815bbb45715d79/django-5.1.9.tar.gz", hash = "sha256:565881bdd0eb67da36442e9ac788bda90275386b549070d70aee86327781a4fc", size = 10710887, upload-time = "2025-05-07T14:06:45.257Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0d/e6dd0ed898b920fec35c6eeeb9acbeb831fff19ad21c5e684744df1d4a36/Django-5.1.8-py3-none-any.whl", hash = "sha256:11b28fa4b00e59d0def004e9ee012fefbb1065a5beb39ee838983fd24493ad4f", size = 8277130, upload-time = "2025-04-02T11:19:51.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/d1/d8b6b8250b84380d5a123e099ad3298a49407d81598faa13b43a2c6d96d7/django-5.1.9-py3-none-any.whl", hash = "sha256:2fd1d4a0a66a5ba702699eb692e75b0d828b73cc2f4e1fc4b6a854a918967411", size = 8277363, upload-time = "2025-05-07T14:06:37.426Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1063,15 +1063,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django-pglock"
|
||||
version = "1.7.1"
|
||||
version = "1.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-pgactivity" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/9f/3b4b2f7021b626b3981646254f04fcc2db681d7ba7b24be563552368be70/django_pglock-1.7.1.tar.gz", hash = "sha256:69050bdb522fd34585d49bb8a4798dbfbab9ec4754dd1927b1b9eef2ec0edadf", size = 16907, upload-time = "2024-12-16T01:53:47.29Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/1a/6c552b75d0a6b380215c919a667072e4949a3123f275506c6e6ff82f6b76/django_pglock-1.7.2.tar.gz", hash = "sha256:d1d8521b382a5819e8d14978d0e8e63ab2763cb784f5a6bfdbe5de807da4a61a", size = 17287, upload-time = "2025-05-15T22:07:23.744Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/bf/6fc72033801279b4ae2003c5b93cc22dfe0814ca1f56432f7cd06975381d/django_pglock-1.7.1-py3-none-any.whl", hash = "sha256:15db418fb56bee37fc8707038495b5085af9b8c203ebfa300202572127bdb3f0", size = 17343, upload-time = "2024-12-16T01:53:45.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/d2/21f19531945f03021460d40654bc2fc3b0c474b57b279d5f5a1c34be7f1b/django_pglock-1.7.2-py3-none-any.whl", hash = "sha256:2f9335527779445fe86507b37e26cfde485a32b91d982a8f80039d3bcd25d596", size = 17674, upload-time = "2025-05-15T22:07:22.618Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2065,7 +2065,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "msgraph-sdk"
|
||||
version = "1.29.0"
|
||||
version = "1.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-identity" },
|
||||
@ -2075,9 +2075,9 @@ dependencies = [
|
||||
{ name = "microsoft-kiota-serialization-text" },
|
||||
{ name = "msgraph-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/31/f37dd23324f6b23008fa19f6cd8d44edec803a4b750239ac005d085fe46a/msgraph_sdk-1.29.0.tar.gz", hash = "sha256:15cfb13d81df65395a663159a9e234a097cca731d60ebc8a3f4016443ba72602", size = 6593690, upload-time = "2025-05-08T12:52:20.528Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/4a/4ff19671f6ea06f98fb2405f73a90350e4719ccc692e85e9e0c2fa066826/msgraph_sdk-1.30.0.tar.gz", hash = "sha256:59e30af6d7244c9009146d620c331e169701b651317746b16f561e2e2452e73f", size = 6608744, upload-time = "2025-05-13T13:09:12.594Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/4f/735ff127e05d317ea0af233c0d6ac9e8644a0501e1523046a3fc799c5af7/msgraph_sdk-1.29.0-py3-none-any.whl", hash = "sha256:a52b13e95221da074fd001ccac19b54ebba40400dc4250af58417ac21f7fbed8", size = 27069538, upload-time = "2025-05-08T12:52:17.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/95/451ec4db8a924274a1f7260809ea03fe9c2b446d84dc5238e92e49a1b522/msgraph_sdk-1.30.0-py3-none-any.whl", hash = "sha256:6748f5cdb5ddbcff9e4f3fb073dd0a604cb00e1cf285dd0fea6969c93ba8282f", size = 27140767, upload-time = "2025-05-13T13:09:07.718Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2396,14 +2396,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg"
|
||||
version = "3.2.7"
|
||||
version = "3.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/16/ca27b38762a630b70243f51eb6a728f903a17cddc4961626fa540577aba6/psycopg-3.2.7.tar.gz", hash = "sha256:9afa609c7ebf139827a38c0bf61be9c024a3ed743f56443de9d38e1efc260bf3", size = 157238, upload-time = "2025-04-30T13:05:22.867Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/27/4a/93a6ab570a8d1a4ad171a1f4256e205ce48d828781312c0bbaff36380ecb/psycopg-3.2.9.tar.gz", hash = "sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700", size = 158122, upload-time = "2025-05-13T16:11:15.533Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/eb/6e32d259437125a17b0bc2624e06c86149c618501da1dcbc8539b2684f6f/psycopg-3.2.7-py3-none-any.whl", hash = "sha256:d39747d2d5b9658b69fa462ad21d31f1ba4a5722ad1d0cb952552bc0b4125451", size = 200028, upload-time = "2025-04-30T12:59:32.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b0/a73c195a56eb6b92e937a5ca58521a5c3346fb233345adc80fd3e2f542e2/psycopg-3.2.9-py3-none-any.whl", hash = "sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6", size = 202705, upload-time = "2025-05-13T16:06:26.584Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@ -2416,9 +2416,9 @@ pool = [
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-c"
|
||||
version = "3.2.7"
|
||||
version = "3.2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/13/74e41e5195e6a0a02b9f1e3560bc714021b725e89a40f5879df58d4189c6/psycopg_c-3.2.7.tar.gz", hash = "sha256:14455cf71ed29fdfa725c550f8c58056a852bb27b55eb59e3a0f127ca92751a3", size = 609707, upload-time = "2025-04-30T13:05:24.834Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/7f/6147cb842081b0b32692bf5a0fdf58e9ac95418ebac1184d4431ec44b85f/psycopg_c-3.2.9.tar.gz", hash = "sha256:8c9f654f20c6c56bddc4543a3caab236741ee94b6732ab7090b95605502210e2", size = 609538, upload-time = "2025-05-13T16:11:19.856Z" }
|
||||
|
||||
[[package]]
|
||||
name = "psycopg-pool"
|
||||
@ -2940,15 +2940,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.27.0"
|
||||
version = "2.28.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/b6/a92ae6fa6d7e6e536bc586776b1669b84fb724dfe21b8ff08297f2d7c969/sentry_sdk-2.27.0.tar.gz", hash = "sha256:90f4f883f9eff294aff59af3d58c2d1b64e3927b28d5ada2b9b41f5aeda47daf", size = 323556, upload-time = "2025-04-24T10:09:37.927Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/bb/6a41b2e0e9121bed4d2ec68d50568ab95c49f4744156a9bbb789c866c66d/sentry_sdk-2.28.0.tar.gz", hash = "sha256:14d2b73bc93afaf2a9412490329099e6217761cbab13b6ee8bc0e82927e1504e", size = 325052, upload-time = "2025-05-12T07:53:12.785Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/8b/fb496a45854e37930b57564a20fb8e90dd0f8b6add0491527c00f2163b00/sentry_sdk-2.27.0-py2.py3-none-any.whl", hash = "sha256:c58935bfff8af6a0856d37e8adebdbc7b3281c2b632ec823ef03cd108d216ff0", size = 340786, upload-time = "2025-04-24T10:09:35.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/4e/b1575833094c088dfdef63fbca794518860fcbc8002aadf51ebe8b6a387f/sentry_sdk-2.28.0-py2.py3-none-any.whl", hash = "sha256:51496e6cb3cb625b99c8e08907c67a9112360259b0ef08470e532c3ab184a232", size = 341693, upload-time = "2025-05-12T07:53:10.882Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3000,11 +3000,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.3.1"
|
||||
version = "80.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/dc/3976b322de9d2e87ed0007cf04cc7553969b6c7b3f48a565d0333748fbcd/setuptools-80.3.1.tar.gz", hash = "sha256:31e2c58dbb67c99c289f51c16d899afedae292b978f8051efaf6262d8212f927", size = 1315082, upload-time = "2025-05-04T18:47:04.397Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/0cc40fe41fd2adb80a2f388987f4f8db3c866c69e33e0b4c8b093fdf700e/setuptools-80.4.0.tar.gz", hash = "sha256:5a78f61820bc088c8e4add52932ae6b8cf423da2aff268c23f813cfbb13b4006", size = 1315008, upload-time = "2025-05-09T20:42:27.972Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/7e/5d8af3317ddbf9519b687bd1c39d8737fde07d97f54df65553faca5cffb1/setuptools-80.3.1-py3-none-any.whl", hash = "sha256:ea8e00d7992054c4c592aeb892f6ad51fe1b4d90cc6947cc45c45717c40ec537", size = 1201172, upload-time = "2025-05-04T18:47:02.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/93/dba5ed08c2e31ec7cdc2ce75705a484ef0be1a2fecac8a58272489349de8/setuptools-80.4.0-py3-none-any.whl", hash = "sha256:6cdc8cb9a7d590b237dbe4493614a9b75d0559b888047c1f67d49ba50fc3edb2", size = 1200812, upload-time = "2025-05-09T20:42:25.325Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3099,14 +3099,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tenant-schemas-celery"
|
||||
version = "3.0.0"
|
||||
version = "4.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "celery" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/fe/cfe19eb7cc3ad8e39d7df7b7c44414bf665b6ac6660c998eb498f89d16c6/tenant_schemas_celery-3.0.0.tar.gz", hash = "sha256:6be3ae1a5826f262f0f3dd343c6a85a34a1c59b89e04ae37de018f36562fed55", size = 15954, upload-time = "2024-05-19T11:16:41.837Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/f8/cf055bf171b5d83d6fe96f1840fba90d3d274be2b5c35cd21b873302b128/tenant_schemas_celery-4.0.1.tar.gz", hash = "sha256:8b8f055fcd82aa53274c09faf88653a935241518d93b86ab2d43a3df3b70c7f8", size = 18870, upload-time = "2025-04-22T18:23:51.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/2c/376e1e641ad08b374c75d896468a7be2e6906ce3621fd0c9f9dc09ff1963/tenant_schemas_celery-3.0.0-py3-none-any.whl", hash = "sha256:ca0f69e78ef698eb4813468231df5a0ab6a660c08e657b65f5ac92e16887eec8", size = 18108, upload-time = "2024-05-19T11:16:39.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a8/fd663c461550d6fedfb24e987acc1557ae5b6615ca08fc6c70dbaaa88aa5/tenant_schemas_celery-4.0.1-py3-none-any.whl", hash = "sha256:d06a3ff6956db3a95168ce2051b7bff2765f9ce0d070e14df92f07a2b60ae0a0", size = 21364, upload-time = "2025-04-22T18:23:49.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3160,7 +3160,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "twilio"
|
||||
version = "9.6.0"
|
||||
version = "9.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@ -3168,9 +3168,9 @@ dependencies = [
|
||||
{ name = "pyjwt" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/91/e9/ffc6e52465ffc16fad31fa64aea4e10e06cb4803447310c539c6fd66e859/twilio-9.6.0.tar.gz", hash = "sha256:bcb6cbc7f1dad09717d48d3e610573b6a55fa4a1f6fd1006f5b59cf6878b5562", size = 986499, upload-time = "2025-05-05T10:48:17.921Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/78/453ff0d35442c53490c22d077f580684a2352846c721d3e01f4c6dfa85bd/twilio-9.6.1.tar.gz", hash = "sha256:bb80b31d4d9e55c33872efef7fb99373149ed4093f21c56cf582797da45862f5", size = 987002, upload-time = "2025-05-13T09:56:55.183Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/04/1d9f452b1089c634bd6d64b40b9002c935b8214e9b08a7cbbfef204c8186/twilio-9.6.0-py2.py3-none-any.whl", hash = "sha256:19e8554c56324186973dcb3121de34626755db15331767e3021a2e23f80c6a3b", size = 1859151, upload-time = "2025-05-05T10:48:15.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f4/36fe2566a3ad7f71a89fd28ea2ebb6b2aa05c3a4d5a55b3ca6c358768c6b/twilio-9.6.1-py2.py3-none-any.whl", hash = "sha256:441fdab61b9a204eef770368380b962cbf08dc0fe9f757fe4b1d63ced37ddeed", size = 1859407, upload-time = "2025-05-13T09:56:53.094Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3487,12 +3487,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "xmlsec"
|
||||
version = "1.3.14"
|
||||
version = "1.3.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "lxml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/5b/244459b51dfe91211c1d9ec68fb5307dfc51e014698f52de575d25f753e0/xmlsec-1.3.14.tar.gz", hash = "sha256:934f804f2f895bcdb86f1eaee236b661013560ee69ec108d29cdd6e5f292a2d9", size = 68854, upload-time = "2024-04-17T19:34:29.388Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/0b/d851367799b865500efd0b255c39fc5d30892ea28c1569ca185a76d19576/xmlsec-1.3.15.tar.gz", hash = "sha256:baa856b83d0012e278e6f6cbec96ac8128de667ca9fa9a2eeb02c752e816f6d8", size = 114117, upload-time = "2025-03-11T22:37:00.567Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/17/0a272e6087ddb24bec96528acf061341845f458671e2a5cb35ff867a7c89/xmlsec-1.3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6ac2154311d32a6571e22f224ed16356029e59bd5ca76edeb3922a809adfe89c", size = 3746315, upload-time = "2025-03-11T22:36:43.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/91/7ce9317e3a2a03e3811e62be52e091c1e661da2d59b5c7f60ec1840a1e6b/xmlsec-1.3.15-cp313-cp313-win32.whl", hash = "sha256:5ed218129f89b0592926ad2be42c017bece469db9b7380dc41bc09b01ca26d5d", size = 2146158, upload-time = "2025-03-11T22:36:44.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/93311b9eedc11055ba667e666dc6ca1e2cc59c2356e91b73c3d5a6738fbf/xmlsec-1.3.15-cp313-cp313-win_amd64.whl", hash = "sha256:5fc29e69b064323317b3862751a3a8107670e0a17510ca4517bbdc1939a90b1a", size = 2442027, upload-time = "2025-03-11T22:36:46.431Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
|
@ -1,11 +0,0 @@
|
||||
import { create } from "@storybook/theming/create";
|
||||
|
||||
const isDarkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
|
||||
export default create({
|
||||
base: isDarkMode ? "dark" : "light",
|
||||
brandTitle: "authentik Storybook",
|
||||
brandUrl: "https://goauthentik.io",
|
||||
brandImage: "https://goauthentik.io/img/icon_left_brand_colour.svg",
|
||||
brandTarget: "_self",
|
||||
});
|
69
web/.storybook/main.js
Normal file
69
web/.storybook/main.js
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file Storybook configuration.
|
||||
* @import { StorybookConfig } from "@storybook/web-components-vite";
|
||||
* @import { InlineConfig, Plugin } from "vite";
|
||||
*/
|
||||
import { cwd } from "process";
|
||||
import postcssLit from "rollup-plugin-postcss-lit";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const NODE_ENV = process.env.NODE_ENV || "development";
|
||||
|
||||
const CSSImportPattern = /import [\w\$]+ from .+\.(css)/g;
|
||||
const JavaScriptFilePattern = /\.m?(js|ts|tsx)$/;
|
||||
|
||||
/**
|
||||
* @satisfies {Plugin<never>}
|
||||
*/
|
||||
const inlineCSSPlugin = {
|
||||
name: "inline-css-plugin",
|
||||
transform: (source, id) => {
|
||||
if (!JavaScriptFilePattern.test(id)) return;
|
||||
|
||||
const code = source.replace(CSSImportPattern, (match) => {
|
||||
return `${match}?inline`;
|
||||
});
|
||||
|
||||
return {
|
||||
code,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @satisfies {StorybookConfig}
|
||||
*/
|
||||
const config = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-controls",
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"storybook-addon-mock",
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/web-components-vite",
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
viteFinal({ plugins = [], ...config }) {
|
||||
/**
|
||||
* @satisfies {InlineConfig}
|
||||
*/
|
||||
const mergedConfig = {
|
||||
...config,
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify(NODE_ENV),
|
||||
"process.env.CWD": JSON.stringify(cwd()),
|
||||
"process.env.AK_API_BASE_PATH": JSON.stringify(process.env.AK_API_BASE_PATH || ""),
|
||||
},
|
||||
plugins: [inlineCSSPlugin, ...plugins, postcssLit(), tsconfigPaths()],
|
||||
};
|
||||
|
||||
return mergedConfig;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
@ -1,81 +0,0 @@
|
||||
import replace from "@rollup/plugin-replace";
|
||||
import type { StorybookConfig } from "@storybook/web-components-vite";
|
||||
import { cwd } from "process";
|
||||
import modify from "rollup-plugin-modify";
|
||||
import postcssLit from "rollup-plugin-postcss-lit";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export const isProdBuild = process.env.NODE_ENV === "production";
|
||||
export const apiBasePath = process.env.AK_API_BASE_PATH || "";
|
||||
|
||||
const importInlinePatterns = [
|
||||
'import AKGlobal from "(\\.\\./)*common/styles/authentik\\.css',
|
||||
'import AKGlobal from "@goauthentik/common/styles/authentik\\.css',
|
||||
'import PF.+ from "@patternfly/patternfly/\\S+\\.css',
|
||||
'import ThemeDark from "@goauthentik/common/styles/theme-dark\\.css',
|
||||
'import OneDark from "@goauthentik/common/styles/one-dark\\.css',
|
||||
'import styles from "\\./LibraryPageImpl\\.css',
|
||||
];
|
||||
|
||||
const importInlineRegexp = new RegExp(importInlinePatterns.map((a) => `(${a})`).join("|"));
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
|
||||
addons: [
|
||||
"@storybook/addon-controls",
|
||||
"@storybook/addon-links",
|
||||
"@storybook/addon-essentials",
|
||||
"storybook-addon-mock",
|
||||
],
|
||||
staticDirs: [
|
||||
{
|
||||
from: "../node_modules/@patternfly/patternfly/patternfly-base.css",
|
||||
to: "@patternfly/patternfly/patternfly-base.css",
|
||||
},
|
||||
{
|
||||
from: "../src/common/styles/authentik.css",
|
||||
to: "@goauthentik/common/styles/authentik.css",
|
||||
},
|
||||
{
|
||||
from: "../src/common/styles/theme-dark.css",
|
||||
to: "@goauthentik/common/styles/theme-dark.css",
|
||||
},
|
||||
{
|
||||
from: "../src/common/styles/one-dark.css",
|
||||
to: "@goauthentik/common/styles/one-dark.css",
|
||||
},
|
||||
],
|
||||
framework: {
|
||||
name: "@storybook/web-components-vite",
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: "tag",
|
||||
},
|
||||
async viteFinal(config) {
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
modify({
|
||||
find: importInlineRegexp,
|
||||
replace: (match: RegExpMatchArray) => {
|
||||
return `${match}?inline`;
|
||||
},
|
||||
}),
|
||||
replace({
|
||||
"process.env.NODE_ENV": JSON.stringify(
|
||||
isProdBuild ? "production" : "development",
|
||||
),
|
||||
"process.env.CWD": JSON.stringify(cwd()),
|
||||
"process.env.AK_API_BASE_PATH": JSON.stringify(apiBasePath),
|
||||
"preventAssignment": true,
|
||||
}),
|
||||
...config.plugins,
|
||||
postcssLit(),
|
||||
tsconfigPaths(),
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
38
web/.storybook/manager.js
Normal file
38
web/.storybook/manager.js
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @file Storybook manager configuration.
|
||||
*
|
||||
* @import { ThemeVarsPartial } from "storybook/internal/theming";
|
||||
*/
|
||||
import { createUIThemeEffect, resolveUITheme } from "@goauthentik/web/common/theme.ts";
|
||||
import { addons } from "@storybook/manager-api";
|
||||
import { create } from "@storybook/theming/create";
|
||||
|
||||
/**
|
||||
* @satisfies {Partial<ThemeVarsPartial>}
|
||||
*/
|
||||
const baseTheme = {
|
||||
brandTitle: "authentik Storybook",
|
||||
brandUrl: "https://goauthentik.io",
|
||||
brandImage: "https://goauthentik.io/img/icon_left_brand_colour.svg",
|
||||
brandTarget: "_self",
|
||||
};
|
||||
|
||||
const uiTheme = resolveUITheme();
|
||||
|
||||
addons.setConfig({
|
||||
theme: create({
|
||||
...baseTheme,
|
||||
base: uiTheme,
|
||||
}),
|
||||
enableShortcuts: false,
|
||||
});
|
||||
|
||||
createUIThemeEffect((nextUITheme) => {
|
||||
addons.setConfig({
|
||||
theme: create({
|
||||
...baseTheme,
|
||||
base: nextUITheme,
|
||||
}),
|
||||
enableShortcuts: false,
|
||||
});
|
||||
});
|
@ -1,9 +0,0 @@
|
||||
// .storybook/manager.js
|
||||
import { addons } from "@storybook/manager-api";
|
||||
|
||||
import authentikTheme from "./authentikTheme";
|
||||
|
||||
addons.setConfig({
|
||||
theme: authentikTheme,
|
||||
enableShortcuts: false,
|
||||
});
|
@ -1,5 +1,3 @@
|
||||
<link rel="stylesheet" href="@patternfly/patternfly/patternfly-base.css" />
|
||||
<link rel="stylesheet" href="@goauthentik/common/styles/authentik.css" />
|
||||
<style>
|
||||
body {
|
||||
overflow-y: scroll;
|
||||
|
32
web/.storybook/preview.js
Normal file
32
web/.storybook/preview.js
Normal file
@ -0,0 +1,32 @@
|
||||
/// <reference types="../types/css.js" />
|
||||
/**
|
||||
* @file Storybook manager configuration.
|
||||
*
|
||||
* @import { Preview } from "@storybook/web-components";
|
||||
*/
|
||||
import { applyDocumentTheme } from "@goauthentik/web/common/theme.ts";
|
||||
|
||||
applyDocumentTheme();
|
||||
|
||||
/**
|
||||
* @satisfies {Preview}
|
||||
*/
|
||||
const preview = {
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
method: "alphabetical",
|
||||
},
|
||||
},
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
@ -1,30 +0,0 @@
|
||||
import type { Preview } from "@storybook/web-components";
|
||||
|
||||
import "@goauthentik/common/styles/authentik.css";
|
||||
// import "@goauthentik/common/styles/theme-dark.css";
|
||||
import "@patternfly/patternfly/components/Brand/brand.css";
|
||||
import "@patternfly/patternfly/components/Page/page.css";
|
||||
// .storybook/preview.js
|
||||
import "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
method: "alphabetical",
|
||||
},
|
||||
},
|
||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||
cssUserPrefs: {
|
||||
"prefers-color-scheme": "light",
|
||||
},
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
2772
web/package-lock.json
generated
2772
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -19,7 +19,6 @@
|
||||
"lint:precommit": "wireit",
|
||||
"lint:types": "wireit",
|
||||
"lit-analyse": "wireit",
|
||||
"postinstall": "bash scripts/patch-spotlight.sh",
|
||||
"precommit": "wireit",
|
||||
"prettier": "wireit",
|
||||
"prettier-check": "wireit",
|
||||
@ -37,7 +36,14 @@
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
"./paths": "./paths.js",
|
||||
"./scripts/*": "./scripts/*.mjs"
|
||||
"./scripts/*": "./scripts/*.mjs",
|
||||
"./elements/*": "./src/elements/*",
|
||||
"./common/*": "./src/common/*",
|
||||
"./components/*": "./src/components/*",
|
||||
"./flow/*": "./src/flow/*",
|
||||
"./locales/*": "./src/locales/*",
|
||||
"./user/*": "./src/user/*",
|
||||
"./admin/*": "./src/admin/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@ -50,7 +56,7 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2025.4.0-1746018955",
|
||||
"@goauthentik/api": "^2025.4.1-1747332783",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
"@lit/reactive-element": "^2.0.4",
|
||||
@ -106,14 +112,14 @@
|
||||
"@hcaptcha/types": "^1.0.4",
|
||||
"@lit/localize-tools": "^0.8.0",
|
||||
"@rollup/plugin-replace": "^6.0.1",
|
||||
"@storybook/addon-essentials": "^8.3.4",
|
||||
"@storybook/addon-links": "^8.3.4",
|
||||
"@storybook/api": "^7.6.17",
|
||||
"@storybook/blocks": "^8.3.4",
|
||||
"@storybook/builder-vite": "^8.3.4",
|
||||
"@storybook/manager-api": "^8.3.4",
|
||||
"@storybook/web-components": "^8.3.4",
|
||||
"@storybook/web-components-vite": "^8.3.4",
|
||||
"@storybook/addon-essentials": "^8.6.12",
|
||||
"@storybook/addon-links": "^8.6.12",
|
||||
"@storybook/blocks": "^8.6.12",
|
||||
"@storybook/experimental-addon-test": "^8.6.12",
|
||||
"@storybook/manager-api": "^8.6.12",
|
||||
"@storybook/test": "^8.6.12",
|
||||
"@storybook/web-components": "^8.6.12",
|
||||
"@storybook/web-components-vite": "^8.6.12",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
||||
"@types/chart.js": "^2.9.41",
|
||||
"@types/codemirror": "^5.60.15",
|
||||
@ -145,9 +151,8 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"pseudolocale": "^2.1.0",
|
||||
"rollup-plugin-modify": "^3.0.0",
|
||||
"rollup-plugin-postcss-lit": "^2.1.0",
|
||||
"storybook": "^8.3.4",
|
||||
"storybook": "^8.6.12",
|
||||
"storybook-addon-mock": "^5.0.0",
|
||||
"turnstile-types": "^1.2.3",
|
||||
"typescript": "^5.6.2",
|
||||
|
@ -6,7 +6,7 @@
|
||||
* @import { Message as ESBuildMessage } from "esbuild";
|
||||
*/
|
||||
|
||||
const logPrefix = "👷 [ESBuild]";
|
||||
const logPrefix = "authentik/dev/web: ";
|
||||
const log = console.debug.bind(console, logPrefix);
|
||||
|
||||
/**
|
||||
@ -76,7 +76,7 @@ export class ESBuildObserver extends EventSource {
|
||||
*/
|
||||
#startListener = () => {
|
||||
this.#trackActivity();
|
||||
log("⏰ Build started...");
|
||||
log("⏰ Build started...");
|
||||
};
|
||||
|
||||
#internalErrorListener = () => {
|
||||
@ -86,7 +86,7 @@ export class ESBuildObserver extends EventSource {
|
||||
clearTimeout(this.#keepAliveInterval);
|
||||
|
||||
this.close();
|
||||
log("⛔️ Closing connection");
|
||||
log("⛔️ Closing connection");
|
||||
}
|
||||
};
|
||||
|
||||
@ -126,13 +126,13 @@ export class ESBuildObserver extends EventSource {
|
||||
this.#trackActivity();
|
||||
|
||||
if (!this.online) {
|
||||
log("🚫 Build finished while offline.");
|
||||
log("🚫 Build finished while offline.");
|
||||
this.deferredReload = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
log("🛎️ Build completed! Reloading...");
|
||||
log("🛎️ Build completed! Reloading...");
|
||||
|
||||
// We use an animation frame to keep the reload from happening before the
|
||||
// event loop has a chance to process the message.
|
||||
@ -189,13 +189,13 @@ export class ESBuildObserver extends EventSource {
|
||||
|
||||
if (!this.deferredReload) return;
|
||||
|
||||
log("🛎️ Reloading after offline build...");
|
||||
log("🛎️ Reloading after offline build...");
|
||||
this.deferredReload = false;
|
||||
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
log("🛎️ Listening for build changes...");
|
||||
log("🛎️ Listening for build changes...");
|
||||
|
||||
this.#keepAliveInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
@ -203,7 +203,7 @@ export class ESBuildObserver extends EventSource {
|
||||
if (now - this.lastUpdatedAt < 10_000) return;
|
||||
|
||||
this.alive = false;
|
||||
log("👋 Waiting for build to start...");
|
||||
log("👋 Waiting for build to start...");
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
|
@ -210,6 +210,9 @@ class PasswordStage extends Stage<PasswordChallenge> {
|
||||
<form id="password-form">
|
||||
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
||||
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
||||
<div class="form-label-group my-3">
|
||||
<input type="text" readonly class="form-control-plaintext" value="Welcome, ${this.challenge?.pendingUser}.">
|
||||
</div>
|
||||
<div class="form-label-group my-3 has-validation">
|
||||
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password">
|
||||
${this.renderInputError("password")}
|
||||
|
@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
TARGET="./node_modules/@spotlightjs/overlay/dist/index-"[0-9a-f]*.js
|
||||
|
||||
if [[ $(grep -L "QX2" "$TARGET" > /dev/null 2> /dev/null) ]]; then
|
||||
patch --forward -V none --no-backup-if-mismatch -p0 $TARGET <<EOF
|
||||
|
||||
TARGET=$(find "./node_modules/@spotlightjs/overlay/dist/" -name "index-[0-9a-f]*.js");
|
||||
|
||||
if ! grep -GL 'QX2 = ' "$TARGET" > /dev/null ; then
|
||||
patch --forward --no-backup-if-mismatch -p0 "$TARGET" <<EOF
|
||||
>>>>>>> main
|
||||
--- a/index-5682ce90.js 2024-06-13 16:19:28
|
||||
+++ b/index-5682ce90.js 2024-06-13 16:20:23
|
||||
@@ -4958,11 +4958,10 @@
|
||||
}
|
||||
);
|
||||
}
|
||||
-const q2 = w.lazy(() => import("./main-3257b7fc.js").then((n) => n.m));
|
||||
+const q2 = w.lazy(() => import("./main-3257b7fc.js").then((n) => n.m)), QX2 = () => {};
|
||||
function Gp({
|
||||
data: n,
|
||||
- onUpdateData: a = () => {
|
||||
- },
|
||||
+ onUpdateData: a = QX2,
|
||||
editingEnabled: s = !1,
|
||||
clipboardEnabled: o = !1,
|
||||
displayDataTypes: c = !1,
|
||||
EOF
|
||||
|
||||
else
|
||||
echo "spotlight overlay.js patch already applied"
|
||||
fi
|
@ -4,14 +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 { WithCapabilitiesConfig } from "@goauthentik/elements/Interface/capabilitiesProvider";
|
||||
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";
|
||||
@ -27,7 +26,7 @@ import "@goauthentik/elements/sidebar/Sidebar";
|
||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
||||
|
||||
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { customElement, eventOptions, property, query } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
@ -36,7 +35,7 @@ import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { CapabilitiesEnum, SessionUser, UiThemeEnum } from "@goauthentik/api";
|
||||
import { LicenseSummaryStatusEnum, SessionUser, UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
import {
|
||||
AdminSidebarEnterpriseEntries,
|
||||
@ -49,34 +48,37 @@ if (process.env.NODE_ENV === "development") {
|
||||
}
|
||||
|
||||
@customElement("ak-interface-admin")
|
||||
export class AdminInterface extends WithCapabilitiesConfig(
|
||||
WithLicenseSummary(AuthenticatedInterface),
|
||||
) {
|
||||
export class AdminInterface extends WithLicenseSummary(AuthenticatedInterface) {
|
||||
//#region Properties
|
||||
|
||||
@property({ type: Boolean })
|
||||
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
public notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
|
||||
@property({ type: Boolean })
|
||||
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
|
||||
public apiDrawerOpen = getURLParam("apiDrawerOpen", false);
|
||||
|
||||
ws: WebsocketClient;
|
||||
protected readonly ws: WebsocketClient;
|
||||
|
||||
@state()
|
||||
user?: SessionUser;
|
||||
@property({
|
||||
type: Object,
|
||||
attribute: false,
|
||||
reflect: false,
|
||||
})
|
||||
public user?: SessionUser;
|
||||
|
||||
@query("ak-about-modal")
|
||||
aboutModal?: AboutModal;
|
||||
public aboutModal?: AboutModal;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public sidebarOpen: boolean;
|
||||
|
||||
#toggleSidebar = () => {
|
||||
this.sidebarOpen = !this.sidebarOpen;
|
||||
};
|
||||
@eventOptions({ passive: true })
|
||||
protected sidebarListener(event: CustomEvent<SidebarToggleEventDetail>) {
|
||||
this.sidebarOpen = !!event.detail.open;
|
||||
}
|
||||
|
||||
#sidebarMatcher: MediaQueryList;
|
||||
#sidebarListener = (event: MediaQueryListEvent) => {
|
||||
#sidebarMediaQueryListener = (event: MediaQueryListEvent) => {
|
||||
this.sidebarOpen = event.matches;
|
||||
};
|
||||
|
||||
@ -84,59 +86,57 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
|
||||
//#region Styles
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
PFBase,
|
||||
PFPage,
|
||||
PFButton,
|
||||
PFDrawer,
|
||||
PFNav,
|
||||
css`
|
||||
.pf-c-page__main,
|
||||
.pf-c-drawer__content,
|
||||
.pf-c-page__drawer {
|
||||
z-index: auto !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
static styles: CSSResult[] = [
|
||||
PFBase,
|
||||
PFPage,
|
||||
PFButton,
|
||||
PFDrawer,
|
||||
PFNav,
|
||||
css`
|
||||
.pf-c-page__main,
|
||||
.pf-c-drawer__content,
|
||||
.pf-c-page__drawer {
|
||||
z-index: auto !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pf-c-page {
|
||||
background-color: var(--pf-c-page--BackgroundColor) !important;
|
||||
}
|
||||
|
||||
:host([theme="dark"]) {
|
||||
/* Global page background colour */
|
||||
.pf-c-page {
|
||||
background-color: var(--pf-c-page--BackgroundColor) !important;
|
||||
--pf-c-page--BackgroundColor: var(--ak-dark-background);
|
||||
}
|
||||
}
|
||||
|
||||
:host([theme="dark"]) {
|
||||
/* Global page background colour */
|
||||
.pf-c-page {
|
||||
--pf-c-page--BackgroundColor: var(--ak-dark-background);
|
||||
}
|
||||
}
|
||||
ak-page-navbar {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
ak-page-navbar {
|
||||
grid-area: header;
|
||||
}
|
||||
.ak-sidebar {
|
||||
grid-area: nav;
|
||||
}
|
||||
|
||||
.ak-sidebar {
|
||||
grid-area: nav;
|
||||
}
|
||||
|
||||
.pf-c-drawer__panel {
|
||||
z-index: var(--pf-global--ZIndex--xl);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
.pf-c-drawer__panel {
|
||||
z-index: var(--pf-global--ZIndex--xl);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
constructor() {
|
||||
configureSentry(true);
|
||||
super();
|
||||
this.ws = new WebsocketClient();
|
||||
|
||||
this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)");
|
||||
this.sidebarOpen = this.#sidebarMatcher.matches;
|
||||
}
|
||||
@ -144,8 +144,6 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
|
||||
|
||||
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
|
||||
this.notificationDrawerOpen = !this.notificationDrawerOpen;
|
||||
updateURLParams({
|
||||
@ -160,17 +158,17 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
});
|
||||
});
|
||||
|
||||
this.#sidebarMatcher.addEventListener("change", this.#sidebarListener);
|
||||
this.#sidebarMatcher.addEventListener("change", this.#sidebarMediaQueryListener, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
|
||||
this.#sidebarMatcher.removeEventListener("change", this.#sidebarListener);
|
||||
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
|
||||
}
|
||||
|
||||
async firstUpdated(): Promise<void> {
|
||||
configureSentry(true);
|
||||
this.user = await me();
|
||||
|
||||
const canAccessAdmin =
|
||||
@ -200,14 +198,14 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
|
||||
return html` <ak-locale-context>
|
||||
<div class="pf-c-page">
|
||||
<ak-page-navbar>
|
||||
<ak-page-navbar ?open=${this.sidebarOpen} @sidebar-toggle=${this.sidebarListener}>
|
||||
<ak-version-banner></ak-version-banner>
|
||||
<ak-enterprise-status interface="admin"></ak-enterprise-status>
|
||||
</ak-page-navbar>
|
||||
|
||||
<ak-sidebar class="${classMap(sidebarClasses)}">
|
||||
${renderSidebarItems(AdminSidebarEntries)}
|
||||
${this.can(CapabilitiesEnum.IsEnterprise)
|
||||
${this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed
|
||||
? renderSidebarItems(AdminSidebarEnterpriseEntries)
|
||||
: nothing}
|
||||
</ak-sidebar>
|
||||
|
@ -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.assignedBackchannelApplicationName
|
||||
: item.assignedApplicationName;
|
||||
const label =
|
||||
item.assignedBackchannelApplicationName || item.assignedApplicationName || item.name;
|
||||
|
||||
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];
|
||||
return [country.code, country.name, country.name];
|
||||
}
|
||||
|
||||
@customElement("ak-policy-geoip-form")
|
||||
@ -210,17 +210,16 @@ export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
|
||||
.getCountries()
|
||||
.then((results) => {
|
||||
if (!search) return results;
|
||||
|
||||
return results.filter((result) =>
|
||||
result.name
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()),
|
||||
);
|
||||
})
|
||||
.then((results) => {
|
||||
return {
|
||||
options: results.map(countryToPair),
|
||||
};
|
||||
});
|
||||
.then((results) => ({
|
||||
options: results.map(countryToPair),
|
||||
}));
|
||||
}}
|
||||
.selected=${(this.instance?.countriesObj ?? []).map(countryToPair)}
|
||||
available-label="${msg("Available Countries")}"
|
||||
|
@ -1,18 +1,14 @@
|
||||
import {
|
||||
CSRFHeaderName,
|
||||
CSRFMiddleware,
|
||||
EventMiddleware,
|
||||
LoggingMiddleware,
|
||||
} from "@goauthentik/common/api/middleware";
|
||||
import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
} from "@goauthentik/common/api/middleware.js";
|
||||
import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants.js";
|
||||
import { globalAK } from "@goauthentik/common/global.js";
|
||||
import { SentryMiddleware } from "@goauthentik/common/sentry";
|
||||
|
||||
import { Config, Configuration, CoreApi, CurrentBrand, RootApi } from "@goauthentik/api";
|
||||
|
||||
// HACK: Workaround for ESBuild not being able to hoist import statement across entrypoints.
|
||||
// This can be removed after ESBuild uses a single build context for all entrypoints.
|
||||
export { CSRFHeaderName };
|
||||
|
||||
let globalConfigPromise: Promise<Config> | undefined = Promise.resolve(globalAK().config);
|
||||
export function config(): Promise<Config> {
|
||||
if (!globalConfigPromise) {
|
||||
@ -66,21 +62,13 @@ export function brand(): Promise<CurrentBrand> {
|
||||
return globalBrandPromise;
|
||||
}
|
||||
|
||||
export function getMetaContent(key: string): string {
|
||||
const metaEl = document.querySelector<HTMLMetaElement>(`meta[name=${key}]`);
|
||||
if (!metaEl) return "";
|
||||
return metaEl.content;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = new Configuration({
|
||||
basePath: `${globalAK().api.base}api/v3`,
|
||||
headers: {
|
||||
"sentry-trace": getMetaContent("sentry-trace"),
|
||||
},
|
||||
middleware: [
|
||||
new CSRFMiddleware(),
|
||||
new EventMiddleware(),
|
||||
new LoggingMiddleware(globalAK().brand),
|
||||
new SentryMiddleware(),
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
|
||||
import { getCookie } from "@goauthentik/common/utils";
|
||||
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants.js";
|
||||
import { getCookie } from "@goauthentik/common/utils.js";
|
||||
|
||||
import {
|
||||
CurrentBrand,
|
||||
|
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2025.4.0";
|
||||
export const VERSION = "2025.4.1";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
@ -11,7 +11,6 @@ export const EVENT_REFRESH = "ak-refresh";
|
||||
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";
|
||||
export const EVENT_API_DRAWER_TOGGLE = "ak-api-drawer-toggle";
|
||||
export const EVENT_FLOW_INSPECTOR_TOGGLE = "ak-flow-inspector-toggle";
|
||||
export const EVENT_SIDEBAR_TOGGLE = "ak-sidebar-toggle";
|
||||
export const EVENT_WS_MESSAGE = "ak-ws-message";
|
||||
export const EVENT_FLOW_ADVANCE = "ak-flow-advance";
|
||||
export const EVENT_LOCALE_CHANGE = "ak-locale-change";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { config } from "@goauthentik/common/api/config";
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
|
||||
import {
|
||||
@ -10,8 +10,16 @@ import {
|
||||
setTag,
|
||||
setUser,
|
||||
} from "@sentry/browser";
|
||||
import { getTraceData } from "@sentry/core";
|
||||
import * as Spotlight from "@spotlightjs/spotlight";
|
||||
|
||||
import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api";
|
||||
import {
|
||||
CapabilitiesEnum,
|
||||
FetchParams,
|
||||
Middleware,
|
||||
RequestContext,
|
||||
ResponseError,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
/**
|
||||
* A generic error that can be thrown without triggering Sentry's reporting.
|
||||
@ -21,69 +29,94 @@ export class SentryIgnoredError extends Error {}
|
||||
export const TAG_SENTRY_COMPONENT = "authentik.component";
|
||||
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";
|
||||
|
||||
export async function configureSentry(canDoPpi = false): Promise<Config> {
|
||||
const cfg = await config();
|
||||
let _sentryConfigured = false;
|
||||
|
||||
if (cfg.errorReporting.enabled) {
|
||||
init({
|
||||
dsn: cfg.errorReporting.sentryDsn,
|
||||
ignoreErrors: [
|
||||
/network/gi,
|
||||
/fetch/gi,
|
||||
/module/gi,
|
||||
// Error on edge on ios,
|
||||
// https://stackoverflow.com/questions/69261499/what-is-instantsearchsdkjsbridgeclearhighlight
|
||||
/instantSearchSDKJSBridgeClearHighlight/gi,
|
||||
// Seems to be an issue in Safari and Firefox
|
||||
/MutationObserver.observe/gi,
|
||||
/NS_ERROR_FAILURE/gi,
|
||||
],
|
||||
release: `authentik@${VERSION}`,
|
||||
export function configureSentry(canDoPpi = false) {
|
||||
const cfg = globalAK().config;
|
||||
const debug = cfg.capabilities.includes(CapabilitiesEnum.CanDebug);
|
||||
if (!cfg.errorReporting.enabled && !debug) {
|
||||
return cfg;
|
||||
}
|
||||
init({
|
||||
dsn: cfg.errorReporting.sentryDsn,
|
||||
ignoreErrors: [
|
||||
/network/gi,
|
||||
/fetch/gi,
|
||||
/module/gi,
|
||||
// Error on edge on ios,
|
||||
// https://stackoverflow.com/questions/69261499/what-is-instantsearchsdkjsbridgeclearhighlight
|
||||
/instantSearchSDKJSBridgeClearHighlight/gi,
|
||||
// Seems to be an issue in Safari and Firefox
|
||||
/MutationObserver.observe/gi,
|
||||
/NS_ERROR_FAILURE/gi,
|
||||
],
|
||||
release: `authentik@${VERSION}`,
|
||||
integrations: [
|
||||
browserTracingIntegration({
|
||||
// https://docs.sentry.io/platforms/javascript/tracing/instrumentation/automatic-instrumentation/#custom-routing
|
||||
instrumentNavigation: false,
|
||||
instrumentPageLoad: false,
|
||||
traceFetch: false,
|
||||
}),
|
||||
],
|
||||
tracePropagationTargets: [window.location.origin],
|
||||
tracesSampleRate: debug ? 1.0 : cfg.errorReporting.tracesSampleRate,
|
||||
environment: cfg.errorReporting.environment,
|
||||
beforeSend: (
|
||||
event: ErrorEvent,
|
||||
hint: EventHint,
|
||||
): ErrorEvent | PromiseLike<ErrorEvent | null> | null => {
|
||||
if (!hint) {
|
||||
return event;
|
||||
}
|
||||
if (hint.originalException instanceof SentryIgnoredError) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
hint.originalException instanceof ResponseError ||
|
||||
hint.originalException instanceof DOMException
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
|
||||
if (window.location.pathname.includes("if/")) {
|
||||
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
|
||||
}
|
||||
if (debug) {
|
||||
Spotlight.init({
|
||||
injectImmediately: true,
|
||||
integrations: [
|
||||
browserTracingIntegration({
|
||||
shouldCreateSpanForRequest: (url: string) => {
|
||||
return url.startsWith(window.location.host);
|
||||
},
|
||||
Spotlight.sentry({
|
||||
injectIntoSDK: true,
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: cfg.errorReporting.tracesSampleRate,
|
||||
environment: cfg.errorReporting.environment,
|
||||
beforeSend: (
|
||||
event: ErrorEvent,
|
||||
hint: EventHint,
|
||||
): ErrorEvent | PromiseLike<ErrorEvent | null> | null => {
|
||||
if (!hint) {
|
||||
return event;
|
||||
}
|
||||
if (hint.originalException instanceof SentryIgnoredError) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
hint.originalException instanceof ResponseError ||
|
||||
hint.originalException instanceof DOMException
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
});
|
||||
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
|
||||
if (window.location.pathname.includes("if/")) {
|
||||
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
|
||||
}
|
||||
if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) {
|
||||
const Spotlight = await import("@spotlightjs/spotlight");
|
||||
|
||||
Spotlight.init({ injectImmediately: true });
|
||||
}
|
||||
if (cfg.errorReporting.sendPii && canDoPpi) {
|
||||
me().then((user) => {
|
||||
setUser({ email: user.user.email });
|
||||
console.debug("authentik/config: Sentry with PII enabled.");
|
||||
});
|
||||
} else {
|
||||
console.debug("authentik/config: Sentry enabled.");
|
||||
}
|
||||
console.debug("authentik/config: Enabled Sentry Spotlight");
|
||||
}
|
||||
if (cfg.errorReporting.sendPii && canDoPpi) {
|
||||
me().then((user) => {
|
||||
setUser({ email: user.user.email });
|
||||
console.debug("authentik/config: Sentry with PII enabled.");
|
||||
});
|
||||
} else {
|
||||
console.debug("authentik/config: Sentry enabled.");
|
||||
}
|
||||
_sentryConfigured = true;
|
||||
}
|
||||
|
||||
export class SentryMiddleware implements Middleware {
|
||||
pre?(context: RequestContext): Promise<FetchParams | void> {
|
||||
if (!_sentryConfigured) {
|
||||
return Promise.resolve(context);
|
||||
}
|
||||
const traceData = getTraceData();
|
||||
// @ts-ignore
|
||||
context.init.headers["baggage"] = traceData["baggage"];
|
||||
// @ts-ignore
|
||||
context.init.headers["sentry-trace"] = traceData["sentry-trace"];
|
||||
return Promise.resolve(context);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
@ -1,3 +1,12 @@
|
||||
/**
|
||||
* @file authentik base UI theme.
|
||||
*/
|
||||
|
||||
/* Defined to better identify the base theme when debugging constructed stylesheets. */
|
||||
.__AK_UI_BASE__ {
|
||||
--__AK_UI_BASE__: 1;
|
||||
}
|
||||
|
||||
/* #region Global */
|
||||
|
||||
:root {
|
||||
|
@ -1,42 +1,48 @@
|
||||
/*
|
||||
/**
|
||||
* @file Atom One Dark syntax highlighting theme.
|
||||
*
|
||||
* @see https://github.com/atom/one-dark-syntax
|
||||
*/
|
||||
|
||||
Atom One Dark by Daniel Gamage
|
||||
Original One Dark Syntax theme from https://github.com/atom/one-dark-syntax
|
||||
/* Defined to better identify the One Dark theme when debugging constructed stylesheets. */
|
||||
.__HIGHLIGHT_THEME_ONE_DARK__ {
|
||||
--__HIGHLIGHT_THEME_ONE_DARK__: 1;
|
||||
}
|
||||
|
||||
base: #282c34
|
||||
mono-1: #abb2bf
|
||||
mono-2: #818896
|
||||
mono-3: #5c6370
|
||||
hue-1: #56b6c2
|
||||
hue-2: #61aeee
|
||||
hue-3: #c678dd
|
||||
hue-4: #98c379
|
||||
hue-5: #e06c75
|
||||
hue-5-2: #be5046
|
||||
hue-6: #d19a66
|
||||
hue-6-2: #e6c07b
|
||||
|
||||
*/
|
||||
:root {
|
||||
--one-dark-base: #282c34;
|
||||
--one-dark-mono-1: #abb2bf;
|
||||
--one-dark-mono-2: #818896;
|
||||
--one-dark-mono-3: #5c6370;
|
||||
--one-dark-hue-1: #56b6c2;
|
||||
--one-dark-hue-2: #61aeee;
|
||||
--one-dark-hue-3: #c678dd;
|
||||
--one-dark-hue-4: #98c379;
|
||||
--one-dark-hue-5: #e06c75;
|
||||
--one-dark-hue-5-2: #be5046;
|
||||
--one-dark-hue-6: #d19a66;
|
||||
--one-dark-hue-6-2: #e6c07b;
|
||||
}
|
||||
|
||||
.hljs {
|
||||
color: #abb2bf;
|
||||
background: #282c34;
|
||||
color: var(--one-dark-mono-1);
|
||||
background: var(--one-dark-base);
|
||||
}
|
||||
|
||||
pre:has(.hljs) {
|
||||
background: #282c34;
|
||||
background: var(--one-dark-base);
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #5c6370;
|
||||
color: var(--one-dark-mono-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-keyword,
|
||||
.hljs-formula {
|
||||
color: #c678dd;
|
||||
color: var(--one-dark-hue-3);
|
||||
}
|
||||
|
||||
.hljs-section,
|
||||
@ -44,11 +50,11 @@ pre:has(.hljs) {
|
||||
.hljs-selector-tag,
|
||||
.hljs-deletion,
|
||||
.hljs-subst {
|
||||
color: #e06c75;
|
||||
color: var(--one-dark-hue-5);
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: #56b6c2;
|
||||
color: var(--one-dark-hue-1);
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
@ -56,7 +62,7 @@ pre:has(.hljs) {
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta .hljs-string {
|
||||
color: #98c379;
|
||||
color: var(--one-dark-hue-4);
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
@ -67,7 +73,7 @@ pre:has(.hljs) {
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-number {
|
||||
color: #d19a66;
|
||||
color: var(--one-dark-hue-6);
|
||||
}
|
||||
|
||||
.hljs-symbol,
|
||||
@ -76,13 +82,13 @@ pre:has(.hljs) {
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-title {
|
||||
color: #61aeee;
|
||||
color: var(--one-dark-hue-2);
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-title.class_,
|
||||
.hljs-class .hljs-title {
|
||||
color: #e6c07b;
|
||||
color: var(--one-dark-hue-6-2);
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
|
@ -1,3 +1,12 @@
|
||||
/**
|
||||
* @file authentik dark UI theme.
|
||||
*/
|
||||
|
||||
/* Defined to better identify the dark theme when debugging constructed stylesheets. */
|
||||
.__AK_UI_DARK__ {
|
||||
--__AK_UI_DARK__: 1;
|
||||
}
|
||||
|
||||
/* #region Global */
|
||||
|
||||
:root {
|
||||
@ -5,9 +14,6 @@
|
||||
--ak-global--Color--100: var(--ak-dark-foreground) !important;
|
||||
--pf-c-page__main-section--m-light--BackgroundColor: var(--ak-dark-background-darker);
|
||||
--pf-global--BorderColor--100: var(--ak-dark-background-lighter) !important;
|
||||
--ak-mermaid-message-text: var(--ak-dark-foreground) !important;
|
||||
--ak-mermaid-box-background-color: var(--ak-dark-background-lighter) !important;
|
||||
--ak-table-stripe-background: var(--pf-global--BackgroundColor--dark-200);
|
||||
}
|
||||
|
||||
body {
|
||||
@ -256,8 +262,13 @@ input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
color: var(--ak-dark-background-lighter);
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-plain:hover {
|
||||
color: var(--ak-dark-foreground);
|
||||
.pf-c-button.pf-m-plain {
|
||||
--pf-c-button--m-plain--focus--Color: var(--pf-global--Color--200);
|
||||
--pf-c-button--m-plain--hover--Color: var(--ak-dark-foreground);
|
||||
|
||||
&:focus:hover {
|
||||
color: var(--pf-c-button--m-plain--hover--Color);
|
||||
}
|
||||
}
|
||||
|
||||
.pf-c-button.pf-m-control {
|
||||
|
@ -1,17 +1,27 @@
|
||||
/**
|
||||
* @file Stylesheet utilities.
|
||||
*/
|
||||
import { CSSResult, CSSResultOrNative, ReactiveElement, css } from "lit";
|
||||
import { CSSResultOrNative, ReactiveElement, adoptStyles as adoptStyleSheetsShim, css } from "lit";
|
||||
|
||||
/**
|
||||
* Elements containing adoptable stylesheets.
|
||||
* Element-like objects containing adoptable stylesheets.
|
||||
*
|
||||
* Note that while these all possess the `adoptedStyleSheets` property,
|
||||
* browser differences and polyfills may make them not actually adoptable.
|
||||
*
|
||||
* This type exists to normalize the different ways of accessing the property.
|
||||
*/
|
||||
export type StyleSheetParent = Pick<DocumentOrShadowRoot, "adoptedStyleSheets">;
|
||||
export type StyleRoot =
|
||||
| Document
|
||||
| ShadowRoot
|
||||
| DocumentFragment
|
||||
| HTMLElement
|
||||
| DocumentOrShadowRoot;
|
||||
|
||||
/**
|
||||
* Type-predicate to determine if a given object has adoptable stylesheets.
|
||||
*/
|
||||
export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheetParent {
|
||||
export function isStyleRoot(input: StyleRoot): input is ShadowRoot {
|
||||
// Sanity check - Does the input have the right shape?
|
||||
|
||||
if (!input || typeof input !== "object") return false;
|
||||
@ -25,39 +35,12 @@ export function isAdoptableStyleSheetParent(input: unknown): input is StyleSheet
|
||||
// All we care about is that it's shaped like an array.
|
||||
if (!("length" in input.adoptedStyleSheets)) return false;
|
||||
|
||||
if (typeof input.adoptedStyleSheets.length !== "number") return false;
|
||||
|
||||
// Finally is the array mutable?
|
||||
return "push" in input.adoptedStyleSheets;
|
||||
return typeof input.adoptedStyleSheets.length === "number";
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given input can adopt stylesheets.
|
||||
*/
|
||||
export function assertAdoptableStyleSheetParent<T>(
|
||||
input: T,
|
||||
): asserts input is T & StyleSheetParent {
|
||||
if (isAdoptableStyleSheetParent(input)) return;
|
||||
|
||||
console.debug("Given input missing `adoptedStyleSheets`", input);
|
||||
|
||||
throw new TypeError("Assertion failed: `adoptedStyleSheets` missing in given input");
|
||||
}
|
||||
|
||||
export function resolveStyleSheetParent<T extends HTMLElement | DocumentFragment | Document>(
|
||||
renderRoot: T,
|
||||
) {
|
||||
const styleRoot = "ShadyDOM" in window ? document : renderRoot;
|
||||
|
||||
assertAdoptableStyleSheetParent(styleRoot);
|
||||
|
||||
return styleRoot;
|
||||
}
|
||||
|
||||
export type StyleSheetInit = string | CSSResult | CSSStyleSheet;
|
||||
|
||||
/**
|
||||
* Given a source of CSS, create a `CSSStyleSheet`.
|
||||
* Create a lazy-loaded `CSSResult` compatible with Lit's
|
||||
* element lifecycle.
|
||||
*
|
||||
* @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
|
||||
*
|
||||
@ -68,8 +51,12 @@ export type StyleSheetInit = string | CSSResult | CSSStyleSheet;
|
||||
*
|
||||
* It works well when Storybook is running in `dev`, but in `build` it fails.
|
||||
* Storied components will have to map their textual CSS imports.
|
||||
*
|
||||
* @see {@linkcode createStyleSheetUnsafe} to create a `CSSStyleSheet` from the given input.
|
||||
*/
|
||||
export function createStyleSheet(input: string): CSSResult {
|
||||
export function createCSSResult(input: string | CSSModule | CSSResultOrNative): CSSResultOrNative {
|
||||
if (typeof input !== "string") return input;
|
||||
|
||||
const inputTemplate = [input] as unknown as TemplateStringsArray;
|
||||
|
||||
const result = css(inputTemplate, []);
|
||||
@ -78,74 +65,91 @@ export function createStyleSheet(input: string): CSSResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a source of CSS, create a `CSSStyleSheet`.
|
||||
* Create a `CSSStyleSheet` from the given input, if it is not already a `CSSStyleSheet`.
|
||||
*
|
||||
* @see {@linkcode createStyleSheet}
|
||||
* @throw {@linkcode TypeError} if the input cannot be converted to a `CSSStyleSheet`
|
||||
*
|
||||
* @see {@linkcode createCSSResult} for the lazy-loaded `CSSResult` normalization.
|
||||
*/
|
||||
export function normalizeCSSSource(css: string): CSSStyleSheet;
|
||||
export function normalizeCSSSource(styleSheet: CSSStyleSheet): CSSStyleSheet;
|
||||
export function normalizeCSSSource(cssResult: CSSResult): CSSResult;
|
||||
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative;
|
||||
export function normalizeCSSSource(input: StyleSheetInit): CSSResultOrNative {
|
||||
if (typeof input === "string") return createStyleSheet(input);
|
||||
export function createStyleSheetUnsafe(
|
||||
input: string | CSSModule | CSSResultOrNative,
|
||||
): CSSStyleSheet {
|
||||
const result = typeof input === "string" ? createCSSResult(input) : input;
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a `CSSStyleSheet` from the given input.
|
||||
*/
|
||||
export function createStyleSheetUnsafe(input: StyleSheetInit): CSSStyleSheet {
|
||||
const result = normalizeCSSSource(input);
|
||||
if (result instanceof CSSStyleSheet) return result;
|
||||
|
||||
if (!result.styleSheet) {
|
||||
console.debug(
|
||||
"authentik/common/stylesheets: CSSResult missing styleSheet, returning empty",
|
||||
{ result, input },
|
||||
);
|
||||
if (result.styleSheet) return result.styleSheet;
|
||||
|
||||
throw new TypeError("Expected a CSSStyleSheet");
|
||||
}
|
||||
const styleSheet = new CSSStyleSheet();
|
||||
|
||||
return result.styleSheet;
|
||||
styleSheet.replaceSync(result.cssText);
|
||||
|
||||
return styleSheet;
|
||||
}
|
||||
|
||||
export type StyleSheetsAction =
|
||||
| Iterable<CSSStyleSheet>
|
||||
| ((currentStyleSheets: CSSStyleSheet[]) => Iterable<CSSStyleSheet>);
|
||||
|
||||
/**
|
||||
* Append stylesheet(s) to the given roots.
|
||||
* Set the adopted stylesheets of a given style parent.
|
||||
*
|
||||
* @see {@linkcode removeStyleSheet} to remove a stylesheet from a given roots.
|
||||
* ```ts
|
||||
* setAdoptedStyleSheets(document.body, (currentStyleSheets) => [
|
||||
* ...currentStyleSheets,
|
||||
* myStyleSheet,
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* Replacing `adoptedStyleSheets` more than once in the same frame may result in
|
||||
* the `currentStyleSheets` parameter being out of sync with the actual sheets.
|
||||
*
|
||||
* A style root's `adoptedStyleSheets` is a proxy object that only updates when
|
||||
* DOM is repainted. We can't easily cache the previous entries since the style root
|
||||
* may polyfilled via ShadyDOM.
|
||||
*
|
||||
* Short of using {@linkcode requestAnimationFrame} to sequence the adoption,
|
||||
* and a visibility toggle to avoid a flash of styles between renders,
|
||||
* we can't reliably cache the previous entries.
|
||||
*
|
||||
* In the meantime, we should try to apply all the sheets in a single frame.
|
||||
*/
|
||||
export function appendStyleSheet(
|
||||
styleParent: StyleSheetParent,
|
||||
...insertions: CSSStyleSheet[]
|
||||
): void {
|
||||
insertions = Array.isArray(insertions) ? insertions : [insertions];
|
||||
export function setAdoptedStyleSheets(styleRoot: StyleRoot, styleSheets: StyleSheetsAction): void {
|
||||
let changed = false;
|
||||
|
||||
for (const styleSheetInsertion of insertions) {
|
||||
if (styleParent.adoptedStyleSheets.includes(styleSheetInsertion)) return;
|
||||
const currentAdoptedStyleSheets = isStyleRoot(styleRoot)
|
||||
? [...styleRoot.adoptedStyleSheets]
|
||||
: [];
|
||||
|
||||
styleParent.adoptedStyleSheets = [...styleParent.adoptedStyleSheets, styleSheetInsertion];
|
||||
const result =
|
||||
typeof styleSheets === "function" ? styleSheets(currentAdoptedStyleSheets) : styleSheets;
|
||||
|
||||
const nextAdoptedStyleSheets: CSSStyleSheet[] = [];
|
||||
|
||||
for (const [idx, styleSheet] of Array.from(result).entries()) {
|
||||
const previousStyleSheet = currentAdoptedStyleSheets[idx];
|
||||
|
||||
changed ||= previousStyleSheet !== styleSheet;
|
||||
|
||||
if (nextAdoptedStyleSheets.includes(styleSheet)) continue;
|
||||
|
||||
nextAdoptedStyleSheets.push(styleSheet);
|
||||
}
|
||||
|
||||
changed ||= nextAdoptedStyleSheets.length !== currentAdoptedStyleSheets.length;
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
if (styleRoot === document) {
|
||||
document.adoptedStyleSheets = nextAdoptedStyleSheets;
|
||||
return;
|
||||
}
|
||||
|
||||
adoptStyleSheetsShim(styleRoot as unknown as ShadowRoot, nextAdoptedStyleSheets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a stylesheet from the given roots, matching by referential equality.
|
||||
*
|
||||
* @see {@linkcode appendStyleSheet} to append a stylesheet to a given roots.
|
||||
*/
|
||||
export function removeStyleSheet(
|
||||
styleParent: StyleSheetParent,
|
||||
...removals: CSSStyleSheet[]
|
||||
): void {
|
||||
const nextAdoptedStyleSheets = styleParent.adoptedStyleSheets.filter(
|
||||
(styleSheet) => !removals.includes(styleSheet),
|
||||
);
|
||||
|
||||
if (nextAdoptedStyleSheets.length === styleParent.adoptedStyleSheets.length) return;
|
||||
|
||||
styleParent.adoptedStyleSheets = nextAdoptedStyleSheets;
|
||||
}
|
||||
//#region Debugging
|
||||
|
||||
/**
|
||||
* Serialize a stylesheet to a string.
|
||||
@ -159,8 +163,8 @@ export function serializeStyleSheet(stylesheet: CSSStyleSheet): string {
|
||||
/**
|
||||
* Inspect the adopted stylesheets of a given style parent, serializing them to strings.
|
||||
*/
|
||||
export function inspectStyleSheets(styleParent: StyleSheetParent): string[] {
|
||||
return styleParent.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
|
||||
export function inspectStyleSheets(styleRoot: ShadowRoot): string[] {
|
||||
return styleRoot.adoptedStyleSheets.map((styleSheet) => serializeStyleSheet(styleSheet));
|
||||
}
|
||||
|
||||
interface InspectedStyleSheetEntry {
|
||||
@ -174,8 +178,11 @@ interface InspectedStyleSheetEntry {
|
||||
* Recursively inspect the adopted stylesheets of a given style parent, serializing them to strings.
|
||||
*/
|
||||
export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleSheetEntry {
|
||||
const styleParent = resolveStyleSheetParent(element.renderRoot);
|
||||
const styles = inspectStyleSheets(styleParent);
|
||||
if (!isStyleRoot(element.renderRoot)) {
|
||||
throw new TypeError("Cannot inspect a render root that doesn't have adoptable stylesheets");
|
||||
}
|
||||
|
||||
const styles = inspectStyleSheets(element.renderRoot);
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
|
||||
const treewalker = document.createTreeWalker(element.renderRoot, NodeFilter.SHOW_ELEMENT, {
|
||||
@ -186,12 +193,14 @@ export function inspectStyleSheetTree(element: ReactiveElement): InspectedStyleS
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
},
|
||||
});
|
||||
|
||||
const children: InspectedStyleSheetEntry[] = [];
|
||||
let currentNode: Node | null = treewalker.nextNode();
|
||||
|
||||
while (currentNode) {
|
||||
const childElement = currentNode as ReactiveElement;
|
||||
|
||||
if (!isAdoptableStyleSheetParent(childElement.renderRoot)) {
|
||||
if (!isStyleRoot(childElement.renderRoot)) {
|
||||
currentNode = treewalker.nextNode();
|
||||
continue;
|
||||
}
|
||||
@ -221,3 +230,5 @@ if (process.env.NODE_ENV === "development") {
|
||||
inspectStyleSheets,
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
@ -1,10 +1,47 @@
|
||||
/**
|
||||
* @file Theme utilities.
|
||||
*/
|
||||
import { UIConfig } from "@goauthentik/common/ui/config";
|
||||
import {
|
||||
type StyleRoot,
|
||||
createStyleSheetUnsafe,
|
||||
setAdoptedStyleSheets,
|
||||
} from "@goauthentik/common/stylesheets.js";
|
||||
import { UIConfig } from "@goauthentik/common/ui/config.js";
|
||||
|
||||
import AKBase from "@goauthentik/common/styles/authentik.css";
|
||||
import AKBaseDark from "@goauthentik/common/styles/theme-dark.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
//#region Stylesheet Exports
|
||||
|
||||
/**
|
||||
* A global style sheet for the Patternfly base styles.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* While a component *may* import its own instance of the PFBase style sheet,
|
||||
* this instance ensures referential identity.
|
||||
*/
|
||||
export const $PFBase = createStyleSheetUnsafe(PFBase);
|
||||
|
||||
/**
|
||||
* A global style sheet for the authentik base styles.
|
||||
*
|
||||
* @see {@linkcode $PFBase} for details.
|
||||
*/
|
||||
export const $AKBase = createStyleSheetUnsafe(AKBase);
|
||||
|
||||
/**
|
||||
* A global style sheet for the authentik dark theme.
|
||||
*
|
||||
* @see {@linkcode $PFBase} for details.
|
||||
*/
|
||||
export const $AKBaseDark = createStyleSheetUnsafe(AKBaseDark);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Scheme Types
|
||||
|
||||
/**
|
||||
@ -134,15 +171,21 @@ export function resolveUITheme(
|
||||
* Effect listener invoked when the color scheme changes.
|
||||
*/
|
||||
export type UIThemeListener = (currentUITheme: ResolvedUITheme) => void;
|
||||
|
||||
/**
|
||||
* Create an effect that runs
|
||||
* Effect destructor invoked when cleanup is required.
|
||||
*/
|
||||
export type UIThemeDestructor = () => void;
|
||||
|
||||
/**
|
||||
* Create an effect that runs UI theme changes.
|
||||
*
|
||||
* @returns A cleanup function that removes the effect.
|
||||
*/
|
||||
export function createUIThemeEffect(
|
||||
effect: UIThemeListener,
|
||||
listenerOptions?: AddEventListenerOptions,
|
||||
): () => void {
|
||||
): UIThemeDestructor {
|
||||
const colorSchemeTarget = resolveUITheme();
|
||||
const invertedColorSchemeTarget = UIThemeInversion[colorSchemeTarget];
|
||||
|
||||
@ -174,6 +217,8 @@ export function createUIThemeEffect(
|
||||
mediaQueryList.removeEventListener("change", changeListener);
|
||||
};
|
||||
|
||||
listenerOptions?.signal?.addEventListener("abort", cleanup);
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@ -181,16 +226,96 @@ export function createUIThemeEffect(
|
||||
|
||||
//#region Theme Element
|
||||
|
||||
/**
|
||||
* Applies the current UI theme to the given style root.
|
||||
*
|
||||
* @param styleRoot The style root to apply the theme to.
|
||||
* @param currentUITheme The current UI theme to apply.
|
||||
* @param additionalStyleSheets Additional style sheets to apply, in addition to the theme's base sheets.
|
||||
* @category CSS
|
||||
*
|
||||
* @see {@linkcode setAdoptedStyleSheets} for caveats.
|
||||
*/
|
||||
export function applyUITheme(
|
||||
styleRoot: StyleRoot,
|
||||
currentUITheme: ResolvedUITheme = resolveUITheme(),
|
||||
...additionalStyleSheets: Array<CSSStyleSheet | undefined | null>
|
||||
): void {
|
||||
setAdoptedStyleSheets(styleRoot, (currentStyleSheets) => {
|
||||
const appendedSheets = additionalStyleSheets.filter(Boolean) as CSSStyleSheet[];
|
||||
|
||||
if (currentUITheme === UiThemeEnum.Dark) {
|
||||
return [...currentStyleSheets, $AKBaseDark, ...appendedSheets];
|
||||
}
|
||||
|
||||
return [
|
||||
...currentStyleSheets.filter((styleSheet) => styleSheet !== $AKBaseDark),
|
||||
...appendedSheets,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the given theme to the document, i.e. the `<html>` element.
|
||||
*
|
||||
* @param hint The color scheme hint to use.
|
||||
*/
|
||||
export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "auto"): void {
|
||||
const preferredColorScheme = formatColorScheme(hint);
|
||||
|
||||
const applyStyleSheets: UIThemeListener = (currentUITheme) => {
|
||||
console.debug(`authentik/theme (document): switching to ${currentUITheme} theme`);
|
||||
|
||||
setAdoptedStyleSheets(document, (currentStyleSheets) => {
|
||||
if (currentUITheme === "dark") {
|
||||
return [...currentStyleSheets, $PFBase, $AKBase, $AKBaseDark];
|
||||
}
|
||||
|
||||
return [
|
||||
...currentStyleSheets.filter((styleSheet) => styleSheet !== $AKBaseDark),
|
||||
$PFBase,
|
||||
$AKBase,
|
||||
];
|
||||
});
|
||||
|
||||
document.documentElement.dataset.theme = currentUITheme;
|
||||
};
|
||||
|
||||
if (preferredColorScheme === "auto") {
|
||||
createUIThemeEffect(applyStyleSheets);
|
||||
return;
|
||||
}
|
||||
|
||||
applyStyleSheets(preferredColorScheme);
|
||||
}
|
||||
|
||||
/**
|
||||
* An element that can be themed.
|
||||
*/
|
||||
export interface ThemedElement extends HTMLElement {
|
||||
brand?: CurrentBrand;
|
||||
uiConfig?: UIConfig;
|
||||
config?: Config;
|
||||
/**
|
||||
* The brand information for the current theme.
|
||||
*/
|
||||
readonly brand?: CurrentBrand;
|
||||
/**
|
||||
* The UI configuration for the current theme,
|
||||
* typically injected through a Lit Mixin.
|
||||
*
|
||||
* @see {@linkcode UIConfig} for details.
|
||||
*/
|
||||
readonly uiConfig?: UIConfig;
|
||||
/**
|
||||
* An authentik configuration initially provided by the server.
|
||||
*/
|
||||
readonly config?: Config;
|
||||
activeTheme: ResolvedUITheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the root interface element of the page.
|
||||
*
|
||||
* @todo Can this be handled with a Lit Mixin?
|
||||
*/
|
||||
export function rootInterface<T extends ThemedElement = ThemedElement>(): T | null {
|
||||
const element = document.body.querySelector<T>("[data-ak-interface-root]");
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { isUserRoute } from "@goauthentik/elements/router/utils";
|
||||
import { me } from "@goauthentik/common/users.js";
|
||||
import { isUserRoute } from "@goauthentik/elements/router/utils.js";
|
||||
|
||||
import { UiThemeEnum, UserSelf } from "@goauthentik/api";
|
||||
import { CurrentBrand } from "@goauthentik/api";
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
|
||||
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config.js";
|
||||
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants.js";
|
||||
import { isResponseErrorLike } from "@goauthentik/common/errors/network.js";
|
||||
|
||||
import { CoreApi, SessionUser } from "@goauthentik/api";
|
||||
|
||||
|
@ -31,18 +31,19 @@ const container = (testItem: TemplateResult) =>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
color: black;
|
||||
margin-top: 1em;
|
||||
|
||||
ak-hint {
|
||||
--ak-hint--Color: var(--pf-global--Color--dark-100);
|
||||
}
|
||||
|
||||
* {
|
||||
--ak-hint--Color: black !important;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
ak-hint {
|
||||
--ak-hint--Color: var(--pf-global--Color--light-100);
|
||||
}
|
||||
}
|
||||
ak-hint-title::part(ak-hint-title),
|
||||
ak-hint-footer::part(ak-hint-footer),
|
||||
slotted::(*) {
|
||||
color: black;
|
||||
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
@ -64,7 +64,15 @@ export class Alert extends AKElement implements IAlert {
|
||||
icon = "fa-exclamation-circle";
|
||||
|
||||
static get styles() {
|
||||
return [PFBase, PFAlert];
|
||||
return [
|
||||
PFBase,
|
||||
PFAlert,
|
||||
css`
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
get classmap() {
|
||||
|
@ -1,30 +1,24 @@
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import { globalAK } from "@goauthentik/common/global.js";
|
||||
import {
|
||||
StyleSheetInit,
|
||||
StyleSheetParent,
|
||||
appendStyleSheet,
|
||||
StyleRoot,
|
||||
createCSSResult,
|
||||
createStyleSheetUnsafe,
|
||||
removeStyleSheet,
|
||||
resolveStyleSheetParent,
|
||||
} from "@goauthentik/common/stylesheets";
|
||||
} from "@goauthentik/common/stylesheets.js";
|
||||
import {
|
||||
$AKBase,
|
||||
CSSColorSchemeValue,
|
||||
ResolvedUITheme,
|
||||
UIThemeListener,
|
||||
ThemedElement,
|
||||
applyUITheme,
|
||||
createUIThemeEffect,
|
||||
formatColorScheme,
|
||||
resolveUITheme,
|
||||
} from "@goauthentik/common/theme";
|
||||
import { type ThemedElement } from "@goauthentik/common/theme";
|
||||
} from "@goauthentik/common/theme.js";
|
||||
|
||||
import { localized } from "@lit/localize";
|
||||
import { CSSResultGroup, CSSResultOrNative, LitElement } from "lit";
|
||||
import { CSSResult, CSSResultGroup, CSSResultOrNative, LitElement } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
|
||||
import AKGlobal from "@goauthentik/common/styles/authentik.css";
|
||||
import OneDark from "@goauthentik/common/styles/one-dark.css";
|
||||
import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
|
||||
|
||||
import { UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
// Re-export the theme helpers
|
||||
@ -32,6 +26,58 @@ export { rootInterface } from "@goauthentik/common/theme";
|
||||
|
||||
@localized()
|
||||
export class AKElement extends LitElement implements ThemedElement {
|
||||
//#region Static Properties
|
||||
|
||||
public static styles?: Array<CSSResult | CSSModule>;
|
||||
|
||||
protected static override finalizeStyles(styles?: CSSResultGroup): CSSResultOrNative[] {
|
||||
if (!styles) return [$AKBase];
|
||||
|
||||
if (!Array.isArray(styles)) return [createCSSResult(styles), $AKBase];
|
||||
|
||||
return [
|
||||
// ---
|
||||
...(styles.flat() as CSSResultOrNative[]).map(createCSSResult),
|
||||
$AKBase,
|
||||
];
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const { brand } = globalAK();
|
||||
|
||||
this.preferredColorScheme = formatColorScheme(brand.uiTheme);
|
||||
this.activeTheme = resolveUITheme(brand?.uiTheme);
|
||||
|
||||
this.#customCSSStyleSheet = brand?.brandingCustomCss
|
||||
? createStyleSheetUnsafe(brand.brandingCustomCss)
|
||||
: null;
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
this.#themeAbortController?.abort();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the node into which the element should render.
|
||||
*
|
||||
* @see {LitElement.createRenderRoot} for more information.
|
||||
*/
|
||||
protected override createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
const renderRoot = super.createRenderRoot();
|
||||
this.styleRoot ??= renderRoot;
|
||||
|
||||
return renderRoot;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
@ -53,87 +99,54 @@ export class AKElement extends LitElement implements ThemedElement {
|
||||
|
||||
//#region Private Properties
|
||||
|
||||
readonly #preferredColorScheme: CSSColorSchemeValue;
|
||||
/**
|
||||
* The preferred color scheme used to look up the UI theme.
|
||||
*/
|
||||
protected readonly preferredColorScheme: CSSColorSchemeValue;
|
||||
|
||||
#customCSSStyleSheet: CSSStyleSheet | null;
|
||||
#darkThemeStyleSheet: CSSStyleSheet | null = null;
|
||||
/**
|
||||
* A custom CSS style sheet to apply to the element.
|
||||
*/
|
||||
readonly #customCSSStyleSheet: CSSStyleSheet | null;
|
||||
|
||||
/**
|
||||
* A controller to abort theme updates, such as when the element is disconnected.
|
||||
*/
|
||||
#themeAbortController: AbortController | null = null;
|
||||
/**
|
||||
* The style root to which the theme is applied.
|
||||
*/
|
||||
#styleRoot?: StyleRoot;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
protected static finalizeStyles(styles?: CSSResultGroup): CSSResultOrNative[] {
|
||||
// Ensure all style sheets being passed are really style sheets.
|
||||
const baseStyles: StyleSheetInit[] = [AKGlobal, OneDark];
|
||||
|
||||
if (!styles) return baseStyles.map(createStyleSheetUnsafe);
|
||||
|
||||
if (Array.isArray(styles)) {
|
||||
return [
|
||||
//---
|
||||
...(styles as unknown as CSSResultOrNative[]),
|
||||
...baseStyles,
|
||||
].flatMap(createStyleSheetUnsafe);
|
||||
}
|
||||
return [styles, ...baseStyles].map(createStyleSheetUnsafe);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const { brand } = globalAK();
|
||||
|
||||
this.#preferredColorScheme = formatColorScheme(brand.uiTheme);
|
||||
this.activeTheme = resolveUITheme(brand?.uiTheme);
|
||||
|
||||
this.#customCSSStyleSheet = brand?.brandingCustomCss
|
||||
? createStyleSheetUnsafe(brand.brandingCustomCss)
|
||||
: null;
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
protected set styleRoot(nextStyleRoot: StyleRoot | undefined) {
|
||||
this.#themeAbortController?.abort();
|
||||
}
|
||||
|
||||
#styleRoot?: StyleSheetParent;
|
||||
this.#styleRoot = nextStyleRoot;
|
||||
|
||||
#dispatchTheme: UIThemeListener = (nextUITheme) => {
|
||||
if (!this.#styleRoot) return;
|
||||
|
||||
if (nextUITheme === UiThemeEnum.Dark) {
|
||||
this.#darkThemeStyleSheet ||= createStyleSheetUnsafe(ThemeDark);
|
||||
appendStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet);
|
||||
this.activeTheme = UiThemeEnum.Dark;
|
||||
} else if (this.#darkThemeStyleSheet) {
|
||||
removeStyleSheet(this.#styleRoot, this.#darkThemeStyleSheet);
|
||||
this.#darkThemeStyleSheet = null;
|
||||
this.activeTheme = UiThemeEnum.Light;
|
||||
}
|
||||
};
|
||||
|
||||
protected createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
const renderRoot = super.createRenderRoot();
|
||||
this.#styleRoot = resolveStyleSheetParent(renderRoot);
|
||||
|
||||
if (this.#customCSSStyleSheet) {
|
||||
console.debug(`authentik/element[${this.tagName.toLowerCase()}]: Adding custom CSS`);
|
||||
|
||||
appendStyleSheet(this.#styleRoot, this.#customCSSStyleSheet);
|
||||
}
|
||||
if (!nextStyleRoot) return;
|
||||
|
||||
this.#themeAbortController = new AbortController();
|
||||
|
||||
if (this.#preferredColorScheme === "dark") {
|
||||
this.#dispatchTheme(UiThemeEnum.Dark);
|
||||
} else if (this.#preferredColorScheme === "auto") {
|
||||
createUIThemeEffect(this.#dispatchTheme, {
|
||||
signal: this.#themeAbortController.signal,
|
||||
});
|
||||
}
|
||||
if (this.preferredColorScheme === "dark") {
|
||||
applyUITheme(nextStyleRoot, UiThemeEnum.Dark, this.#customCSSStyleSheet);
|
||||
|
||||
return renderRoot;
|
||||
this.activeTheme = UiThemeEnum.Dark;
|
||||
} else if (this.preferredColorScheme === "auto") {
|
||||
createUIThemeEffect(
|
||||
(nextUITheme) => {
|
||||
applyUITheme(nextStyleRoot, nextUITheme, this.#customCSSStyleSheet);
|
||||
|
||||
this.activeTheme = nextUITheme;
|
||||
},
|
||||
{
|
||||
signal: this.#themeAbortController.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected get styleRoot(): StyleRoot | undefined {
|
||||
return this.#styleRoot;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
@ -1,34 +1,45 @@
|
||||
import {
|
||||
appendStyleSheet,
|
||||
createStyleSheetUnsafe,
|
||||
resolveStyleSheetParent,
|
||||
} from "@goauthentik/common/stylesheets";
|
||||
import { ThemedElement } from "@goauthentik/common/theme";
|
||||
import { UIConfig } from "@goauthentik/common/ui/config";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController";
|
||||
import { globalAK } from "@goauthentik/common/global.js";
|
||||
import { ThemedElement, applyDocumentTheme } from "@goauthentik/common/theme.js";
|
||||
import { UIConfig } from "@goauthentik/common/ui/config.js";
|
||||
import { AKElement } from "@goauthentik/elements/Base.js";
|
||||
import { VersionContextController } from "@goauthentik/elements/Interface/VersionContextController.js";
|
||||
import { ModalOrchestrationController } from "@goauthentik/elements/controllers/ModalOrchestrationController.js";
|
||||
|
||||
import { state } from "lit/decorators.js";
|
||||
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import type { Config, CurrentBrand, LicenseSummary, Version } from "@goauthentik/api";
|
||||
import {
|
||||
type Config,
|
||||
type CurrentBrand,
|
||||
type LicenseSummary,
|
||||
type Version,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { BrandContextController } from "./BrandContextController";
|
||||
import { ConfigContextController } from "./ConfigContextController";
|
||||
import { EnterpriseContextController } from "./EnterpriseContextController";
|
||||
import { BrandContextController } from "./BrandContextController.js";
|
||||
import { ConfigContextController } from "./ConfigContextController.js";
|
||||
import { EnterpriseContextController } from "./EnterpriseContextController.js";
|
||||
|
||||
const configContext = Symbol("configContext");
|
||||
const modalController = Symbol("modalController");
|
||||
const versionContext = Symbol("versionContext");
|
||||
|
||||
export abstract class Interface extends AKElement implements ThemedElement {
|
||||
protected static readonly PFBaseStyleSheet = createStyleSheetUnsafe(PFBase);
|
||||
export abstract class LightInterface extends AKElement implements ThemedElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
|
||||
|
||||
[configContext]: ConfigContextController;
|
||||
if (!document.documentElement.dataset.theme) {
|
||||
applyDocumentTheme(globalAK().brand.uiTheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[modalController]: ModalOrchestrationController;
|
||||
export abstract class Interface extends LightInterface implements ThemedElement {
|
||||
static styles = [PFBase];
|
||||
protected [configContext]: ConfigContextController;
|
||||
|
||||
protected [modalController]: ModalOrchestrationController;
|
||||
|
||||
@state()
|
||||
public config?: Config;
|
||||
@ -38,11 +49,6 @@ export abstract class Interface extends AKElement implements ThemedElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const styleParent = resolveStyleSheetParent(document);
|
||||
|
||||
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
|
||||
|
||||
appendStyleSheet(styleParent, Interface.PFBaseStyleSheet);
|
||||
|
||||
this.addController(new BrandContextController(this));
|
||||
this[configContext] = new ConfigContextController(this);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AuthenticatedInterface, Interface } from "./Interface";
|
||||
import { AuthenticatedInterface, Interface, LightInterface } from "./Interface";
|
||||
|
||||
export { Interface, AuthenticatedInterface };
|
||||
export { Interface, AuthenticatedInterface, LightInterface };
|
||||
export default Interface;
|
||||
|
@ -1,8 +1,4 @@
|
||||
import {
|
||||
EVENT_SIDEBAR_TOGGLE,
|
||||
EVENT_WS_MESSAGE,
|
||||
TITLE_DEFAULT,
|
||||
} from "@goauthentik/common/constants";
|
||||
import { EVENT_WS_MESSAGE, TITLE_DEFAULT } from "@goauthentik/common/constants";
|
||||
import { globalAK } from "@goauthentik/common/global";
|
||||
import { UIConfig, UserDisplay, getConfigForUser } from "@goauthentik/common/ui/config";
|
||||
import { DefaultBrand } from "@goauthentik/common/ui/config";
|
||||
@ -29,6 +25,14 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { SessionUser } from "@goauthentik/api";
|
||||
|
||||
//#region Events
|
||||
|
||||
export interface SidebarToggleEventDetail {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Page Navbar
|
||||
|
||||
export interface PageNavbarDetails {
|
||||
@ -45,7 +49,10 @@ export interface PageNavbarDetails {
|
||||
* dispatched by the `ak-page-header` component.
|
||||
*/
|
||||
@customElement("ak-page-navbar")
|
||||
export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails {
|
||||
export class AKPageNavbar
|
||||
extends WithBrandConfig(AKElement)
|
||||
implements PageNavbarDetails, SidebarToggleEventDetail
|
||||
{
|
||||
//#region Static Properties
|
||||
|
||||
private static elementRef: AKPageNavbar | null = null;
|
||||
@ -260,29 +267,31 @@ export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavb
|
||||
|
||||
//#region Properties
|
||||
|
||||
@property({ type: String })
|
||||
@state()
|
||||
icon?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
@state()
|
||||
iconImage = false;
|
||||
|
||||
@property({ type: String })
|
||||
@state()
|
||||
header?: string;
|
||||
|
||||
@property({ type: String })
|
||||
@state()
|
||||
description?: string;
|
||||
|
||||
@property({ type: Boolean })
|
||||
@state()
|
||||
hasIcon = true;
|
||||
|
||||
@property({ type: Boolean })
|
||||
open = true;
|
||||
@property({
|
||||
type: Boolean,
|
||||
})
|
||||
public open?: boolean;
|
||||
|
||||
@state()
|
||||
session?: SessionUser;
|
||||
protected session?: SessionUser;
|
||||
|
||||
@state()
|
||||
uiConfig!: UIConfig;
|
||||
protected uiConfig!: UIConfig;
|
||||
|
||||
//#endregion
|
||||
|
||||
@ -305,9 +314,10 @@ export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavb
|
||||
this.open = !this.open;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
|
||||
new CustomEvent<SidebarToggleEventDetail>("sidebar-toggle", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { open: this.open },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -12,7 +12,6 @@ import type { DualSelectPair } from "./types.js";
|
||||
* A top-level component for multi-select elements have dynamically generated "selected"
|
||||
* lists.
|
||||
*/
|
||||
|
||||
@customElement("ak-dual-select-dynamic-selected")
|
||||
export class AkDualSelectDynamic extends AkDualSelectProvider {
|
||||
/**
|
||||
@ -23,20 +22,24 @@ export class AkDualSelectDynamic extends AkDualSelectProvider {
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
selector: (_: DualSelectPair[]) => Promise<DualSelectPair[]> = async (_) => Promise.resolve([]);
|
||||
selector: (_: DualSelectPair[]) => Promise<DualSelectPair[]> = () => Promise.resolve([]);
|
||||
|
||||
private firstUpdateHasRun = false;
|
||||
#didFirstUpdate = false;
|
||||
|
||||
willUpdate(changed: PropertyValues<this>) {
|
||||
super.willUpdate(changed);
|
||||
|
||||
// On the first update *only*, even before rendering, when the options are handed up, update
|
||||
// the selected list with the contents derived from the selector.
|
||||
if (!this.firstUpdateHasRun && this.options.length > 0) {
|
||||
this.firstUpdateHasRun = true;
|
||||
this.selector(this.options).then((selected) => {
|
||||
this.selected = selected;
|
||||
});
|
||||
}
|
||||
|
||||
if (this.#didFirstUpdate) return;
|
||||
if (this.options.length === 0) return;
|
||||
|
||||
this.#didFirstUpdate = true;
|
||||
|
||||
this.selector(this.options).then((selected) => {
|
||||
this.selected = selected;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -1,18 +1,16 @@
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { debounce } from "@goauthentik/elements/utils/debounce";
|
||||
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { PropertyValues, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import type { Ref } from "lit/directives/ref.js";
|
||||
|
||||
import type { Pagination } from "@goauthentik/api";
|
||||
|
||||
import "./ak-dual-select";
|
||||
import { AkDualSelect } from "./ak-dual-select";
|
||||
import type { DataProvider, DualSelectPair } from "./types";
|
||||
import "./ak-dual-select.js";
|
||||
import { AkDualSelect } from "./ak-dual-select.js";
|
||||
import { type DataProvider, DualSelectEventType, type DualSelectPair } from "./types.js";
|
||||
|
||||
/**
|
||||
* @element ak-dual-select-provider
|
||||
@ -22,18 +20,19 @@ import type { DataProvider, DualSelectPair } from "./types";
|
||||
* between authentik and the generic ak-dual-select component; aside from knowing that
|
||||
* the Pagination object "looks like Django," the interior components don't know anything
|
||||
* about authentik at all and could be dropped into Gravity unchanged.)
|
||||
*
|
||||
*/
|
||||
|
||||
@customElement("ak-dual-select-provider")
|
||||
export class AkDualSelectProvider extends CustomListenerElement(AkControlElement) {
|
||||
/** A function that takes a page and returns the DualSelectPair[] collection with which to update
|
||||
* the "Available" pane.
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* A function that takes a page and returns the {@linkcode DualSelectPair DualSelectPair[]}
|
||||
* collection with which to update the "Available" pane.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Object })
|
||||
provider!: DataProvider;
|
||||
public provider!: DataProvider;
|
||||
|
||||
/**
|
||||
* The list of selected items. This is the *complete* list, not paginated, as presented by a
|
||||
@ -42,7 +41,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Array })
|
||||
selected: DualSelectPair[] = [];
|
||||
public selected: DualSelectPair[] = [];
|
||||
|
||||
/**
|
||||
* The label for the left ("available") pane
|
||||
@ -50,7 +49,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: "available-label" })
|
||||
availableLabel = msg("Available options");
|
||||
public availableLabel = msg("Available options");
|
||||
|
||||
/**
|
||||
* The label for the right ("selected") pane
|
||||
@ -58,7 +57,7 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: "selected-label" })
|
||||
selectedLabel = msg("Selected options");
|
||||
public selectedLabel = msg("Selected options");
|
||||
|
||||
/**
|
||||
* The debounce for the search as the user is typing in a request
|
||||
@ -66,103 +65,125 @@ export class AkDualSelectProvider extends CustomListenerElement(AkControlElement
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: "search-delay", type: Number })
|
||||
searchDelay = 250;
|
||||
public searchDelay = 250;
|
||||
|
||||
public get value() {
|
||||
return this.dualSelector.value!.selected.map(([k, _]) => k);
|
||||
}
|
||||
|
||||
public json() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region State
|
||||
|
||||
@state()
|
||||
options: DualSelectPair[] = [];
|
||||
protected options: DualSelectPair[] = [];
|
||||
|
||||
protected dualSelector: Ref<AkDualSelect> = createRef();
|
||||
#loading = false;
|
||||
|
||||
protected isLoading = false;
|
||||
#didFirstUpdate = false;
|
||||
#selected: DualSelectPair[] = [];
|
||||
|
||||
private doneFirstUpdate = false;
|
||||
private internalSelected: DualSelectPair[] = [];
|
||||
#previousSearchValue = "";
|
||||
|
||||
protected pagination?: Pagination;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
setTimeout(() => this.fetch(1), 0);
|
||||
this.onNav = this.onNav.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.onSearch = this.onSearch.bind(this);
|
||||
this.addCustomListener("ak-pagination-nav-to", this.onNav);
|
||||
this.addCustomListener("ak-dual-select-change", this.onChange);
|
||||
this.addCustomListener("ak-dual-select-search", this.onSearch);
|
||||
//#endregion
|
||||
|
||||
//#region Refs
|
||||
|
||||
protected dualSelector = createRef<AkDualSelect>();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addCustomListener(DualSelectEventType.NavigateTo, this.#navigationListener);
|
||||
this.addCustomListener(DualSelectEventType.Change, this.#changeListener);
|
||||
this.addCustomListener(DualSelectEventType.Search, this.#searchListener);
|
||||
|
||||
this.#fetch(1);
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("selected") && !this.doneFirstUpdate) {
|
||||
this.doneFirstUpdate = true;
|
||||
this.internalSelected = this.selected;
|
||||
}
|
||||
|
||||
if (changedProperties.has("searchDelay")) {
|
||||
this.doSearch = debounce(
|
||||
AkDualSelectProvider.prototype.doSearch.bind(this),
|
||||
this.searchDelay,
|
||||
);
|
||||
if (changedProperties.has("selected") && !this.#didFirstUpdate) {
|
||||
this.#didFirstUpdate = true;
|
||||
this.#selected = this.selected;
|
||||
}
|
||||
|
||||
if (changedProperties.has("provider")) {
|
||||
this.pagination = undefined;
|
||||
this.fetch();
|
||||
this.#previousSearchValue = "";
|
||||
this.#fetch();
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(page?: number, search = "") {
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
this.isLoading = true;
|
||||
const goto = page ?? this.pagination?.current ?? 1;
|
||||
const data = await this.provider(goto, search);
|
||||
this.pagination = data.pagination;
|
||||
this.options = data.options;
|
||||
this.isLoading = false;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
onNav(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expecting a CustomEvent for navigation, received ${event} instead`);
|
||||
}
|
||||
this.fetch(event.detail);
|
||||
}
|
||||
//#region Private Methods
|
||||
|
||||
onChange(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
|
||||
}
|
||||
this.internalSelected = event.detail.value;
|
||||
this.selected = this.internalSelected;
|
||||
}
|
||||
#fetch = async (page?: number, search = this.#previousSearchValue): Promise<void> => {
|
||||
if (this.#loading) return;
|
||||
|
||||
onSearch(event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
|
||||
}
|
||||
this.doSearch(event.detail);
|
||||
}
|
||||
this.#previousSearchValue = search;
|
||||
this.#loading = true;
|
||||
|
||||
doSearch(search: string) {
|
||||
this.pagination = undefined;
|
||||
this.fetch(undefined, search);
|
||||
}
|
||||
page ??= this.pagination?.current ?? 1;
|
||||
|
||||
get value() {
|
||||
return this.dualSelector.value!.selected.map(([k, _]) => k);
|
||||
}
|
||||
return this.provider(page, search)
|
||||
.then((data) => {
|
||||
this.pagination = data.pagination;
|
||||
this.options = data.options;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.#loading = false;
|
||||
});
|
||||
};
|
||||
|
||||
json() {
|
||||
return this.value;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
#navigationListener = (event: CustomEvent<number>) => {
|
||||
this.#fetch(event.detail, this.#previousSearchValue);
|
||||
};
|
||||
|
||||
#changeListener = (event: CustomEvent<{ value: DualSelectPair[] }>) => {
|
||||
this.#selected = event.detail.value;
|
||||
this.selected = this.#selected;
|
||||
};
|
||||
|
||||
#searchListener = (event: CustomEvent<string>) => {
|
||||
this.#doSearch(event.detail);
|
||||
};
|
||||
|
||||
#searchTimeoutID?: ReturnType<typeof setTimeout>;
|
||||
|
||||
#doSearch = (search: string) => {
|
||||
clearTimeout(this.#searchTimeoutID);
|
||||
|
||||
setTimeout(() => {
|
||||
this.pagination = undefined;
|
||||
this.#fetch(undefined, search);
|
||||
}, this.searchDelay);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
render() {
|
||||
return html`<ak-dual-select
|
||||
${ref(this.dualSelector)}
|
||||
.options=${this.options}
|
||||
.pages=${this.pagination}
|
||||
.selected=${this.internalSelected}
|
||||
.selected=${this.#selected}
|
||||
available-label=${this.availableLabel}
|
||||
selected-label=${this.selectedLabel}
|
||||
></ak-dual-select>`;
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
CustomEmitterElement,
|
||||
CustomListenerElement,
|
||||
} from "@goauthentik/elements/utils/eventEmitter";
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { PropertyValues, html, nothing } from "lit";
|
||||
@ -15,34 +16,41 @@ import { globalVariables, mainStyles } from "./components/styles.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import "./components/ak-dual-select-available-pane";
|
||||
import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane";
|
||||
import "./components/ak-dual-select-controls";
|
||||
import "./components/ak-dual-select-selected-pane";
|
||||
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane";
|
||||
import "./components/ak-pagination";
|
||||
import "./components/ak-search-bar";
|
||||
import "./components/ak-dual-select-available-pane.js";
|
||||
import { AkDualSelectAvailablePane } from "./components/ak-dual-select-available-pane.js";
|
||||
import "./components/ak-dual-select-controls.js";
|
||||
import "./components/ak-dual-select-selected-pane.js";
|
||||
import { AkDualSelectSelectedPane } from "./components/ak-dual-select-selected-pane.js";
|
||||
import "./components/ak-pagination.js";
|
||||
import "./components/ak-search-bar.js";
|
||||
import {
|
||||
EVENT_ADD_ALL,
|
||||
EVENT_ADD_ONE,
|
||||
EVENT_ADD_SELECTED,
|
||||
EVENT_DELETE_ALL,
|
||||
EVENT_REMOVE_ALL,
|
||||
EVENT_REMOVE_ONE,
|
||||
EVENT_REMOVE_SELECTED,
|
||||
} from "./constants";
|
||||
import type { BasePagination, DualSelectPair, SearchbarEvent } from "./types";
|
||||
BasePagination,
|
||||
DualSelectEventType,
|
||||
DualSelectPair,
|
||||
SearchbarEventDetail,
|
||||
SearchbarEventSource,
|
||||
} from "./types.js";
|
||||
|
||||
function alphaSort([_k1, v1, s1]: DualSelectPair, [_k2, v2, s2]: DualSelectPair) {
|
||||
const [l, r] = [s1 !== undefined ? s1 : v1, s2 !== undefined ? s2 : v2];
|
||||
return l < r ? -1 : l > r ? 1 : 0;
|
||||
function localeComparator(a: DualSelectPair, b: DualSelectPair) {
|
||||
const aSortBy = a[2];
|
||||
const bSortBy = b[2];
|
||||
|
||||
return aSortBy.localeCompare(bSortBy);
|
||||
}
|
||||
|
||||
function mapDualPairs(pairs: DualSelectPair[]) {
|
||||
return new Map(pairs.map(([k, v, _]) => [k, v]));
|
||||
function keyfinder(key: string) {
|
||||
return ([k]: DualSelectPair) => k === key;
|
||||
}
|
||||
|
||||
const styles = [PFBase, PFButton, globalVariables, mainStyles];
|
||||
const DelegatedEvents = [
|
||||
DualSelectEventType.AddSelected,
|
||||
DualSelectEventType.RemoveSelected,
|
||||
DualSelectEventType.AddAll,
|
||||
DualSelectEventType.RemoveAll,
|
||||
DualSelectEventType.DeleteAll,
|
||||
DualSelectEventType.AddOne,
|
||||
DualSelectEventType.RemoveOne,
|
||||
] as const satisfies DualSelectEventType[];
|
||||
|
||||
/**
|
||||
* @element ak-dual-select
|
||||
@ -53,24 +61,25 @@ const styles = [PFBase, PFButton, globalVariables, mainStyles];
|
||||
*
|
||||
* @fires ak-dual-select-change - A custom change event with the current `selected` list.
|
||||
*/
|
||||
|
||||
const keyfinder =
|
||||
(key: string) =>
|
||||
([k]: DualSelectPair) =>
|
||||
k === key;
|
||||
|
||||
@customElement("ak-dual-select")
|
||||
export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKElement)) {
|
||||
static get styles() {
|
||||
return styles;
|
||||
}
|
||||
static styles = [PFBase, PFButton, globalVariables, mainStyles];
|
||||
|
||||
/* The list of options to *currently* show. Note that this is not *all* the options, only the
|
||||
* currently shown list of options from a pagination collection. */
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* The list of options to *currently* show.
|
||||
*
|
||||
* Note that this is not *all* the options,
|
||||
* only the currently shown list of options from a pagination collection.
|
||||
*/
|
||||
@property({ type: Array })
|
||||
options: DualSelectPair[] = [];
|
||||
|
||||
/* The list of options selected. This is the *entire* list and will not be paginated. */
|
||||
/**
|
||||
* The list of options selected.
|
||||
* This is the *entire* list and will not be paginated.
|
||||
*/
|
||||
@property({ type: Array })
|
||||
selected: DualSelectPair[] = [];
|
||||
|
||||
@ -83,138 +92,133 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
@property({ attribute: "selected-label" })
|
||||
selectedLabel = msg("Selected options");
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region State
|
||||
|
||||
@state()
|
||||
selectedFilter: string = "";
|
||||
protected selectedFilter: string = "";
|
||||
|
||||
#selectedKeys: Set<string> = new Set();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Refs
|
||||
|
||||
availablePane: Ref<AkDualSelectAvailablePane> = createRef();
|
||||
|
||||
selectedPane: Ref<AkDualSelectSelectedPane> = createRef();
|
||||
|
||||
selectedKeys: Set<string> = new Set();
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.handleMove = this.handleMove.bind(this);
|
||||
this.handleSearch = this.handleSearch.bind(this);
|
||||
[
|
||||
EVENT_ADD_ALL,
|
||||
EVENT_ADD_SELECTED,
|
||||
EVENT_DELETE_ALL,
|
||||
EVENT_REMOVE_ALL,
|
||||
EVENT_REMOVE_SELECTED,
|
||||
EVENT_ADD_ONE,
|
||||
EVENT_REMOVE_ONE,
|
||||
].forEach((eventName: string) => {
|
||||
this.addCustomListener(eventName, (event: Event) => this.handleMove(eventName, event));
|
||||
});
|
||||
|
||||
for (const eventName of DelegatedEvents) {
|
||||
this.addCustomListener(eventName, this.#moveListener);
|
||||
}
|
||||
|
||||
this.addCustomListener("ak-dual-select-move", () => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
this.addCustomListener("ak-search", this.handleSearch);
|
||||
|
||||
this.addCustomListener("ak-search", this.#searchListener);
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("selected")) {
|
||||
this.selectedKeys = new Set(this.selected.map(([key, _]) => key));
|
||||
this.#selectedKeys = new Set(this.selected.map(([key]) => key));
|
||||
}
|
||||
|
||||
// Pagination invalidates available moveables.
|
||||
if (changedProperties.has("options") && this.availablePane.value) {
|
||||
this.availablePane.value.clearMove();
|
||||
}
|
||||
}
|
||||
|
||||
handleMove(eventName: string, event: Event) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expected move event here, got ${eventName}`);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
switch (eventName) {
|
||||
case EVENT_ADD_SELECTED: {
|
||||
this.addSelected();
|
||||
break;
|
||||
}
|
||||
case EVENT_REMOVE_SELECTED: {
|
||||
this.removeSelected();
|
||||
break;
|
||||
}
|
||||
case EVENT_ADD_ALL: {
|
||||
this.addAllVisible();
|
||||
break;
|
||||
}
|
||||
case EVENT_REMOVE_ALL: {
|
||||
this.removeAllVisible();
|
||||
break;
|
||||
}
|
||||
case EVENT_DELETE_ALL: {
|
||||
this.removeAll();
|
||||
break;
|
||||
}
|
||||
case EVENT_ADD_ONE: {
|
||||
this.addOne(event.detail);
|
||||
break;
|
||||
}
|
||||
case EVENT_REMOVE_ONE: {
|
||||
this.removeOne(event.detail);
|
||||
break;
|
||||
}
|
||||
//#region Event Listeners
|
||||
|
||||
#moveListener = (event: CustomEvent<string>) => {
|
||||
match(event.type)
|
||||
.with(DualSelectEventType.AddSelected, () => this.addSelected())
|
||||
.with(DualSelectEventType.RemoveSelected, () => this.removeSelected())
|
||||
.with(DualSelectEventType.AddAll, () => this.addAllVisible())
|
||||
.with(DualSelectEventType.RemoveAll, () => this.removeAllVisible())
|
||||
.with(DualSelectEventType.DeleteAll, () => this.removeAll())
|
||||
.with(DualSelectEventType.AddOne, () => this.addOne(event.detail))
|
||||
.with(DualSelectEventType.RemoveOne, () => this.removeOne(event.detail))
|
||||
.otherwise(() => {
|
||||
throw new Error(`Expected move event here, got ${event.type}`);
|
||||
});
|
||||
|
||||
this.dispatchCustomEvent(DualSelectEventType.Change, { value: this.value });
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`AkDualSelect.handleMove received unknown event type: ${eventName}`,
|
||||
);
|
||||
}
|
||||
this.dispatchCustomEvent("ak-dual-select-change", { value: this.value });
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected addSelected() {
|
||||
if (this.availablePane.value!.moveable.length === 0) return;
|
||||
|
||||
addSelected() {
|
||||
if (this.availablePane.value!.moveable.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.selected = this.availablePane.value!.moveable.reduce(
|
||||
(acc, key) => {
|
||||
const value = this.options.find(keyfinder(key));
|
||||
|
||||
return value && !acc.find(keyfinder(value[0])) ? [...acc, value] : acc;
|
||||
},
|
||||
[...this.selected],
|
||||
);
|
||||
|
||||
// This is where the information gets... lossy. Dammit.
|
||||
this.availablePane.value!.clearMove();
|
||||
}
|
||||
|
||||
addOne(key: string) {
|
||||
protected addOne(key: string) {
|
||||
const requested = this.options.find(keyfinder(key));
|
||||
if (requested && !this.selected.find(keyfinder(requested[0]))) {
|
||||
this.selected = [...this.selected, requested];
|
||||
}
|
||||
|
||||
if (!requested) return;
|
||||
if (this.selected.find(keyfinder(requested[0]))) return;
|
||||
|
||||
this.selected = [...this.selected, requested];
|
||||
}
|
||||
|
||||
// These are the *currently visible* options; the parent node is responsible for paginating and
|
||||
// updating the list of currently visible options;
|
||||
addAllVisible() {
|
||||
protected addAllVisible() {
|
||||
// Create a new array of all current options and selected, and de-dupe.
|
||||
const selected = mapDualPairs([...this.options, ...this.selected]);
|
||||
this.selected = Array.from(selected.entries());
|
||||
const selected = new Map<string, DualSelectPair>([
|
||||
...this.options.map((pair) => [pair[0], pair] as const),
|
||||
...this.selected.map((pair) => [pair[0], pair] as const),
|
||||
]);
|
||||
|
||||
this.selected = Array.from(selected.values());
|
||||
|
||||
this.availablePane.value!.clearMove();
|
||||
}
|
||||
|
||||
removeSelected() {
|
||||
if (this.selectedPane.value!.moveable.length === 0) {
|
||||
return;
|
||||
}
|
||||
protected removeSelected() {
|
||||
if (this.selectedPane.value!.moveable.length === 0) return;
|
||||
|
||||
const deselected = new Set(this.selectedPane.value!.moveable);
|
||||
|
||||
this.selected = this.selected.filter(([key]) => !deselected.has(key));
|
||||
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
removeOne(key: string) {
|
||||
protected removeOne(key: string) {
|
||||
this.selected = this.selected.filter(([k]) => k !== key);
|
||||
}
|
||||
|
||||
removeAllVisible() {
|
||||
protected removeAllVisible() {
|
||||
// Remove all the items from selected that are in the *currently visible* options list
|
||||
const options = new Set(this.options.map(([k, _]) => k));
|
||||
const options = new Set(this.options.map(([k]) => k));
|
||||
|
||||
this.selected = this.selected.filter(([k]) => !options.has(k));
|
||||
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
@ -223,24 +227,25 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
|
||||
handleSearch(event: SearchbarEvent) {
|
||||
switch (event.detail.source) {
|
||||
case "ak-dual-list-available-search":
|
||||
return this.handleAvailableSearch(event.detail.value);
|
||||
case "ak-dual-list-selected-search":
|
||||
return this.handleSelectedSearch(event.detail.value);
|
||||
}
|
||||
#searchListener = (event: CustomEvent<SearchbarEventDetail>) => {
|
||||
const { source, value } = event.detail;
|
||||
|
||||
match(source)
|
||||
.with(SearchbarEventSource.Available, () => {
|
||||
this.dispatchCustomEvent(DualSelectEventType.Search, value);
|
||||
})
|
||||
.with(SearchbarEventSource.Selected, () => {
|
||||
this.selectedFilter = value;
|
||||
this.selectedPane.value!.clearMove();
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
handleAvailableSearch(value: string) {
|
||||
this.dispatchCustomEvent("ak-dual-select-search", value);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
handleSelectedSearch(value: string) {
|
||||
this.selectedFilter = value;
|
||||
this.selectedPane.value!.clearMove();
|
||||
}
|
||||
//#region Public Getters
|
||||
|
||||
get value() {
|
||||
return this.selected;
|
||||
@ -251,7 +256,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
// added.
|
||||
const allMoved =
|
||||
this.options.length ===
|
||||
this.options.filter(([key, _]) => this.selectedKeys.has(key)).length;
|
||||
this.options.filter(([key, _]) => this.#selectedKeys.has(key)).length;
|
||||
|
||||
return this.options.length > 0 && !allMoved;
|
||||
}
|
||||
@ -259,7 +264,8 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
get canRemoveAll() {
|
||||
// False if no visible option can be found in the selected list
|
||||
return (
|
||||
this.options.length > 0 && !!this.options.find(([key, _]) => this.selectedKeys.has(key))
|
||||
this.options.length > 0 &&
|
||||
!!this.options.find(([key, _]) => this.#selectedKeys.has(key))
|
||||
);
|
||||
}
|
||||
|
||||
@ -267,6 +273,10 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
return (this.pages?.next ?? 0) > 0 || (this.pages?.previous ?? 0) > 0;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Render
|
||||
|
||||
render() {
|
||||
const selected =
|
||||
this.selectedFilter === ""
|
||||
@ -282,11 +292,15 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
const availableCount = this.availablePane.value?.toMove.size ?? 0;
|
||||
const selectedCount = this.selectedPane.value?.toMove.size ?? 0;
|
||||
const selectedTotal = selected.length;
|
||||
|
||||
const availableStatus =
|
||||
availableCount > 0 ? msg(str`${availableCount} item(s) marked to add.`) : " ";
|
||||
|
||||
const selectedTotalStatus = msg(str`${selectedTotal} item(s) selected.`);
|
||||
|
||||
const selectedCountStatus =
|
||||
selectedCount > 0 ? " " + msg(str`${selectedCount} item(s) marked to remove.`) : "";
|
||||
|
||||
const selectedStatus = `${selectedTotalStatus} ${selectedCountStatus}`;
|
||||
|
||||
return html`
|
||||
@ -310,7 +324,7 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
<ak-dual-select-available-pane
|
||||
${ref(this.availablePane)}
|
||||
.options=${this.options}
|
||||
.selected=${this.selectedKeys}
|
||||
.selected=${this.#selectedKeys}
|
||||
></ak-dual-select-available-pane>
|
||||
${this.needPagination
|
||||
? html`<ak-pagination .pages=${this.pages}></ak-pagination>`
|
||||
@ -344,12 +358,14 @@ export class AkDualSelect extends CustomEmitterElement(CustomListenerElement(AKE
|
||||
|
||||
<ak-dual-select-selected-pane
|
||||
${ref(this.selectedPane)}
|
||||
.selected=${selected.toSorted(alphaSort)}
|
||||
.selected=${selected.toSorted(localeComparator)}
|
||||
></ak-dual-select-selected-pane>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user