Compare commits

..

2 Commits

Author SHA1 Message Date
e3f2ed0436 web: Prep for hash-less routing. 2025-04-03 15:19:19 +02:00
a5bb22a66a web: Move build observer. Prep. 2025-04-03 06:12:51 +02:00
173 changed files with 4692 additions and 4382 deletions

View File

@ -36,6 +36,11 @@ jobs:
run: | run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION npm i @goauthentik/api@$VERSION
- name: Upgrade /web/packages/sfe
working-directory: web/packages/sfe
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@v7 - uses: peter-evans/create-pull-request@v7
id: cpr id: cpr
with: with:

View File

@ -30,6 +30,7 @@ WORKDIR /work/web
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \ --mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev npm ci --include=dev
@ -93,7 +94,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv # Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.12 AS uv FROM ghcr.io/astral-sh/uv:0.6.11 AS uv
# Stage 6: Base python image # Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base

View File

@ -179,13 +179,10 @@ class UserSourceConnectionSerializer(SourceSerializer):
"user", "user",
"source", "source",
"source_obj", "source_obj",
"identifier",
"created", "created",
"last_updated",
] ]
extra_kwargs = { extra_kwargs = {
"created": {"read_only": True}, "created": {"read_only": True},
"last_updated": {"read_only": True},
} }
@ -202,7 +199,7 @@ class UserSourceConnectionViewSet(
queryset = UserSourceConnection.objects.all() queryset = UserSourceConnection.objects.all()
serializer_class = UserSourceConnectionSerializer serializer_class = UserSourceConnectionSerializer
filterset_fields = ["user", "source__slug"] filterset_fields = ["user", "source__slug"]
search_fields = ["user__username", "source__slug", "identifier"] search_fields = ["source__slug"]
ordering = ["source__slug", "pk"] ordering = ["source__slug", "pk"]
owner_field = "user" owner_field = "user"
@ -221,11 +218,9 @@ class GroupSourceConnectionSerializer(SourceSerializer):
"source_obj", "source_obj",
"identifier", "identifier",
"created", "created",
"last_updated",
] ]
extra_kwargs = { extra_kwargs = {
"created": {"read_only": True}, "created": {"read_only": True},
"last_updated": {"read_only": True},
} }
@ -242,5 +237,6 @@ class GroupSourceConnectionViewSet(
queryset = GroupSourceConnection.objects.all() queryset = GroupSourceConnection.objects.all()
serializer_class = GroupSourceConnectionSerializer serializer_class = GroupSourceConnectionSerializer
filterset_fields = ["group", "source__slug"] filterset_fields = ["group", "source__slug"]
search_fields = ["group__name", "source__slug", "identifier"] search_fields = ["source__slug"]
ordering = ["source__slug", "pk"] ordering = ["source__slug", "pk"]
owner_field = "user"

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.13 on 2025-04-07 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0043_alter_group_options"),
]
operations = [
migrations.AddField(
model_name="usersourceconnection",
name="new_identifier",
field=models.TextField(default=""),
preserve_default=False,
),
]

View File

@ -1,30 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0044_usersourceconnection_new_identifier"),
("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"),
("authentik_sources_oauth", "0009_migrate_useroauthsourceconnection_identifier"),
("authentik_sources_plex", "0005_migrate_userplexsourceconnection_identifier"),
("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"),
]
operations = [
migrations.RenameField(
model_name="usersourceconnection",
old_name="new_identifier",
new_name="identifier",
),
migrations.AddIndex(
model_name="usersourceconnection",
index=models.Index(fields=["identifier"], name="authentik_c_identif_59226f_idx"),
),
migrations.AddIndex(
model_name="usersourceconnection",
index=models.Index(
fields=["source", "identifier"], name="authentik_c_source__649e04_idx"
),
),
]

View File

@ -824,7 +824,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
source = models.ForeignKey(Source, on_delete=models.CASCADE) source = models.ForeignKey(Source, on_delete=models.CASCADE)
identifier = models.TextField()
objects = InheritanceManager() objects = InheritanceManager()
@ -838,10 +837,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
class Meta: class Meta:
unique_together = (("user", "source"),) unique_together = (("user", "source"),)
indexes = (
models.Index(fields=("identifier",)),
models.Index(fields=("source", "identifier")),
)
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel): class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):

View File

@ -13,11 +13,7 @@ from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
from authentik.core.api.groups import GroupViewSet from authentik.core.api.groups import GroupViewSet
from authentik.core.api.property_mappings import PropertyMappingViewSet from authentik.core.api.property_mappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
GroupSourceConnectionViewSet,
SourceViewSet,
UserSourceConnectionViewSet,
)
from authentik.core.api.tokens import TokenViewSet from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.transactional_applications import TransactionalApplicationView from authentik.core.api.transactional_applications import TransactionalApplicationView
from authentik.core.api.users import UserViewSet from authentik.core.api.users import UserViewSet
@ -85,7 +81,6 @@ api_urlpatterns = [
("core/tokens", TokenViewSet), ("core/tokens", TokenViewSet),
("sources/all", SourceViewSet), ("sources/all", SourceViewSet),
("sources/user_connections/all", UserSourceConnectionViewSet), ("sources/user_connections/all", UserSourceConnectionViewSet),
("sources/group_connections/all", GroupSourceConnectionViewSet),
("providers/all", ProviderViewSet), ("providers/all", ProviderViewSet),
("propertymappings/all", PropertyMappingViewSet), ("propertymappings/all", PropertyMappingViewSet),
("authenticators/all", DeviceViewSet, "device"), ("authenticators/all", DeviceViewSet, "device"),

View File

@ -49,6 +49,6 @@
</main> </main>
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span> <span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
</div> </div>
<script src="{% static 'dist/sfe/main.js' %}"></script> <script src="{% static 'dist/sfe/index.js' %}"></script>
</body> </body>
</html> </html>

View File

@ -1,11 +1,13 @@
"""Kerberos Source Serializer"""
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import (
GroupSourceConnectionSerializer, GroupSourceConnectionSerializer,
GroupSourceConnectionViewSet, GroupSourceConnectionViewSet,
UserSourceConnectionSerializer, UserSourceConnectionSerializer,
UserSourceConnectionViewSet,
) )
from authentik.core.api.used_by import UsedByMixin
from authentik.sources.kerberos.models import ( from authentik.sources.kerberos.models import (
GroupKerberosSourceConnection, GroupKerberosSourceConnection,
UserKerberosSourceConnection, UserKerberosSourceConnection,
@ -13,20 +15,33 @@ from authentik.sources.kerberos.models import (
class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer): class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer):
class Meta(UserSourceConnectionSerializer.Meta): """Kerberos Source Serializer"""
class Meta:
model = UserKerberosSourceConnection model = UserKerberosSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
class UserKerberosSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet):
"""Source Viewset"""
queryset = UserKerberosSourceConnection.objects.all() queryset = UserKerberosSourceConnection.objects.all()
serializer_class = UserKerberosSourceConnectionSerializer serializer_class = UserKerberosSourceConnectionSerializer
filterset_fields = ["source__slug"]
search_fields = ["source__slug"]
ordering = ["source__slug"]
owner_field = "user"
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta): class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupKerberosSourceConnection model = GroupKerberosSourceConnection
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet):
"""Group-source connection Viewset"""
queryset = GroupKerberosSourceConnection.objects.all() queryset = GroupKerberosSourceConnection.objects.all()
serializer_class = GroupKerberosSourceConnectionSerializer serializer_class = GroupKerberosSourceConnectionSerializer

View File

@ -1,28 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserKerberosSourceConnection = apps.get_model(
"authentik_sources_kerberos", "UserKerberosSourceConnection"
)
for connection in UserKerberosSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_kerberos", "0002_kerberossource_kadmin_type"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="userkerberossourceconnection",
name="identifier",
),
]

View File

@ -372,6 +372,8 @@ class KerberosSourcePropertyMapping(PropertyMapping):
class UserKerberosSourceConnection(UserSourceConnection): class UserKerberosSourceConnection(UserSourceConnection):
"""Connection to configured Kerberos Sources.""" """Connection to configured Kerberos Sources."""
identifier = models.TextField()
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.sources.kerberos.api.source_connection import ( from authentik.sources.kerberos.api.source_connection import (

View File

@ -1,3 +1,5 @@
"""OAuth Source Serializer"""
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import (
@ -10,9 +12,11 @@ from authentik.sources.oauth.models import GroupOAuthSourceConnection, UserOAuth
class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer): class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
"""OAuth Source Serializer"""
class Meta(UserSourceConnectionSerializer.Meta): class Meta(UserSourceConnectionSerializer.Meta):
model = UserOAuthSourceConnection model = UserOAuthSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["access_token"] fields = UserSourceConnectionSerializer.Meta.fields + ["identifier", "access_token"]
extra_kwargs = { extra_kwargs = {
**UserSourceConnectionSerializer.Meta.extra_kwargs, **UserSourceConnectionSerializer.Meta.extra_kwargs,
"access_token": {"write_only": True}, "access_token": {"write_only": True},
@ -20,15 +24,21 @@ class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Source Viewset"""
queryset = UserOAuthSourceConnection.objects.all() queryset = UserOAuthSourceConnection.objects.all()
serializer_class = UserOAuthSourceConnectionSerializer serializer_class = UserOAuthSourceConnectionSerializer
class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta): class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupOAuthSourceConnection model = GroupOAuthSourceConnection
class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
"""Group-source connection Viewset"""
queryset = GroupOAuthSourceConnection.objects.all() queryset = GroupOAuthSourceConnection.objects.all()
serializer_class = GroupOAuthSourceConnectionSerializer serializer_class = GroupOAuthSourceConnectionSerializer

View File

@ -1,28 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserOAuthSourceConnection = apps.get_model(
"authentik_sources_oauth", "UserOAuthSourceConnection"
)
for connection in UserOAuthSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_oauth", "0008_groupoauthsourceconnection_and_more"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="useroauthsourceconnection",
name="identifier",
),
]

View File

@ -286,6 +286,7 @@ class OAuthSourcePropertyMapping(PropertyMapping):
class UserOAuthSourceConnection(UserSourceConnection): class UserOAuthSourceConnection(UserSourceConnection):
"""Authorized remote OAuth provider.""" """Authorized remote OAuth provider."""
identifier = models.CharField(max_length=255)
access_token = models.TextField(blank=True, null=True, default=None) access_token = models.TextField(blank=True, null=True, default=None)
@property @property

View File

@ -1,3 +1,5 @@
"""Plex Source connection Serializer"""
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import (
@ -10,9 +12,14 @@ from authentik.sources.plex.models import GroupPlexSourceConnection, UserPlexSou
class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer): class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
"""Plex Source connection Serializer"""
class Meta(UserSourceConnectionSerializer.Meta): class Meta(UserSourceConnectionSerializer.Meta):
model = UserPlexSourceConnection model = UserPlexSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["plex_token"] fields = UserSourceConnectionSerializer.Meta.fields + [
"identifier",
"plex_token",
]
extra_kwargs = { extra_kwargs = {
**UserSourceConnectionSerializer.Meta.extra_kwargs, **UserSourceConnectionSerializer.Meta.extra_kwargs,
"plex_token": {"write_only": True}, "plex_token": {"write_only": True},
@ -20,15 +27,21 @@ class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Plex Source connection Serializer"""
queryset = UserPlexSourceConnection.objects.all() queryset = UserPlexSourceConnection.objects.all()
serializer_class = UserPlexSourceConnectionSerializer serializer_class = UserPlexSourceConnectionSerializer
class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""Plex Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta): class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupPlexSourceConnection model = GroupPlexSourceConnection
class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
"""Group-source connection Viewset"""
queryset = GroupPlexSourceConnection.objects.all() queryset = GroupPlexSourceConnection.objects.all()
serializer_class = GroupPlexSourceConnectionSerializer serializer_class = GroupPlexSourceConnectionSerializer

View File

@ -1,29 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserPlexSourceConnection = apps.get_model("authentik_sources_plex", "UserPlexSourceConnection")
for connection in UserPlexSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
(
"authentik_sources_plex",
"0004_groupplexsourceconnection_plexsourcepropertymapping_and_more",
),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="userplexsourceconnection",
name="identifier",
),
]

View File

@ -141,6 +141,7 @@ class UserPlexSourceConnection(UserSourceConnection):
"""Connect user and plex source""" """Connect user and plex source"""
plex_token = models.TextField() plex_token = models.TextField()
identifier = models.TextField()
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> type[Serializer]:

View File

@ -1,3 +1,5 @@
"""SAML Source Serializer"""
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import ( from authentik.core.api.sources import (
@ -10,20 +12,29 @@ from authentik.sources.saml.models import GroupSAMLSourceConnection, UserSAMLSou
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer): class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
"""SAML Source Serializer"""
class Meta(UserSourceConnectionSerializer.Meta): class Meta(UserSourceConnectionSerializer.Meta):
model = UserSAMLSourceConnection model = UserSAMLSourceConnection
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet): class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
"""Source Viewset"""
queryset = UserSAMLSourceConnection.objects.all() queryset = UserSAMLSourceConnection.objects.all()
serializer_class = UserSAMLSourceConnectionSerializer serializer_class = UserSAMLSourceConnectionSerializer
class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer): class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer):
"""OAuth Group-Source connection Serializer"""
class Meta(GroupSourceConnectionSerializer.Meta): class Meta(GroupSourceConnectionSerializer.Meta):
model = GroupSAMLSourceConnection model = GroupSAMLSourceConnection
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet): class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet):
"""Group-source connection Viewset"""
queryset = GroupSAMLSourceConnection.objects.all() queryset = GroupSAMLSourceConnection.objects.all()
serializer_class = GroupSAMLSourceConnectionSerializer serializer_class = GroupSAMLSourceConnectionSerializer

View File

@ -1,26 +0,0 @@
from django.db import migrations
def migrate_identifier(apps, schema_editor):
db_alias = schema_editor.connection.alias
UserSAMLSourceConnection = apps.get_model("authentik_sources_saml", "UserSAMLSourceConnection")
for connection in UserSAMLSourceConnection.objects.using(db_alias).all():
connection.new_identifier = connection.identifier
connection.save(using=db_alias)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_saml", "0018_alter_samlsource_slo_url_alter_samlsource_sso_url"),
("authentik_core", "0044_usersourceconnection_new_identifier"),
]
operations = [
migrations.RunPython(code=migrate_identifier, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="usersamlsourceconnection",
name="identifier",
),
]

View File

@ -318,6 +318,8 @@ class SAMLSourcePropertyMapping(PropertyMapping):
class UserSAMLSourceConnection(UserSourceConnection): class UserSAMLSourceConnection(UserSourceConnection):
"""Connection to configured SAML Sources.""" """Connection to configured SAML Sources."""
identifier = models.TextField()
@property @property
def serializer(self) -> Serializer: def serializer(self) -> Serializer:
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer

View File

@ -104,13 +104,6 @@ def send_mail(
# can't be converted to json) # can't be converted to json)
message_object.attach(logo_data()) message_object.attach(logo_data())
if (
message_object.to
and isinstance(message_object.to[0], str)
and "=?utf-8?" in message_object.to[0]
):
message_object.to = [message_object.to[0].split("<")[-1].replace(">", "")]
LOGGER.debug("Sending mail", to=message_object.to) LOGGER.debug("Sending mail", to=message_object.to)
backend.send_messages([message_object]) backend.send_messages([message_object])
Event.new( Event.new(

View File

@ -97,37 +97,6 @@ class TestEmailStageSending(FlowTestCase):
self.assertEqual(mail.outbox[0].subject, "authentik") self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"]) self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"])
def test_utf8_name(self):
"""Test with pending user"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
utf8_user = create_test_user()
utf8_user.name = "Cirilo ЉМНЊ el cirilico И̂ӢЙӤ "
utf8_user.email = "cyrillic@authentik.local"
utf8_user.save()
plan.context[PLAN_CONTEXT_PENDING_USER] = utf8_user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
with patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
):
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
self.flow,
response_errors={
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
},
)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, [f"{utf8_user.email}"])
def test_pending_fake_user(self): def test_pending_fake_user(self):
"""Test with pending (fake) user""" """Test with pending (fake) user"""
self.flow.designation = FlowDesignation.RECOVERY self.flow.designation = FlowDesignation.RECOVERY

View File

@ -8231,6 +8231,7 @@
}, },
"identifier": { "identifier": {
"type": "string", "type": "string",
"maxLength": 255,
"minLength": 1, "minLength": 1,
"title": "Identifier" "title": "Identifier"
}, },

View File

@ -9,7 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"aws-cdk": "^2.1007.0", "aws-cdk": "^2.1006.0",
"cross-env": "^7.0.3" "cross-env": "^7.0.3"
}, },
"engines": { "engines": {
@ -17,9 +17,9 @@
} }
}, },
"node_modules/aws-cdk": { "node_modules/aws-cdk": {
"version": "2.1007.0", "version": "2.1006.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1007.0.tgz", "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1006.0.tgz",
"integrity": "sha512-/UOYOTGWUm+pP9qxg03tID5tL6euC+pb+xo0RBue+xhnUWwj/Bbsw6DbqbpOPMrNzTUxmM723/uMEQmM6S26dw==", "integrity": "sha512-6qYnCt4mBN+3i/5F+FC2yMETkDHY/IL7gt3EuqKVPcaAO4jU7oXfVSlR60CYRkZWL4fnAurUV14RkJuJyVG/IA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {

View File

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

Binary file not shown.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@goauthentik/authentik", "name": "@goauthentik/authentik",
"version": "2025.2.3", "version": "2025.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@goauthentik/authentik", "name": "@goauthentik/authentik",
"version": "2025.2.3" "version": "2025.2.1"
} }
} }
} }

View File

@ -52,7 +52,7 @@ dependencies = [
"pydantic-scim", "pydantic-scim",
"pyjwt", "pyjwt",
"pyrad", "pyrad",
"python-kadmin-rs ==0.6.0", "python-kadmin-rs ==0.5.3",
"pyyaml", "pyyaml",
"requests-oauthlib", "requests-oauthlib",
"scim2-filter-parser", "scim2-filter-parser",

View File

@ -25938,243 +25938,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/sources/group_connections/all/:
get:
operationId: sources_group_connections_all_list
description: Group-source connection Viewset
parameters:
- in: query
name: group
schema:
type: string
format: uuid
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: search
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: source__slug
schema:
type: string
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedGroupSourceConnectionList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/all/{id}/:
get:
operationId: sources_group_connections_all_retrieve
description: Group-source connection Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
put:
operationId: sources_group_connections_all_update
description: Group-source connection Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSourceConnectionRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
patch:
operationId: sources_group_connections_all_partial_update
description: Group-source connection Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedGroupSourceConnectionRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
delete:
operationId: sources_group_connections_all_destroy
description: Group-source connection Viewset
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'204':
description: No response body
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/all/{id}/used_by/:
get:
operationId: sources_group_connections_all_used_by_list
description: Get a list of all objects that use this object
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this group source connection.
required: true
tags:
- sources
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UsedBy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/kerberos/: /sources/group_connections/kerberos/:
get: get:
operationId: sources_group_connections_kerberos_list operationId: sources_group_connections_kerberos_list
@ -26236,38 +25999,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
post:
operationId: sources_group_connections_kerberos_create
description: Group-source connection Viewset
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GroupKerberosSourceConnectionRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupKerberosSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/kerberos/{id}/: /sources/group_connections/kerberos/{id}/:
get: get:
operationId: sources_group_connections_kerberos_retrieve operationId: sources_group_connections_kerberos_retrieve
@ -27048,38 +26779,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
post:
operationId: sources_group_connections_saml_create
description: Group-source connection Viewset
tags:
- sources
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSAMLSourceConnectionRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/GroupSAMLSourceConnection'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/sources/group_connections/saml/{id}/: /sources/group_connections/saml/{id}/:
get: get:
operationId: sources_group_connections_saml_retrieve operationId: sources_group_connections_saml_retrieve
@ -30293,7 +29992,7 @@ paths:
/sources/user_connections/kerberos/: /sources/user_connections/kerberos/:
get: get:
operationId: sources_user_connections_kerberos_list operationId: sources_user_connections_kerberos_list
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- name: ordering - name: ordering
required: false required: false
@ -30323,10 +30022,6 @@ paths:
name: source__slug name: source__slug
schema: schema:
type: string type: string
- in: query
name: user
schema:
type: integer
tags: tags:
- sources - sources
security: security:
@ -30352,7 +30047,7 @@ paths:
description: '' description: ''
post: post:
operationId: sources_user_connections_kerberos_create operationId: sources_user_connections_kerberos_create
description: User-source connection Viewset description: Source Viewset
tags: tags:
- sources - sources
requestBody: requestBody:
@ -30385,7 +30080,7 @@ paths:
/sources/user_connections/kerberos/{id}/: /sources/user_connections/kerberos/{id}/:
get: get:
operationId: sources_user_connections_kerberos_retrieve operationId: sources_user_connections_kerberos_retrieve
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30419,7 +30114,7 @@ paths:
description: '' description: ''
put: put:
operationId: sources_user_connections_kerberos_update operationId: sources_user_connections_kerberos_update
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30459,7 +30154,7 @@ paths:
description: '' description: ''
patch: patch:
operationId: sources_user_connections_kerberos_partial_update operationId: sources_user_connections_kerberos_partial_update
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30498,7 +30193,7 @@ paths:
description: '' description: ''
delete: delete:
operationId: sources_user_connections_kerberos_destroy operationId: sources_user_connections_kerberos_destroy
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30566,7 +30261,7 @@ paths:
/sources/user_connections/oauth/: /sources/user_connections/oauth/:
get: get:
operationId: sources_user_connections_oauth_list operationId: sources_user_connections_oauth_list
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- name: ordering - name: ordering
required: false required: false
@ -30625,7 +30320,7 @@ paths:
description: '' description: ''
post: post:
operationId: sources_user_connections_oauth_create operationId: sources_user_connections_oauth_create
description: User-source connection Viewset description: Source Viewset
tags: tags:
- sources - sources
requestBody: requestBody:
@ -30658,7 +30353,7 @@ paths:
/sources/user_connections/oauth/{id}/: /sources/user_connections/oauth/{id}/:
get: get:
operationId: sources_user_connections_oauth_retrieve operationId: sources_user_connections_oauth_retrieve
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30691,7 +30386,7 @@ paths:
description: '' description: ''
put: put:
operationId: sources_user_connections_oauth_update operationId: sources_user_connections_oauth_update
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30730,7 +30425,7 @@ paths:
description: '' description: ''
patch: patch:
operationId: sources_user_connections_oauth_partial_update operationId: sources_user_connections_oauth_partial_update
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30768,7 +30463,7 @@ paths:
description: '' description: ''
delete: delete:
operationId: sources_user_connections_oauth_destroy operationId: sources_user_connections_oauth_destroy
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30834,7 +30529,7 @@ paths:
/sources/user_connections/plex/: /sources/user_connections/plex/:
get: get:
operationId: sources_user_connections_plex_list operationId: sources_user_connections_plex_list
description: User-source connection Viewset description: Plex Source connection Serializer
parameters: parameters:
- name: ordering - name: ordering
required: false required: false
@ -30893,7 +30588,7 @@ paths:
description: '' description: ''
post: post:
operationId: sources_user_connections_plex_create operationId: sources_user_connections_plex_create
description: User-source connection Viewset description: Plex Source connection Serializer
tags: tags:
- sources - sources
requestBody: requestBody:
@ -30926,7 +30621,7 @@ paths:
/sources/user_connections/plex/{id}/: /sources/user_connections/plex/{id}/:
get: get:
operationId: sources_user_connections_plex_retrieve operationId: sources_user_connections_plex_retrieve
description: User-source connection Viewset description: Plex Source connection Serializer
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30959,7 +30654,7 @@ paths:
description: '' description: ''
put: put:
operationId: sources_user_connections_plex_update operationId: sources_user_connections_plex_update
description: User-source connection Viewset description: Plex Source connection Serializer
parameters: parameters:
- in: path - in: path
name: id name: id
@ -30998,7 +30693,7 @@ paths:
description: '' description: ''
patch: patch:
operationId: sources_user_connections_plex_partial_update operationId: sources_user_connections_plex_partial_update
description: User-source connection Viewset description: Plex Source connection Serializer
parameters: parameters:
- in: path - in: path
name: id name: id
@ -31036,7 +30731,7 @@ paths:
description: '' description: ''
delete: delete:
operationId: sources_user_connections_plex_destroy operationId: sources_user_connections_plex_destroy
description: User-source connection Viewset description: Plex Source connection Serializer
parameters: parameters:
- in: path - in: path
name: id name: id
@ -31102,7 +30797,7 @@ paths:
/sources/user_connections/saml/: /sources/user_connections/saml/:
get: get:
operationId: sources_user_connections_saml_list operationId: sources_user_connections_saml_list
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- name: ordering - name: ordering
required: false required: false
@ -31161,7 +30856,7 @@ paths:
description: '' description: ''
post: post:
operationId: sources_user_connections_saml_create operationId: sources_user_connections_saml_create
description: User-source connection Viewset description: Source Viewset
tags: tags:
- sources - sources
requestBody: requestBody:
@ -31194,7 +30889,7 @@ paths:
/sources/user_connections/saml/{id}/: /sources/user_connections/saml/{id}/:
get: get:
operationId: sources_user_connections_saml_retrieve operationId: sources_user_connections_saml_retrieve
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -31227,7 +30922,7 @@ paths:
description: '' description: ''
put: put:
operationId: sources_user_connections_saml_update operationId: sources_user_connections_saml_update
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -31266,7 +30961,7 @@ paths:
description: '' description: ''
patch: patch:
operationId: sources_user_connections_saml_partial_update operationId: sources_user_connections_saml_partial_update
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -31304,7 +30999,7 @@ paths:
description: '' description: ''
delete: delete:
operationId: sources_user_connections_saml_destroy operationId: sources_user_connections_saml_destroy
description: User-source connection Viewset description: Source Viewset
parameters: parameters:
- in: path - in: path
name: id name: id
@ -44758,7 +44453,7 @@ components:
- users_obj - users_obj
GroupKerberosSourceConnection: GroupKerberosSourceConnection:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties: properties:
pk: pk:
type: integer type: integer
@ -44780,21 +44475,16 @@ components:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required: required:
- created - created
- group - group
- identifier - identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
GroupKerberosSourceConnectionRequest: GroupKerberosSourceConnectionRequest:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties: properties:
group: group:
type: string type: string
@ -44894,7 +44584,7 @@ components:
- username - username
GroupOAuthSourceConnection: GroupOAuthSourceConnection:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties: properties:
pk: pk:
type: integer type: integer
@ -44916,21 +44606,16 @@ components:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required: required:
- created - created
- group - group
- identifier - identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
GroupOAuthSourceConnectionRequest: GroupOAuthSourceConnectionRequest:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties: properties:
group: group:
type: string type: string
@ -44947,7 +44632,7 @@ components:
- source - source
GroupPlexSourceConnection: GroupPlexSourceConnection:
type: object type: object
description: Group Source Connection description: Plex Group-Source connection Serializer
properties: properties:
pk: pk:
type: integer type: integer
@ -44969,21 +44654,16 @@ components:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required: required:
- created - created
- group - group
- identifier - identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
GroupPlexSourceConnectionRequest: GroupPlexSourceConnectionRequest:
type: object type: object
description: Group Source Connection description: Plex Group-Source connection Serializer
properties: properties:
group: group:
type: string type: string
@ -45028,7 +44708,7 @@ components:
- name - name
GroupSAMLSourceConnection: GroupSAMLSourceConnection:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties: properties:
pk: pk:
type: integer type: integer
@ -45050,74 +44730,16 @@ components:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required: required:
- created - created
- group - group
- identifier - identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
GroupSAMLSourceConnectionRequest: GroupSAMLSourceConnectionRequest:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties:
group:
type: string
format: uuid
source:
type: string
format: uuid
identifier:
type: string
minLength: 1
required:
- group
- identifier
- source
GroupSourceConnection:
type: object
description: Group Source Connection
properties:
pk:
type: integer
readOnly: true
title: ID
group:
type: string
format: uuid
source:
type: string
format: uuid
source_obj:
allOf:
- $ref: '#/components/schemas/Source'
readOnly: true
identifier:
type: string
created:
type: string
format: date-time
readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required:
- created
- group
- identifier
- last_updated
- pk
- source
- source_obj
GroupSourceConnectionRequest:
type: object
description: Group Source Connection
properties: properties:
group: group:
type: string type: string
@ -48743,18 +48365,6 @@ components:
required: required:
- pagination - pagination
- results - results
PaginatedGroupSourceConnectionList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/GroupSourceConnection'
required:
- pagination
- results
PaginatedIdentificationStageList: PaginatedIdentificationStageList:
type: object type: object
properties: properties:
@ -51135,7 +50745,7 @@ components:
the remote system. the remote system.
PatchedGroupKerberosSourceConnectionRequest: PatchedGroupKerberosSourceConnectionRequest:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties: properties:
group: group:
type: string type: string
@ -51148,7 +50758,7 @@ components:
minLength: 1 minLength: 1
PatchedGroupOAuthSourceConnectionRequest: PatchedGroupOAuthSourceConnectionRequest:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties: properties:
group: group:
type: string type: string
@ -51161,7 +50771,7 @@ components:
minLength: 1 minLength: 1
PatchedGroupPlexSourceConnectionRequest: PatchedGroupPlexSourceConnectionRequest:
type: object type: object
description: Group Source Connection description: Plex Group-Source connection Serializer
properties: properties:
group: group:
type: string type: string
@ -51200,20 +50810,7 @@ components:
format: uuid format: uuid
PatchedGroupSAMLSourceConnectionRequest: PatchedGroupSAMLSourceConnectionRequest:
type: object type: object
description: Group Source Connection description: OAuth Group-Source connection Serializer
properties:
group:
type: string
format: uuid
source:
type: string
format: uuid
identifier:
type: string
minLength: 1
PatchedGroupSourceConnectionRequest:
type: object
description: Group Source Connection
properties: properties:
group: group:
type: string type: string
@ -53186,7 +52783,7 @@ components:
$ref: '#/components/schemas/FlowSetRequest' $ref: '#/components/schemas/FlowSetRequest'
PatchedUserKerberosSourceConnectionRequest: PatchedUserKerberosSourceConnectionRequest:
type: object type: object
description: User source connection description: Kerberos Source Serializer
properties: properties:
user: user:
type: integer type: integer
@ -53243,7 +52840,7 @@ components:
$ref: '#/components/schemas/FlowSetRequest' $ref: '#/components/schemas/FlowSetRequest'
PatchedUserOAuthSourceConnectionRequest: PatchedUserOAuthSourceConnectionRequest:
type: object type: object
description: User source connection description: OAuth Source Serializer
properties: properties:
user: user:
type: integer type: integer
@ -53253,13 +52850,14 @@ components:
identifier: identifier:
type: string type: string
minLength: 1 minLength: 1
maxLength: 255
access_token: access_token:
type: string type: string
writeOnly: true writeOnly: true
nullable: true nullable: true
PatchedUserPlexSourceConnectionRequest: PatchedUserPlexSourceConnectionRequest:
type: object type: object
description: User source connection description: Plex Source connection Serializer
properties: properties:
user: user:
type: integer type: integer
@ -53313,7 +52911,7 @@ components:
$ref: '#/components/schemas/UserTypeEnum' $ref: '#/components/schemas/UserTypeEnum'
PatchedUserSAMLSourceConnectionRequest: PatchedUserSAMLSourceConnectionRequest:
type: object type: object
description: User source connection description: SAML Source Serializer
properties: properties:
user: user:
type: integer type: integer
@ -53332,9 +52930,6 @@ components:
source: source:
type: string type: string
format: uuid format: uuid
identifier:
type: string
minLength: 1
PatchedUserWriteStageRequest: PatchedUserWriteStageRequest:
type: object type: object
description: UserWriteStage Serializer description: UserWriteStage Serializer
@ -58422,7 +58017,7 @@ components:
- name - name
UserKerberosSourceConnection: UserKerberosSourceConnection:
type: object type: object
description: User source connection description: Kerberos Source Serializer
properties: properties:
pk: pk:
type: integer type: integer
@ -58437,27 +58032,22 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/Source' - $ref: '#/components/schemas/Source'
readOnly: true readOnly: true
identifier:
type: string
created: created:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated: identifier:
type: string type: string
format: date-time
readOnly: true
required: required:
- created - created
- identifier - identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
- user - user
UserKerberosSourceConnectionRequest: UserKerberosSourceConnectionRequest:
type: object type: object
description: User source connection description: Kerberos Source Serializer
properties: properties:
user: user:
type: integer type: integer
@ -58684,7 +58274,7 @@ components:
- logins_failed - logins_failed
UserOAuthSourceConnection: UserOAuthSourceConnection:
type: object type: object
description: User source connection description: OAuth Source Serializer
properties: properties:
pk: pk:
type: integer type: integer
@ -58699,27 +58289,23 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/Source' - $ref: '#/components/schemas/Source'
readOnly: true readOnly: true
identifier:
type: string
created: created:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated: identifier:
type: string type: string
format: date-time maxLength: 255
readOnly: true
required: required:
- created - created
- identifier - identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
- user - user
UserOAuthSourceConnectionRequest: UserOAuthSourceConnectionRequest:
type: object type: object
description: User source connection description: OAuth Source Serializer
properties: properties:
user: user:
type: integer type: integer
@ -58729,6 +58315,7 @@ components:
identifier: identifier:
type: string type: string
minLength: 1 minLength: 1
maxLength: 255
access_token: access_token:
type: string type: string
writeOnly: true writeOnly: true
@ -58786,7 +58373,7 @@ components:
- paths - paths
UserPlexSourceConnection: UserPlexSourceConnection:
type: object type: object
description: User source connection description: Plex Source connection Serializer
properties: properties:
pk: pk:
type: integer type: integer
@ -58801,27 +58388,22 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/Source' - $ref: '#/components/schemas/Source'
readOnly: true readOnly: true
identifier:
type: string
created: created:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated: identifier:
type: string type: string
format: date-time
readOnly: true
required: required:
- created - created
- identifier - identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
- user - user
UserPlexSourceConnectionRequest: UserPlexSourceConnectionRequest:
type: object type: object
description: User source connection description: Plex Source connection Serializer
properties: properties:
user: user:
type: integer type: integer
@ -58883,7 +58465,7 @@ components:
- username - username
UserSAMLSourceConnection: UserSAMLSourceConnection:
type: object type: object
description: User source connection description: SAML Source Serializer
properties: properties:
pk: pk:
type: integer type: integer
@ -58898,27 +58480,22 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/Source' - $ref: '#/components/schemas/Source'
readOnly: true readOnly: true
identifier:
type: string
created: created:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated: identifier:
type: string type: string
format: date-time
readOnly: true
required: required:
- created - created
- identifier - identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
- user - user
UserSAMLSourceConnectionRequest: UserSAMLSourceConnectionRequest:
type: object type: object
description: User source connection description: SAML Source Serializer
properties: properties:
user: user:
type: integer type: integer
@ -59082,20 +58659,12 @@ components:
allOf: allOf:
- $ref: '#/components/schemas/Source' - $ref: '#/components/schemas/Source'
readOnly: true readOnly: true
identifier:
type: string
created: created:
type: string type: string
format: date-time format: date-time
readOnly: true readOnly: true
last_updated:
type: string
format: date-time
readOnly: true
required: required:
- created - created
- identifier
- last_updated
- pk - pk
- source - source
- source_obj - source_obj
@ -59109,11 +58678,7 @@ components:
source: source:
type: string type: string
format: uuid format: uuid
identifier:
type: string
minLength: 1
required: required:
- identifier
- source - source
- user - user
UserTypeEnum: UserTypeEnum:

18
uv.lock generated
View File

@ -310,7 +310,7 @@ requires-dist = [
{ name = "pydantic-scim" }, { name = "pydantic-scim" },
{ name = "pyjwt" }, { name = "pyjwt" },
{ name = "pyrad" }, { name = "pyrad" },
{ name = "python-kadmin-rs", specifier = "==0.6.0" }, { name = "python-kadmin-rs", specifier = "==0.5.3" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "requests-oauthlib" }, { name = "requests-oauthlib" },
{ name = "scim2-filter-parser" }, { name = "scim2-filter-parser" },
@ -2599,16 +2599,16 @@ wheels = [
[[package]] [[package]]
name = "python-kadmin-rs" name = "python-kadmin-rs"
version = "0.6.0" version = "0.5.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b9/ac/df3a093b1e186cd68a6f38778fac025450e5c5e9859c4790e00c2ed0ff62/python_kadmin_rs-0.6.0.tar.gz", hash = "sha256:dadd3d4ef542b829c1dcde97360a6b6a10700a4b5686f12f24b10f6cf5ca6e6c", size = 89318 } sdist = { url = "https://files.pythonhosted.org/packages/e7/95/07b708623f13874ad86dc603f2fe36e980a5f5890edea87286d13f2b0b81/python_kadmin_rs-0.5.3.tar.gz", hash = "sha256:4f46fd854af622896136c3ac4fc5e6a37d37bfffb5b2023e438001ffa62ab7e3", size = 89865 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/6d/59fefe1c4c11177c4feb8ad65dd6a265e9cc5fc83682a928acdccb170000/python_kadmin_rs-0.6.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:0069fbd656096b98853f8cdc6d5e24f754829fa9cb4a716dac33777f0305d37a", size = 1418187 }, { url = "https://files.pythonhosted.org/packages/96/46/1bbfd7d6819851c300b991d7340452fba8edc3d2fe68b33271279eb74887/python_kadmin_rs-0.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:54b5e1c2e22da0d16c1418eb2b46da8baa11699a5db8db2afc52dbfd02d14958", size = 1416637 },
{ url = "https://files.pythonhosted.org/packages/a6/12/c00a71c0fc17f5d208b4bb5e570002d74f0bc414e35194537d46ea32080f/python_kadmin_rs-0.6.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:cfcfe9982e969705dee62f2b97c8d7c249b55b2a97e2bc981408061ea7182b96", size = 1501759 }, { url = "https://files.pythonhosted.org/packages/be/34/fd7f5c324aaf1b9ad3dd5050ac2059230618c29adc452d676d2af4d5ae79/python_kadmin_rs-0.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:d1dc7ad1f07bbfd09baeb1fb0dfc45c87776ed717052081e63d3bdba340a250e", size = 1503018 },
{ url = "https://files.pythonhosted.org/packages/a0/b5/06cf809cfaaeded84e6634bf07116264ab4f8fd5eccca7523114e197f424/python_kadmin_rs-0.6.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:920df382e7a554d2f6fd160436a64adf1251f3262ec16bccd6d3b9f7e039d5fa", size = 3262691 }, { url = "https://files.pythonhosted.org/packages/e5/29/3931502534e07806cf7c70631374452cfcbafa44e75c5403416372b701c7/python_kadmin_rs-0.5.3-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86404a1060ece916088ae4a0d188e9309fd46e0b3003779ee7a8dc7493176779", size = 3268475 },
{ url = "https://files.pythonhosted.org/packages/e6/72/99884dbc1856440a548ea8bf2ff1232c7f2823b6cb1a62bbb4d902a34609/python_kadmin_rs-0.6.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:94509b7470b18105c27fcaf5e6af894644614a687af74a43499735c405217e01", size = 3382996 }, { url = "https://files.pythonhosted.org/packages/ba/5d/f18ca5df97a4241711555987eb308c6e6c5505883514ac7f18d7aebd52f2/python_kadmin_rs-0.5.3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7aa62a618af2b2112f708fd44f9cc3cf25e28f1562ea66a2036fb3cd1a47e649", size = 3371699 },
{ url = "https://files.pythonhosted.org/packages/bd/4f/5d7e5be27cd466affc00fcab71fb94ea0420aee95306188988faf270b129/python_kadmin_rs-0.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6f89e7fbcb7220a42c143a1b008685f98ca0a72ecc55c30f85b72c9d1ba9c3b9", size = 1572007 }, { url = "https://files.pythonhosted.org/packages/91/d3/42c4d57414cfdf4e4ff528dd8e72428908ee67aeeae6a63fe2f5dbcd04bc/python_kadmin_rs-0.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80813af82dfbcc6a90505183c822eab11de77b6703e5691e37ed77d292224dd9", size = 1584049 },
{ url = "https://files.pythonhosted.org/packages/a6/1e/fdd7d6cd2ebc4cc654112329311380d1c03c681511973e32ae6ab90f261c/python_kadmin_rs-0.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:775ce07ffd47a50ba27c8d74c20baacb56acfc7a8c56a8b02f2207ed9829156e", size = 1618897 }, { url = "https://files.pythonhosted.org/packages/9a/65/705f179cf4bf4d16fc1daeac0810def57da2f4514a5b79ca60f24d7efb90/python_kadmin_rs-0.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6799a0faddb4ccf200acfa87da38e5fa2af54970d066b2c876e752bbf794b204", size = 1590360 },
] ]
[[package]] [[package]]

View File

@ -1,206 +0,0 @@
/**
* @file WebAuthn utilities.
*/
import { fromByteArray } from "base64-js";
//@ts-check
//#region Type Definitions
/**
* @typedef {object} Assertion
* @property {string} id
* @property {string} rawId
* @property {string} type
* @property {string} registrationClientExtensions
* @property {object} response
* @property {string} response.clientDataJSON
* @property {string} response.attestationObject
*/
/**
* @typedef {object} AuthAssertion
* @property {string} id
* @property {string} rawId
* @property {string} type
* @property {string} assertionClientExtensions
* @property {object} response
* @property {string} response.clientDataJSON
* @property {string} response.authenticatorData
* @property {string} response.signature
* @property {string | null} response.userHandle
*/
//#endregion
//#region Encoding/Decoding
/**
* Encodes a byte array into a URL-safe base64 string.
*
* @param {Uint8Array} buffer
* @returns {string}
*/
export function encodeBase64(buffer) {
return fromByteArray(buffer).replace(/\+/g, "-").replace(/\//g, "_").replace(/[=]/g, "");
}
/**
* Encodes a byte array into a base64 string without URL-safe encoding, i.e., with padding.
* @param {Uint8Array} buffer
* @returns {string}
*/
export function encodeBase64Raw(buffer) {
return fromByteArray(buffer).replace(/\+/g, "-").replace(/\//g, "_");
}
/**
* Decodes a base64 string into a byte array.
*
* @param {string} input
* @returns {Uint8Array}
*/
export function decodeBase64(input) {
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
c.charCodeAt(0),
);
}
//#endregion
//#region Utility Functions
/**
* Checks if the browser supports WebAuthn.
*
* @returns {boolean}
*/
export function isWebAuthnSupported() {
if ("credentials" in navigator) return true;
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
console.warn("WebAuthn requires this page to be accessed via HTTPS.");
return false;
}
console.warn("WebAuthn not supported by browser.");
return false;
}
/**
* Asserts that the browser supports WebAuthn and that we're in a secure context.
*
* @throws {Error} If WebAuthn is not supported.
*/
export function assertWebAuthnSupport() {
// Is the navigator exposing the credentials API?
if ("credentials" in navigator) return;
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
throw new Error("WebAuthn requires this page to be accessed via HTTPS.");
}
throw new Error("WebAuthn not supported by browser.");
}
/**
* Transforms items in the credentialCreateOptions generated on the server
* into byte arrays expected by the navigator.credentials.create() call
* @param {PublicKeyCredentialCreationOptions} credentialCreateOptions
* @param {string} userID
* @returns {PublicKeyCredentialCreationOptions}
*/
export function transformCredentialCreateOptions(credentialCreateOptions, userID) {
const user = credentialCreateOptions.user;
// Because json can't contain raw bytes, the server base64-encodes the User ID
// So to get the base64 encoded byte array, we first need to convert it to a regular
// string, then a byte array, re-encode it and wrap that in an array.
const stringId = decodeURIComponent(window.atob(userID));
user.id = decodeBase64(encodeBase64(decodeBase64(stringId)));
const challenge = decodeBase64(credentialCreateOptions.challenge.toString());
return {
...credentialCreateOptions,
challenge,
user,
};
}
/**
* Transforms the binary data in the credential into base64 strings
* for posting to the server.
*
* @param {PublicKeyCredential} newAssertion
* @returns {Assertion}
*/
export function transformNewAssertionForServer(newAssertion) {
const response = /** @type {AuthenticatorAttestationResponse} */ (newAssertion.response);
const attObj = new Uint8Array(response.attestationObject);
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const registrationClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: encodeBase64(rawId),
type: newAssertion.type,
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
response: {
clientDataJSON: encodeBase64(clientDataJSON),
attestationObject: encodeBase64(attObj),
},
};
}
/**
* Transforms the items in the credentialRequestOptions generated on the server
*
* @param {PublicKeyCredentialRequestOptions} credentialRequestOptions
* @returns {PublicKeyCredentialRequestOptions}
*/
export function transformCredentialRequestOptions(credentialRequestOptions) {
const challenge = decodeBase64(credentialRequestOptions.challenge.toString());
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
(credentialDescriptor) => {
const id = decodeBase64(credentialDescriptor.id.toString());
return Object.assign({}, credentialDescriptor, { id });
},
);
return Object.assign({}, credentialRequestOptions, {
challenge,
allowCredentials,
});
}
/**
* Encodes the binary data in the assertion into strings for posting to the server.
* @param {PublicKeyCredential} newAssertion
* @returns {AuthAssertion}
*/
export function transformAssertionForServer(newAssertion) {
const response = /** @type {AuthenticatorAssertionResponse} */ (newAssertion.response);
const authData = new Uint8Array(response.authenticatorData);
const clientDataJSON = new Uint8Array(response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const sig = new Uint8Array(response.signature);
const assertionClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: encodeBase64(rawId),
type: newAssertion.type,
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
response: {
clientDataJSON: encodeBase64Raw(clientDataJSON),
signature: encodeBase64Raw(sig),
authenticatorData: encodeBase64Raw(authData),
userHandle: null,
},
};
}

View File

@ -48,9 +48,6 @@ export default [
"lit/no-template-bind": "error", "lit/no-template-bind": "error",
"no-unused-vars": "off", "no-unused-vars": "off",
"no-console": ["error", { allow: ["debug", "warn", "error"] }], "no-console": ["error", { allow: ["debug", "warn", "error"] }],
// TODO: TypeScript already handles this.
// Remove after project-wide ESLint config is properly set up.
"no-undef": "off",
"@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "error",
@ -74,18 +71,8 @@ export default [
...globals.node, ...globals.node,
}, },
}, },
files: [ files: ["scripts/**/*.mjs", "*.ts", "*.mjs"],
// TODO:Remove after project-wide ESLint config is properly set up.
"scripts/**/*.mjs",
"authentication/**/*.js",
"sfe/**/*.js",
"*.ts",
"*.mjs",
],
rules: { rules: {
"no-undef": "off",
// TODO: TypeScript already handles this.
// Remove after project-wide ESLint config is properly set up.
"no-unused-vars": "off", "no-unused-vars": "off",
// We WANT our scripts to output to the console! // We WANT our scripts to output to the console!
"no-console": "off", "no-console": "off",

1914
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -57,14 +57,9 @@
"ts-pattern": "^5.4.0", "ts-pattern": "^5.4.0",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"webcomponent-qr-code": "^1.2.0", "webcomponent-qr-code": "^1.2.0",
"yaml": "^2.5.1", "yaml": "^2.5.1"
"bootstrap": "^4.6.1",
"formdata-polyfill": "^4.0.10",
"jquery": "^3.7.1",
"weakmap-polyfill": "^2.0.4"
}, },
"devDependencies": { "devDependencies": {
"@types/jquery": "^3.5.31",
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@hcaptcha/types": "^1.0.4", "@hcaptcha/types": "^1.0.4",
"@lit/localize-tools": "^0.8.0", "@lit/localize-tools": "^0.8.0",
@ -95,8 +90,6 @@
"@wdio/spec-reporter": "^9.1.2", "@wdio/spec-reporter": "^9.1.2",
"chromedriver": "^131.0.1", "chromedriver": "^131.0.1",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"esbuild-plugin-copy": "^2.1.1",
"esbuild-plugin-es5": "^2.1.1",
"esbuild-plugin-polyfill-node": "^0.3.0", "esbuild-plugin-polyfill-node": "^0.3.0",
"esbuild-plugins-node-modules-polyfill": "^1.7.0", "esbuild-plugins-node-modules-polyfill": "^1.7.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",
@ -168,12 +161,6 @@
"watch": "run-s build-locales esbuild:watch" "watch": "run-s build-locales esbuild:watch"
}, },
"type": "module", "type": "module",
"exports": {
"./package.json": "./package.json",
"./paths": "./paths.js",
"./authentication": "./authentication/index.js",
"./scripts/*": "./scripts/*.mjs"
},
"wireit": { "wireit": {
"build": { "build": {
"#comment": [ "#comment": [
@ -206,7 +193,8 @@
"./dist/patternfly.min.css" "./dist/patternfly.min.css"
], ],
"dependencies": [ "dependencies": [
"build-locales" "build-locales",
"./packages/sfe:build"
], ],
"env": { "env": {
"NODE_RUNNER": { "NODE_RUNNER": {
@ -216,7 +204,12 @@
} }
}, },
"build:sfe": { "build:sfe": {
"command": "node scripts/build-sfe.mjs" "dependencies": [
"./packages/sfe:build"
],
"files": [
"./packages/sfe/**/*.ts"
]
}, },
"build-proxy": { "build-proxy": {
"command": "node scripts/build-web.mjs --proxy", "command": "node scripts/build-web.mjs --proxy",
@ -249,6 +242,11 @@
"lint:package" "lint:package"
] ]
}, },
"format:packages": {
"dependencies": [
"./packages/sfe:prettier"
]
},
"lint": { "lint": {
"command": "eslint --max-warnings 0 --fix", "command": "eslint --max-warnings 0 --fix",
"env": { "env": {
@ -276,6 +274,11 @@
"shell": true, "shell": true,
"command": "sh ./scripts/lint-lockfile.sh package-lock.json" "command": "sh ./scripts/lint-lockfile.sh package-lock.json"
}, },
"lint:lockfiles": {
"dependencies": [
"./packages/sfe:lint:lockfile"
]
},
"lint:package": { "lint:package": {
"command": "syncpack format -i ' '" "command": "syncpack format -i ' '"
}, },
@ -311,7 +314,9 @@
"lint:spelling", "lint:spelling",
"lint:package", "lint:package",
"lint:lockfile", "lint:lockfile",
"lint:precommit" "lint:lockfiles",
"lint:precommit",
"format:packages"
] ]
}, },
"prettier": { "prettier": {

View File

@ -0,0 +1,23 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 100,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleQuote": false,
"tabWidth": 4,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": ["^(@?)lit(.*)$", "\\.css$", "^@goauthentik/api$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderParserPlugins": ["typescript", "classProperties", "decorators-legacy"]
}

View File

@ -0,0 +1,18 @@
The MIT License (MIT)
Copyright (c) 2024 Authentik Security, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,68 @@
{
"name": "@goauthentik/web-sfe",
"version": "0.0.0",
"dependencies": {
"@goauthentik/api": "^2024.6.0-1719577139",
"base64-js": "^1.5.1",
"bootstrap": "^4.6.1",
"formdata-polyfill": "^4.0.10",
"jquery": "^3.7.1",
"weakmap-polyfill": "^2.0.4"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^28.0.0",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-swc": "^0.4.0",
"@swc/cli": "^0.4.0",
"@swc/core": "^1.7.28",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@types/jquery": "^3.5.31",
"lockfile-lint": "^4.14.0",
"prettier": "^3.3.2",
"rollup": "^4.23.0",
"rollup-plugin-copy": "^3.5.0",
"wireit": "^0.14.9"
},
"license": "MIT",
"optionalDependencies": {
"@swc/core": "^1.7.28",
"@swc/core-darwin-arm64": "^1.6.13",
"@swc/core-darwin-x64": "^1.6.13",
"@swc/core-linux-arm-gnueabihf": "^1.6.13",
"@swc/core-linux-arm64-gnu": "^1.6.13",
"@swc/core-linux-arm64-musl": "^1.6.13",
"@swc/core-linux-x64-gnu": "^1.6.13",
"@swc/core-linux-x64-musl": "^1.6.13",
"@swc/core-win32-arm64-msvc": "^1.6.13",
"@swc/core-win32-ia32-msvc": "^1.6.13",
"@swc/core-win32-x64-msvc": "^1.6.13"
},
"private": true,
"scripts": {
"build": "wireit",
"lint:lockfile": "wireit",
"prettier": "prettier --write ./src ./tsconfig.json ./rollup.config.js ./package.json",
"watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs"
},
"wireit": {
"build:sfe": {
"command": "rollup -c rollup.config.js --bundleConfigAsCjs",
"files": [
"../../node_modules/bootstrap/dist/css/bootstrap.min.css",
"src/index.ts"
],
"output": [
"./dist/sfe/*"
]
},
"build": {
"command": "mkdir -p ../../dist/sfe && cp -r dist/sfe/* ../../dist/sfe",
"dependencies": [
"build:sfe"
]
},
"lint:lockfile": {
"command": "lockfile-lint --path package.json --type npm --allowed-hosts npm --validate-https"
}
}
}

View File

@ -0,0 +1,43 @@
import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";
import swc from "@rollup/plugin-swc";
import copy from "rollup-plugin-copy";
export default {
input: "src/index.ts",
output: {
dir: "./dist/sfe",
format: "cjs",
},
context: "window",
plugins: [
copy({
targets: [
{
src: "../../node_modules/bootstrap/dist/css/bootstrap.min.css",
dest: "./dist/sfe",
},
],
}),
resolve({ browser: true }),
commonjs(),
swc({
swc: {
jsc: {
loose: false,
externalHelpers: false,
// Requires v1.2.50 or upper and requires target to be es2016 or upper.
keepClassNames: false,
},
minify: false,
env: {
targets: {
edge: "17",
ie: "11",
},
mode: "entry",
},
},
}),
],
};

View File

@ -0,0 +1,527 @@
import { fromByteArray } from "base64-js";
import "formdata-polyfill";
import $ from "jquery";
import "weakmap-polyfill";
import {
type AuthenticatorValidationChallenge,
type AutosubmitChallenge,
type ChallengeTypes,
ChallengeTypesFromJSON,
type ContextualFlowInfo,
type DeviceChallenge,
type ErrorDetail,
type IdentificationChallenge,
type PasswordChallenge,
type RedirectChallenge,
} from "@goauthentik/api";
interface GlobalAuthentik {
brand: {
branding_logo: string;
};
api: {
base: string;
};
}
function ak(): GlobalAuthentik {
return (
window as unknown as {
authentik: GlobalAuthentik;
}
).authentik;
}
class SimpleFlowExecutor {
challenge?: ChallengeTypes;
flowSlug: string;
container: HTMLDivElement;
constructor(container: HTMLDivElement) {
this.flowSlug = window.location.pathname.split("/")[3];
this.container = container;
}
get apiURL() {
return `${ak().api.base}api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`;
}
start() {
$.ajax({
type: "GET",
url: this.apiURL,
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
});
}
submit(data: { [key: string]: unknown } | FormData) {
$("button[type=submit]").addClass("disabled")
.html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span role="status">Loading...</span>`);
let finalData: { [key: string]: unknown } = {};
if (data instanceof FormData) {
finalData = {};
data.forEach((value, key) => {
finalData[key] = value;
});
} else {
finalData = data;
}
$.ajax({
type: "POST",
url: this.apiURL,
data: JSON.stringify(finalData),
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
contentType: "application/json",
dataType: "json",
});
}
renderChallenge() {
switch (this.challenge?.component) {
case "ak-stage-identification":
new IdentificationStage(this, this.challenge).render();
return;
case "ak-stage-password":
new PasswordStage(this, this.challenge).render();
return;
case "xak-flow-redirect":
new RedirectStage(this, this.challenge).render();
return;
case "ak-stage-autosubmit":
new AutosubmitStage(this, this.challenge).render();
return;
case "ak-stage-authenticator-validate":
new AuthenticatorValidateStage(this, this.challenge).render();
return;
default:
this.container.innerText = "Unsupported stage: " + this.challenge?.component;
return;
}
}
}
export interface FlowInfoChallenge {
flowInfo?: ContextualFlowInfo;
responseErrors?: {
[key: string]: Array<ErrorDetail>;
};
}
class Stage<T extends FlowInfoChallenge> {
constructor(
public executor: SimpleFlowExecutor,
public challenge: T,
) {}
error(fieldName: string) {
if (!this.challenge.responseErrors) {
return [];
}
return this.challenge.responseErrors[fieldName] || [];
}
renderInputError(fieldName: string) {
return `${this.error(fieldName)
.map((error) => {
return `<div class="invalid-feedback">
${error.string}
</div>`;
})
.join("")}`;
}
renderNonFieldErrors() {
return `${this.error("non_field_errors")
.map((error) => {
return `<div class="alert alert-danger" role="alert">
${error.string}
</div>`;
})
.join("")}`;
}
html(html: string) {
this.executor.container.innerHTML = html;
}
render() {
throw new Error("Abstract method");
}
}
const IS_INVALID = "is-invalid";
class IdentificationStage extends Stage<IdentificationChallenge> {
render() {
this.html(`
<form id="ident-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>
${
this.challenge.applicationPre
? `<p>
Log in to continue to ${this.challenge.applicationPre}.
</p>`
: ""
}
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username">
</div>
${
this.challenge.passwordFields
? `<div class="form-label-group my-3 has-validation">
<input type="password" class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>`
: ""
}
${this.renderNonFieldErrors()}
<button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button>
</form>`);
$("#ident-form input[name=uid_field]").trigger("focus");
$("#ident-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
}
class PasswordStage extends Stage<PasswordChallenge> {
render() {
this.html(`
<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 has-validation">
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? IS_INVALID : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#password-form input").trigger("focus");
$("#password-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
}
class RedirectStage extends Stage<RedirectChallenge> {
render() {
window.location.assign(this.challenge.to);
}
}
class AutosubmitStage extends Stage<AutosubmitChallenge> {
render() {
this.html(`
<form id="autosubmit-form" action="${this.challenge.url}" method="POST">
<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>
${Object.entries(this.challenge.attrs).map(([key, value]) => {
return `<input
type="hidden"
name="${key}"
value="${value}"
/>`;
})}
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>`);
$("#autosubmit-form").submit();
}
}
export interface Assertion {
id: string;
rawId: string;
type: string;
registrationClientExtensions: string;
response: {
clientDataJSON: string;
attestationObject: string;
};
}
export interface AuthAssertion {
id: string;
rawId: string;
type: string;
assertionClientExtensions: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
}
class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge> {
deviceChallenge?: DeviceChallenge;
b64enc(buf: Uint8Array): string {
return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
b64RawEnc(buf: Uint8Array): string {
return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
}
u8arr(input: string): Uint8Array {
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
c.charCodeAt(0),
);
}
checkWebAuthnSupport(): boolean {
if ("credentials" in navigator) {
return true;
}
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
console.warn("WebAuthn requires this page to be accessed via HTTPS.");
return false;
}
console.warn("WebAuthn not supported by browser.");
return false;
}
/**
* Transforms items in the credentialCreateOptions generated on the server
* into byte arrays expected by the navigator.credentials.create() call
*/
transformCredentialCreateOptions(
credentialCreateOptions: PublicKeyCredentialCreationOptions,
userId: string,
): PublicKeyCredentialCreationOptions {
const user = credentialCreateOptions.user;
// Because json can't contain raw bytes, the server base64-encodes the User ID
// So to get the base64 encoded byte array, we first need to convert it to a regular
// string, then a byte array, re-encode it and wrap that in an array.
const stringId = decodeURIComponent(window.atob(userId));
user.id = this.u8arr(this.b64enc(this.u8arr(stringId)));
const challenge = this.u8arr(credentialCreateOptions.challenge.toString());
return Object.assign({}, credentialCreateOptions, {
challenge,
user,
});
}
/**
* Transforms the binary data in the credential into base64 strings
* for posting to the server.
* @param {PublicKeyCredential} newAssertion
*/
transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
const attObj = new Uint8Array(
(newAssertion.response as AuthenticatorAttestationResponse).attestationObject,
);
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const registrationClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: this.b64enc(rawId),
type: newAssertion.type,
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
response: {
clientDataJSON: this.b64enc(clientDataJSON),
attestationObject: this.b64enc(attObj),
},
};
}
transformCredentialRequestOptions(
credentialRequestOptions: PublicKeyCredentialRequestOptions,
): PublicKeyCredentialRequestOptions {
const challenge = this.u8arr(credentialRequestOptions.challenge.toString());
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
(credentialDescriptor) => {
const id = this.u8arr(credentialDescriptor.id.toString());
return Object.assign({}, credentialDescriptor, { id });
},
);
return Object.assign({}, credentialRequestOptions, {
challenge,
allowCredentials,
});
}
/**
* Encodes the binary data in the assertion into strings for posting to the server.
* @param {PublicKeyCredential} newAssertion
*/
transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion {
const response = newAssertion.response as AuthenticatorAssertionResponse;
const authData = new Uint8Array(response.authenticatorData);
const clientDataJSON = new Uint8Array(response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const sig = new Uint8Array(response.signature);
const assertionClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: this.b64enc(rawId),
type: newAssertion.type,
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
response: {
clientDataJSON: this.b64RawEnc(clientDataJSON),
signature: this.b64RawEnc(sig),
authenticatorData: this.b64RawEnc(authData),
userHandle: null,
},
};
}
render() {
if (!this.deviceChallenge) {
return this.renderChallengePicker();
}
switch (this.deviceChallenge.deviceClass) {
case "static":
case "totp":
this.renderCodeInput();
break;
case "webauthn":
this.renderWebauthn();
break;
default:
break;
}
}
renderChallengePicker() {
const challenges = this.challenge.deviceChallenges.filter((challenge) =>
challenge.deviceClass === "webauthn" && !this.checkWebAuthnSupport()
? undefined
: challenge,
);
this.html(`<form id="picker-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>
${
challenges.length > 0
? "<p>Select an authentication method.</p>"
: `
<p>No compatible authentication method available</p>
`
}
${challenges
.map((challenge) => {
let label = undefined;
switch (challenge.deviceClass) {
case "static":
label = "Recovery keys";
break;
case "totp":
label = "Traditional authenticator";
break;
case "webauthn":
label = "Security key";
break;
}
if (!label) {
return "";
}
return `<div class="form-label-group my-3 has-validation">
<button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button">
${label}
</button>
</div>`;
})
.join("")}
</form>`);
this.challenge.deviceChallenges.forEach((challenge) => {
$(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on(
"click",
() => {
this.deviceChallenge = challenge;
this.render();
},
);
});
}
renderCodeInput() {
this.html(`
<form id="totp-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 has-validation">
<input type="text" autofocus class="form-control ${this.error("code").length > 0 ? IS_INVALID : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code">
${this.renderInputError("code")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#totp-form input").trigger("focus");
$("#totp-form").on("submit", (ev) => {
ev.preventDefault();
const data = new FormData(ev.target as HTMLFormElement);
this.executor.submit(data);
});
}
renderWebauthn() {
this.html(`
<form id="totp-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="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>
`);
navigator.credentials
.get({
publicKey: this.transformCredentialRequestOptions(
this.deviceChallenge?.challenge as PublicKeyCredentialRequestOptions,
),
})
.then((assertion) => {
if (!assertion) {
throw new Error("No assertion");
}
try {
// we now have an authentication assertion! encode the byte arrays contained
// in the assertion data as strings for posting to the server
const transformedAssertionForServer = this.transformAssertionForServer(
assertion as PublicKeyCredential,
);
// post the assertion to the server for verification.
this.executor.submit({
webauthn: transformedAssertionForServer,
});
} catch (err) {
throw new Error(`Error when validating assertion on server: ${err}`);
}
})
.catch((error) => {
console.warn(error);
this.deviceChallenge = undefined;
this.render();
});
}
}
const sfe = new SimpleFlowExecutor($("#flow-sfe-container")[0] as HTMLDivElement);
sfe.start();

View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"types": ["jquery"],
"esModuleInterop": true,
"lib": ["DOM", "ES2015", "ES2017"]
}
}

View File

@ -1,25 +0,0 @@
/**
* @file Path constants for the web package.
*/
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* @typedef {'@goauthentik/web'} WebPackageIdentifier
*/
/**
* The root of the web package.
*/
export const PackageRoot = /** @type {WebPackageIdentifier} */ (resolve(__dirname));
/**
* Path to the web package's distribution directory.
*
* This is where the built files are located after running the build process.
*/
export const DistDirectory = /** @type {`${WebPackageIdentifier}/dist`} */ (
resolve(__dirname, "dist")
);

View File

@ -1,90 +0,0 @@
/**
* @file Build script for the simplified flow executor (SFE).
*/
import { DistDirectory, PackageRoot } from "@goauthentik/web/paths";
import esbuild from "esbuild";
import copy from "esbuild-plugin-copy";
import { es5Plugin } from "esbuild-plugin-es5";
import { createRequire } from "node:module";
import * as path from "node:path";
/**
* Builds the Simplified Flow Executor bundle.
*
* @remarks
* The output directory and file names are referenced by the backend.
* @see {@link ../../authentik/flows/templates/if/flow-sfe.html}
* @returns {Promise<void>}
*/
async function buildSFE() {
const require = createRequire(import.meta.url);
const sourceDirectory = path.join(PackageRoot, "sfe");
const entryPoint = path.join(sourceDirectory, "main.js");
const outDirectory = path.join(DistDirectory, "sfe");
const bootstrapCSSPath = require.resolve(
path.join("bootstrap", "dist", "css", "bootstrap.min.css"),
);
/**
* @type {esbuild.BuildOptions}
*/
const config = {
tsconfig: path.join(sourceDirectory, "tsconfig.json"),
entryPoints: [entryPoint],
minify: false,
bundle: true,
sourcemap: true,
treeShaking: true,
legalComments: "external",
platform: "browser",
format: "iife",
alias: {
"@swc/helpers": path.dirname(require.resolve("@swc/helpers/package.json")),
},
banner: {
js: [
// ---
"// Simplified Flow Executor (SFE)",
`// Bundled on ${new Date().toISOString()}`,
"// @ts-nocheck",
"",
].join("\n"),
},
plugins: [
copy({
assets: [
{
from: bootstrapCSSPath,
to: outDirectory,
},
],
}),
es5Plugin({
swc: {
jsc: {
loose: false,
externalHelpers: false,
keepClassNames: false,
},
minify: false,
},
}),
],
target: ["es5"],
outdir: outDirectory,
};
esbuild.build(config);
}
buildSFE()
.then(() => {
console.log("Build complete");
})
.catch((error) => {
console.error("Build failed", error);
process.exit(1);
});

View File

@ -1,4 +1,3 @@
import { DistDirectory, PackageRoot } from "@goauthentik/web/paths";
import { execFileSync } from "child_process"; import { execFileSync } from "child_process";
import { deepmerge } from "deepmerge-ts"; import { deepmerge } from "deepmerge-ts";
import esbuild from "esbuild"; import esbuild from "esbuild";
@ -171,7 +170,7 @@ function composeVersionID() {
* @throws {Error} on build failure * @throws {Error} on build failure
*/ */
function createEntryPointOptions([source, dest], overrides = {}) { function createEntryPointOptions([source, dest], overrides = {}) {
const outdir = path.join(DistDirectory, dest); const outdir = path.join(__dirname, "..", "dist", dest);
/** /**
* @type {esbuild.BuildOptions} * @type {esbuild.BuildOptions}
@ -234,7 +233,7 @@ async function doWatch() {
buildObserverPlugin({ buildObserverPlugin({
serverURL, serverURL,
logPrefix: entryPoint[1], logPrefix: entryPoint[1],
relativeRoot: PackageRoot, relativeRoot: path.join(__dirname, ".."),
}), }),
], ],
define: { define: {

View File

@ -1,191 +0,0 @@
/**
* @import { AuthenticatorValidationChallenge, DeviceChallenge } from "@goauthentik/api";
* @import { FlowExecutor } from './Stage.js';
*/
import {
isWebAuthnSupported,
transformAssertionForServer,
transformCredentialRequestOptions,
} from "@goauthentik/web/authentication";
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
//@ts-check
/**
* @template {AuthenticatorValidationChallenge} T
* @extends {Stage<T>}
*/
export class AuthenticatorValidateStage extends Stage {
/**
* @param {FlowExecutor} executor - The executor for this stage
* @param {T} challenge - The challenge for this stage
*/
constructor(executor, challenge) {
super(executor, challenge);
/**
* @type {DeviceChallenge | null}
*/
this.deviceChallenge = null;
}
render() {
if (!this.deviceChallenge) {
this.renderChallengePicker();
return;
}
switch (this.deviceChallenge.deviceClass) {
case "static":
case "totp":
this.renderCodeInput();
break;
case "webauthn":
this.renderWebauthn();
break;
default:
break;
}
}
/**
* @private
*/
renderChallengePicker() {
const challenges = this.challenge.deviceChallenges.filter((challenge) =>
challenge.deviceClass === "webauthn" && !isWebAuthnSupported() ? undefined : challenge,
);
this.html(/* html */ `<form id="picker-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>
${
challenges.length > 0
? /* html */ `<p>Select an authentication method.</p>`
: /* html */ `<p>No compatible authentication method available</p>`
}
${challenges
.map((challenge) => {
let label = undefined;
switch (challenge.deviceClass) {
case "static":
label = "Recovery keys";
break;
case "totp":
label = "Traditional authenticator";
break;
case "webauthn":
label = "Security key";
break;
}
if (!label) return "";
return /* html */ `<div class="form-label-group my-3 has-validation">
<button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button">
${label}
</button>
</div>`;
})
.join("")}
</form>`);
this.challenge.deviceChallenges.forEach((challenge) => {
$(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on(
"click",
() => {
this.deviceChallenge = challenge;
this.render();
},
);
});
}
/**
* @private
*/
renderCodeInput() {
this.html(/* html */ `
<form id="totp-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 has-validation">
<input type="text" autofocus class="form-control ${this.error("code").length > 0 ? "is-invalid" : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code">
${this.renderInputError("code")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#totp-form input").trigger("focus");
$("#totp-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
/**
* @private
*/
renderWebauthn() {
this.html(/* html */ `
<form id="totp-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="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>
`);
const challenge = /** @type {PublicKeyCredentialRequestOptions} */ (
this.deviceChallenge?.challenge
);
navigator.credentials
.get({
publicKey: transformCredentialRequestOptions(challenge),
})
.then((credential) => {
if (!credential) {
throw new Error("No assertion");
}
if (credential.type !== "public-key") {
throw new Error("Invalid assertion type");
}
try {
// We now have an authentication assertion!
// Encode the byte arrays contained in the assertion data as strings
// for posting to the server.
const transformedAssertionForServer = transformAssertionForServer(
/** @type {PublicKeyCredential} */ (credential),
);
// Post the assertion to the server for verification.
this.executor.submit({
webauthn: transformedAssertionForServer,
});
} catch (err) {
throw new Error(`Error when validating assertion on server: ${err}`);
}
})
.catch((error) => {
console.warn(error);
this.deviceChallenge = null;
this.render();
});
}
}

View File

@ -1,35 +0,0 @@
/**
* @import { AutosubmitChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {AutosubmitChallenge} T
* @extends {Stage<T>}
*/
export class AutosubmitStage extends Stage {
render() {
this.html(/* html */ `
<form id="autosubmit-form" action="${this.challenge.url}" method="POST">
<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>
${Object.entries(this.challenge.attrs).map(([key, value]) => {
return /* html */ `<input
type="hidden"
name="${key}"
value="${value}"
/>`;
})}
<div class="d-flex justify-content-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</form>`);
$("#autosubmit-form").submit();
}
}

View File

@ -1,50 +0,0 @@
/**
* @import { IdentificationChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {IdentificationChallenge} T
* @extends {Stage<T>}
*/
export class IdentificationStage extends Stage {
render() {
this.html(/* html */ `
<form id="ident-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>
${
this.challenge.applicationPre
? /* html */ `<p>
Log in to continue to ${this.challenge.applicationPre}.
</p>`
: ""
}
<div class="form-label-group my-3 has-validation">
<input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username">
</div>
${
this.challenge.passwordFields
? /* html */ `<div class="form-label-group my-3 has-validation">
<input type="password" class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>`
: ""
}
${this.renderNonFieldErrors()}
<button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button>
</form>`);
$("#ident-form input[name=uid_field]").trigger("focus");
$("#ident-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
}

View File

@ -1,37 +0,0 @@
/**
* @import { PasswordChallenge } from "@goauthentik/api";
*/
import $ from "jquery";
import { Stage } from "./Stage.js";
import { ak } from "./utils.js";
/**
* @template {PasswordChallenge} T
* @extends {Stage<T>}
*/
export class PasswordStage extends Stage {
render() {
this.html(/* html */ `
<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 has-validation">
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
${this.renderInputError("password")}
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
</form>`);
$("#password-form input").trigger("focus");
$("#password-form").on("submit", (ev) => {
ev.preventDefault();
const target = /** @type {HTMLFormElement} */ (ev.target);
const data = new FormData(target);
this.executor.submit(data);
});
}
}

View File

@ -1,14 +0,0 @@
/**
* @import { RedirectChallenge } from "@goauthentik/api";
*/
import { Stage } from "./Stage.js";
/**
* @template {RedirectChallenge} T
* @extends {Stage<T>}
*/
export class RedirectStage extends Stage {
render() {
window.location.assign(this.challenge.to);
}
}

View File

@ -1,113 +0,0 @@
/**
* @import { ChallengeTypes } from "@goauthentik/api";
* @import { FlowExecutor } from './Stage.js';
*/
import $ from "jquery";
import { ChallengeTypesFromJSON } from "@goauthentik/api";
import { AuthenticatorValidateStage } from "./AuthenticatorValidateStage.js";
import { AutosubmitStage } from "./AutosubmitStage.js";
import { IdentificationStage } from "./IdentificationStage.js";
import { PasswordStage } from "./PasswordStage.js";
import { RedirectStage } from "./RedirectStage.js";
import { ak } from "./utils.js";
/**
* Simple Flow Executor lifecycle.
*
* @implements {FlowExecutor}
*/
export class SimpleFlowExecutor {
/**
*
* @param {HTMLDivElement} container
*/
constructor(container) {
/**
* @type {ChallengeTypes | null} The current challenge.
*/
this.challenge = null;
/**
* @type {string} The flow slug.
*/
this.flowSlug = window.location.pathname.split("/")[3] || "";
/**
* @type {HTMLDivElement} The container element for the flow executor.
*/
this.container = container;
}
get apiURL() {
return `${ak().api.base}api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`;
}
start() {
$.ajax({
type: "GET",
url: this.apiURL,
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
});
}
/**
* Submits the form data.
* @param {Record<string, unknown> | FormData} payload
*/
submit(payload) {
$("button[type=submit]").addClass("disabled")
.html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span role="status">Loading...</span>`);
/**
* @type {Record<string, unknown>}
*/
let finalData;
if (payload instanceof FormData) {
finalData = {};
payload.forEach((value, key) => {
finalData[key] = value;
});
} else {
finalData = payload;
}
$.ajax({
type: "POST",
url: this.apiURL,
data: JSON.stringify(finalData),
success: (data) => {
this.challenge = ChallengeTypesFromJSON(data);
this.renderChallenge();
},
contentType: "application/json",
dataType: "json",
});
}
/**
* @returns {void}
*/
renderChallenge() {
switch (this.challenge?.component) {
case "ak-stage-identification":
return new IdentificationStage(this, this.challenge).render();
case "ak-stage-password":
return new PasswordStage(this, this.challenge).render();
case "xak-flow-redirect":
return new RedirectStage(this, this.challenge).render();
case "ak-stage-autosubmit":
return new AutosubmitStage(this, this.challenge).render();
case "ak-stage-authenticator-validate":
return new AuthenticatorValidateStage(this, this.challenge).render();
default:
this.container.innerText = `Unsupported stage: ${this.challenge?.component}`;
return;
}
}
}

View File

@ -1,116 +0,0 @@
/**
* @import { ContextualFlowInfo, ErrorDetail } from "@goauthentik/api";
*/
/**
* @typedef {object} FlowInfoChallenge
* @property {ContextualFlowInfo} [flowInfo]
* @property {Record<string, Array<ErrorDetail>>} [responseErrors]
*/
/**
* @abstract
*/
export class FlowExecutor {
constructor() {
/**
* The DOM container element.
*
* @type {HTMLElement}
* @abstract
* @returns {void}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.container;
}
/**
* Submits the form data.
*
* @param {Record<string, unknown> | FormData} data The data to submit.
* @abstract
* @returns {void}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
submit(data) {
throw new Error(`Method 'submit' not implemented in ${this.constructor.name}`);
}
}
/**
* Represents a stage in a flow
* @template {FlowInfoChallenge} T
* @abstract
*/
export class Stage {
/**
* @param {FlowExecutor} executor - The executor for this stage
* @param {T} challenge - The challenge for this stage
*/
constructor(executor, challenge) {
/** @type {FlowExecutor} */
this.executor = executor;
/** @type {T} */
this.challenge = challenge;
}
/**
* @protected
* @param {string} fieldName
*/
error(fieldName) {
if (!this.challenge.responseErrors) {
return [];
}
return this.challenge.responseErrors[fieldName] || [];
}
/**
* @protected
* @param {string} fieldName
* @returns {string}
*/
renderInputError(fieldName) {
return `${this.error(fieldName)
.map((error) => {
return /* html */ `<div class="invalid-feedback">
${error.string}
</div>`;
})
.join("")}`;
}
/**
* @protected
* @returns {string}
*/
renderNonFieldErrors() {
return `${this.error("non_field_errors")
.map((error) => {
return /* html */ `<div class="alert alert-danger" role="alert">
${error.string}
</div>`;
})
.join("")}`;
}
/**
* @protected
* @param {string} innerHTML
* @returns {void}
*/
html(innerHTML) {
this.executor.container.innerHTML = innerHTML;
}
/**
* Renders the stage (must be implemented by subclasses)
*
* @abstract
* @returns {void}
*/
render() {
throw new Error("Abstract method");
}
}

View File

@ -1,12 +0,0 @@
/**
* @file Simplified Flow Executor (SFE) library module.
*/
export * from "./Stage.js";
export * from "./SimpleFlowExecutor.js";
export * from "./AuthenticatorValidateStage.js";
export * from "./AutosubmitStage.js";
export * from "./IdentificationStage.js";
export * from "./PasswordStage.js";
export * from "./RedirectStage.js";
export * from "./utils.js";

View File

@ -1,20 +0,0 @@
/**
* @typedef {object} GlobalAuthentik
* @property {object} brand
* @property {string} brand.branding_logo
* @property {object} api
* @property {string} api.base
*/
/**
* Retrieves the global authentik object from the window.
* @throws {Error} If the object not found
* @returns {GlobalAuthentik}
*/
export function ak() {
if (!("authentik" in window)) {
throw new Error("No authentik object found in window");
}
return /** @type {GlobalAuthentik} */ (window.authentik);
}

View File

@ -1,17 +0,0 @@
/**
* @file Simplified Flow Executor (SFE) entry point.
*/
import "formdata-polyfill";
import $ from "jquery";
import { SimpleFlowExecutor } from "./lib/index.js";
const flowContainer = /** @type {HTMLDivElement} */ ($("#flow-sfe-container")[0]);
if (!flowContainer) {
throw new Error("No flow container element found");
}
const sfe = new SimpleFlowExecutor(flowContainer);
sfe.start();

View File

@ -1,46 +0,0 @@
{
// TODO: Replace with @goauthentik/tsconfig after project compilation.
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"paths": {
"@goauthentik/web/authentication": ["../authentication/index.js"]
},
"alwaysStrict": true,
"baseUrl": ".",
"rootDir": "../",
"composite": true,
"declaration": true,
"allowJs": true,
"declarationMap": true,
"isolatedModules": true,
"incremental": true,
"emitDeclarationOnly": true,
"esModuleInterop": true,
"lib": ["DOM", "ES2015", "ES2017"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"newLine": "lf",
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": false,
"outDir": "${configDir}/out",
"pretty": true,
"skipDefaultLibCheck": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"target": "ESNext",
"useUnknownInCatchVariables": true
},
"exclude": [
// ---
"./out/**/*",
"./dist/**/*"
],
"include": [
// ---
"./**/*.js",
"../authentication/**/*.js"
]
}

View File

@ -19,7 +19,7 @@ import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthent
@customElement("ak-about-modal") @customElement("ak-about-modal")
export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) { export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) {
static get styles() { static get styles() {
return ModalButton.styles.concat( return super.styles.concat(
PFAbout, PFAbout,
css` css`
.pf-c-about-modal-box__hero { .pf-c-about-modal-box__hero {

View File

@ -16,8 +16,8 @@ import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/notifications/APIDrawer"; import "@goauthentik/elements/notifications/APIDrawer";
import "@goauthentik/elements/notifications/NotificationDrawer"; import "@goauthentik/elements/notifications/NotificationDrawer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import "@goauthentik/elements/router/RouterOutlet"; import "@goauthentik/elements/router/RouterOutlet";
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import "@goauthentik/elements/sidebar/Sidebar"; import "@goauthentik/elements/sidebar/Sidebar";
import "@goauthentik/elements/sidebar/SidebarItem"; import "@goauthentik/elements/sidebar/SidebarItem";
@ -37,10 +37,10 @@ import "./AdminSidebar";
@customElement("ak-interface-admin") @customElement("ak-interface-admin")
export class AdminInterface extends AuthenticatedInterface { export class AdminInterface extends AuthenticatedInterface {
@property({ type: Boolean }) @property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); notificationDrawerOpen = getRouteParameter("notificationDrawerOpen", false);
@property({ type: Boolean }) @property({ type: Boolean })
apiDrawerOpen = getURLParam("apiDrawerOpen", false); apiDrawerOpen = getRouteParameter("apiDrawerOpen", false);
ws: WebsocketClient; ws: WebsocketClient;
@ -93,14 +93,14 @@ export class AdminInterface extends AuthenticatedInterface {
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
this.notificationDrawerOpen = !this.notificationDrawerOpen; this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({ patchRouteParams({
notificationDrawerOpen: this.notificationDrawerOpen, notificationDrawerOpen: this.notificationDrawerOpen,
}); });
}); });
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => { window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
this.apiDrawerOpen = !this.apiDrawerOpen; this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({ patchRouteParams({
apiDrawerOpen: this.apiDrawerOpen, apiDrawerOpen: this.apiDrawerOpen,
}); });
}); });
@ -123,7 +123,7 @@ export class AdminInterface extends AuthenticatedInterface {
super.connectedCallback(); super.connectedCallback();
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) { if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("@goauthentik/common/client"); const { ESBuildObserver } = await import("src/development/build-observer");
new ESBuildObserver(process.env.WATCHER_URL); new ESBuildObserver(process.env.WATCHER_URL);
} }
@ -158,7 +158,7 @@ export class AdminInterface extends AuthenticatedInterface {
class="pf-c-page__main" class="pf-c-page__main"
tabindex="-1" tabindex="-1"
id="main-content" id="main-content"
defaultUrl="/administration/overview" defaultURL="/administration/overview"
.routes=${ROUTES} .routes=${ROUTES}
> >
</ak-router-outlet> </ak-router-outlet>

View File

@ -6,7 +6,7 @@ import {
WithCapabilitiesConfig, WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider"; } from "@goauthentik/elements/Interface/capabilitiesProvider";
import { WithVersion } from "@goauthentik/elements/Interface/versionProvider"; import { WithVersion } from "@goauthentik/elements/Interface/versionProvider";
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router";
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
import { spread } from "@open-wc/lit-helpers"; import { spread } from "@open-wc/lit-helpers";
@ -95,62 +95,127 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
} }
renderSidebarItems(): TemplateResult { renderSidebarItems(): TemplateResult {
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
// commonplace and singular enough to merit its own handler.
type SidebarEntry = [ type SidebarEntry = [
path: string | null, /**
* The pathname to match against. If null, this is a parent item.
*/
pathname: string | null,
/**
* The label to display in the sidebar.
*/
label: string, label: string,
attributes?: Record<string, any> | string[] | null, // eslint-disable-line /**
* The attributes to apply to the sidebar item. This is a map of attribute name to value.
*
* The second attribute type is of string[] to help with the 'activeWhen' control,
* which was commonplace and singular enough to merit its own handler.
*/
attributes?: Record<string, unknown> | string[] | null,
/**
* The children of this sidebar item. This is a recursive structure.
*/
children?: SidebarEntry[], children?: SidebarEntry[],
]; ];
// prettier-ignore
const sidebarContent: SidebarEntry[] = [ const sidebarContent: SidebarEntry[] = [
[null, msg("Dashboards"), { "?expanded": true }, [ // ---
["/administration/overview", msg("Overview")], [
["/administration/dashboard/users", msg("User Statistics")], null,
["/administration/system-tasks", msg("System Tasks")]]], msg("Dashboards"),
[null, msg("Applications"), null, [ { "?expanded": true },
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]], [
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]], ["/administration/overview", msg("Overview")],
["/outpost/outposts", msg("Outposts")]]], ["/administration/dashboard/users", msg("User Statistics")],
[null, msg("Events"), null, [ ["/administration/system-tasks", msg("System Tasks")],
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]], ],
["/events/rules", msg("Notification Rules")], ],
["/events/transports", msg("Notification Transports")]]], [
[null, msg("Customization"), null, [ null,
["/policy/policies", msg("Policies")], msg("Applications"),
["/core/property-mappings", msg("Property Mappings")], null,
["/blueprints/instances", msg("Blueprints")], [
["/policy/reputation", msg("Reputation scores")]]], [
[null, msg("Flows and Stages"), null, [ "/core/applications",
["/flow/flows", msg("Flows"), [`^/flow/flows/(?<slug>${SLUG_REGEX})$`]], msg("Applications"),
["/flow/stages", msg("Stages")], [`/core/applications/:slug(${SLUG_PATTERN})`],
["/flow/stages/prompts", msg("Prompts")]]], ],
[null, msg("Directory"), null, [ ["/core/providers", msg("Providers"), [`/core/providers/:id(${ID_PATTERN})`]],
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]], ["/outpost/outposts", msg("Outposts")],
["/identity/groups", msg("Groups"), [`^/identity/groups/(?<id>${UUID_REGEX})$`]], ],
["/identity/roles", msg("Roles"), [`^/identity/roles/(?<id>${UUID_REGEX})$`]], ],
["/core/sources", msg("Federation and Social login"), [`^/core/sources/(?<slug>${SLUG_REGEX})$`]], [
["/core/tokens", msg("Tokens and App passwords")], null,
["/flow/stages/invitations", msg("Invitations")]]], msg("Events"),
[null, msg("System"), null, [ null,
["/core/brands", msg("Brands")], [
["/crypto/certificates", msg("Certificates")], ["/events/log", msg("Logs"), [`/events/log/:id(${UUID_PATTERN})`]],
["/outpost/integrations", msg("Outpost Integrations")], ["/events/rules", msg("Notification Rules")],
["/admin/settings", msg("Settings")]]], ["/events/transports", msg("Notification Transports")],
],
],
[
null,
msg("Customization"),
null,
[
["/policy/policies", msg("Policies")],
["/core/property-mappings", msg("Property Mappings")],
["/blueprints/instances", msg("Blueprints")],
["/policy/reputation", msg("Reputation scores")],
],
],
[
null,
msg("Flows and Stages"),
null,
[
["/flow/flows", msg("Flows"), [`/flow/flows/:slug(${SLUG_PATTERN})`]],
["/flow/stages", msg("Stages")],
["/flow/stages/prompts", msg("Prompts")],
],
],
[
null,
msg("Directory"),
null,
[
["/identity/users", msg("Users"), [`/identity/users/:id(${ID_PATTERN})`]],
["/identity/groups", msg("Groups"), [`/identity/groups/:id(${UUID_PATTERN})`]],
["/identity/roles", msg("Roles"), [`/identity/roles/:id(${UUID_PATTERN})`]],
[
"/core/sources",
msg("Federation and Social login"),
[`/core/sources/:slug(${SLUG_PATTERN})`],
],
["/core/tokens", msg("Tokens and App passwords")],
["/flow/stages/invitations", msg("Invitations")],
],
],
[
null,
msg("System"),
null,
[
["/core/brands", msg("Brands")],
["/crypto/certificates", msg("Certificates")],
["/outpost/integrations", msg("Outpost Integrations")],
["/admin/settings", msg("Settings")],
],
],
]; ];
// Typescript requires the type here to correctly type the recursive path // Typescript requires the type here to correctly type the recursive path
type SidebarRenderer = (_: SidebarEntry) => TemplateResult; type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => { const renderOneSidebarItem: SidebarRenderer = ([pathname, label, attributes, children]) => {
const properties = Array.isArray(attributes) const properties = Array.isArray(attributes)
? { ".activeWhen": attributes } ? { ".activeWhen": attributes }
: (attributes ?? {}); : (attributes ?? {});
if (path) {
properties.path = path; if (pathname) {
properties.pathname = pathname;
} }
return html`<ak-sidebar-item ${spread(properties)}> return html`<ak-sidebar-item ${spread(properties)}>
${label ? html`<span slot="label">${label}</span>` : nothing} ${label ? html`<span slot="label">${label}</span>` : nothing}
${map(children, renderOneSidebarItem)} ${map(children, renderOneSidebarItem)}

View File

@ -1,5 +1,4 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages"; import { MessageLevel } from "@goauthentik/common/messages";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/PageHeader";
@ -55,12 +54,10 @@ export class DebugPage extends AKElement {
message: "Success", message: "Success",
}); });
}) })
.catch(async (error) => { .catch((exc) => {
const parsedError = await parseAPIResponseError(error);
showMessage({ showMessage({
level: MessageLevel.error, level: MessageLevel.error,
message: pluckErrorDetail(parsedError), message: exc,
}); });
}); });
}} }}

View File

@ -1,155 +1,210 @@
import "@goauthentik/admin/admin-overview/AdminOverviewPage"; import "@goauthentik/admin/admin-overview/AdminOverviewPage";
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; import { Route } from "@goauthentik/elements/router/Route";
import { ID_PATTERN, SLUG_PATTERN, UUID_PATTERN } from "@goauthentik/elements/router/constants";
import { html } from "lit"; import { html } from "lit";
export const ROUTES: Route[] = [ interface IDParameters {
id: string;
}
interface SlugParameters {
slug: string;
}
interface UUIDParameters {
uuid: string;
}
export const ROUTES = [
// Prevent infinite Shell loops // Prevent infinite Shell loops
new Route(new RegExp("^/$")).redirect("/administration/overview"), Route.redirect("^/$", "/administration/overview"),
new Route(new RegExp("^#.*")).redirect("/administration/overview"), Route.redirect("^#.*", "/administration/overview"),
new Route(new RegExp("^/library$")).redirect("/if/user/", true), Route.redirect("^/library$", "/if/user/", true),
// statically imported since this is the default route // statically imported since this is the default route
new Route(new RegExp("^/administration/overview$"), async () => { new Route("/administration/overview", () => {
return html`<ak-admin-overview></ak-admin-overview>`; return html`<ak-admin-overview></ak-admin-overview>`;
}), }),
new Route(new RegExp("^/administration/dashboard/users$"), async () => { new Route("/administration/dashboard/users", async () => {
await import("@goauthentik/admin/admin-overview/DashboardUserPage"); await import("@goauthentik/admin/admin-overview/DashboardUserPage");
return html`<ak-admin-dashboard-users></ak-admin-dashboard-users>`; return html`<ak-admin-dashboard-users></ak-admin-dashboard-users>`;
}), }),
new Route(new RegExp("^/administration/system-tasks$"), async () => { new Route("/administration/system-tasks", async () => {
await import("@goauthentik/admin/system-tasks/SystemTaskListPage"); await import("@goauthentik/admin/system-tasks/SystemTaskListPage");
return html`<ak-system-task-list></ak-system-task-list>`; return html`<ak-system-task-list></ak-system-task-list>`;
}), }),
new Route(new RegExp("^/core/providers$"), async () => { new Route("/core/providers", async () => {
await import("@goauthentik/admin/providers/ProviderListPage"); await import("@goauthentik/admin/providers/ProviderListPage");
return html`<ak-provider-list></ak-provider-list>`; return html`<ak-provider-list></ak-provider-list>`;
}), }),
new Route(new RegExp(`^/core/providers/(?<id>${ID_REGEX})$`), async (args) => { new Route<IDParameters>(
await import("@goauthentik/admin/providers/ProviderViewPage"); new URLPattern({
return html`<ak-provider-view .providerID=${parseInt(args.id, 10)}></ak-provider-view>`; pathname: `/core/providers/:id(${ID_PATTERN})`,
}), }),
new Route(new RegExp("^/core/applications$"), async () => { async (params) => {
await import("@goauthentik/admin/providers/ProviderViewPage");
return html`<ak-provider-view
.providerID=${parseInt(params.id, 10)}
></ak-provider-view>`;
},
),
new Route("/core/applications", async () => {
await import("@goauthentik/admin/applications/ApplicationListPage"); await import("@goauthentik/admin/applications/ApplicationListPage");
return html`<ak-application-list></ak-application-list>`; return html`<ak-application-list></ak-application-list>`;
}), }),
new Route(new RegExp(`^/core/applications/(?<slug>${SLUG_REGEX})$`), async (args) => { new Route(`/core/applications/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/applications/ApplicationViewPage"); await import("@goauthentik/admin/applications/ApplicationViewPage");
return html`<ak-application-view .applicationSlug=${args.slug}></ak-application-view>`;
return html`<ak-application-view .applicationSlug=${slug}></ak-application-view>`;
}), }),
new Route(new RegExp("^/core/sources$"), async () => { new Route("/core/sources", async () => {
await import("@goauthentik/admin/sources/SourceListPage"); await import("@goauthentik/admin/sources/SourceListPage");
return html`<ak-source-list></ak-source-list>`; return html`<ak-source-list></ak-source-list>`;
}), }),
new Route(new RegExp(`^/core/sources/(?<slug>${SLUG_REGEX})$`), async (args) => { new Route(`/core/sources/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/sources/SourceViewPage"); await import("@goauthentik/admin/sources/SourceViewPage");
return html`<ak-source-view .sourceSlug=${args.slug}></ak-source-view>`;
return html`<ak-source-view .sourceSlug=${slug}></ak-source-view>`;
}), }),
new Route(new RegExp("^/core/property-mappings$"), async () => { new Route("/core/property-mappings", async () => {
await import("@goauthentik/admin/property-mappings/PropertyMappingListPage"); await import("@goauthentik/admin/property-mappings/PropertyMappingListPage");
return html`<ak-property-mapping-list></ak-property-mapping-list>`; return html`<ak-property-mapping-list></ak-property-mapping-list>`;
}), }),
new Route(new RegExp("^/core/tokens$"), async () => { new Route("/core/tokens", async () => {
await import("@goauthentik/admin/tokens/TokenListPage"); await import("@goauthentik/admin/tokens/TokenListPage");
return html`<ak-token-list></ak-token-list>`; return html`<ak-token-list></ak-token-list>`;
}), }),
new Route(new RegExp("^/core/brands"), async () => { new Route("/core/brands", async () => {
await import("@goauthentik/admin/brands/BrandListPage"); await import("@goauthentik/admin/brands/BrandListPage");
return html`<ak-brand-list></ak-brand-list>`; return html`<ak-brand-list></ak-brand-list>`;
}), }),
new Route(new RegExp("^/policy/policies$"), async () => { new Route("/policy/policies", async () => {
await import("@goauthentik/admin/policies/PolicyListPage"); await import("@goauthentik/admin/policies/PolicyListPage");
return html`<ak-policy-list></ak-policy-list>`; return html`<ak-policy-list></ak-policy-list>`;
}), }),
new Route(new RegExp("^/policy/reputation$"), async () => { new Route("/policy/reputation", async () => {
await import("@goauthentik/admin/policies/reputation/ReputationListPage"); await import("@goauthentik/admin/policies/reputation/ReputationListPage");
return html`<ak-policy-reputation-list></ak-policy-reputation-list>`; return html`<ak-policy-reputation-list></ak-policy-reputation-list>`;
}), }),
new Route(new RegExp("^/identity/groups$"), async () => { new Route("/identity/groups", async () => {
await import("@goauthentik/admin/groups/GroupListPage"); await import("@goauthentik/admin/groups/GroupListPage");
return html`<ak-group-list></ak-group-list>`; return html`<ak-group-list></ak-group-list>`;
}), }),
new Route(new RegExp(`^/identity/groups/(?<uuid>${UUID_REGEX})$`), async (args) => { new Route<UUIDParameters>(`/identity/groups/:uuid(${UUID_PATTERN})`, async ({ uuid }) => {
await import("@goauthentik/admin/groups/GroupViewPage"); await import("@goauthentik/admin/groups/GroupViewPage");
return html`<ak-group-view .groupId=${args.uuid}></ak-group-view>`;
return html`<ak-group-view .groupId=${uuid}></ak-group-view>`;
}), }),
new Route(new RegExp("^/identity/users$"), async () => { new Route("/identity/users", async () => {
await import("@goauthentik/admin/users/UserListPage"); await import("@goauthentik/admin/users/UserListPage");
return html`<ak-user-list></ak-user-list>`; return html`<ak-user-list></ak-user-list>`;
}), }),
new Route(new RegExp(`^/identity/users/(?<id>${ID_REGEX})$`), async (args) => { new Route<IDParameters>(`/identity/users/:id(${ID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/users/UserViewPage"); await import("@goauthentik/admin/users/UserViewPage");
return html`<ak-user-view .userId=${parseInt(args.id, 10)}></ak-user-view>`;
return html`<ak-user-view .userId=${parseInt(id, 10)}></ak-user-view>`;
}), }),
new Route(new RegExp("^/identity/roles$"), async () => { new Route("/identity/roles", async () => {
await import("@goauthentik/admin/roles/RoleListPage"); await import("@goauthentik/admin/roles/RoleListPage");
return html`<ak-role-list></ak-role-list>`; return html`<ak-role-list></ak-role-list>`;
}), }),
new Route(new RegExp(`^/identity/roles/(?<id>${UUID_REGEX})$`), async (args) => { new Route<IDParameters>(`/identity/roles/:id(${UUID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/roles/RoleViewPage"); await import("@goauthentik/admin/roles/RoleViewPage");
return html`<ak-role-view roleId=${args.id}></ak-role-view>`;
return html`<ak-role-view roleId=${id}></ak-role-view>`;
}), }),
new Route(new RegExp("^/flow/stages/invitations$"), async () => { new Route("/flow/stages/invitations", async () => {
await import("@goauthentik/admin/stages/invitation/InvitationListPage"); await import("@goauthentik/admin/stages/invitation/InvitationListPage");
return html`<ak-stage-invitation-list></ak-stage-invitation-list>`; return html`<ak-stage-invitation-list></ak-stage-invitation-list>`;
}), }),
new Route(new RegExp("^/flow/stages/prompts$"), async () => { new Route("/flow/stages/prompts", async () => {
await import("@goauthentik/admin/stages/prompt/PromptListPage"); await import("@goauthentik/admin/stages/prompt/PromptListPage");
return html`<ak-stage-prompt-list></ak-stage-prompt-list>`; return html`<ak-stage-prompt-list></ak-stage-prompt-list>`;
}), }),
new Route(new RegExp("^/flow/stages$"), async () => { new Route("/flow/stages", async () => {
await import("@goauthentik/admin/stages/StageListPage"); await import("@goauthentik/admin/stages/StageListPage");
return html`<ak-stage-list></ak-stage-list>`; return html`<ak-stage-list></ak-stage-list>`;
}), }),
new Route(new RegExp("^/flow/flows$"), async () => { new Route("/flow/flows", async () => {
await import("@goauthentik/admin/flows/FlowListPage"); await import("@goauthentik/admin/flows/FlowListPage");
return html`<ak-flow-list></ak-flow-list>`; return html`<ak-flow-list></ak-flow-list>`;
}), }),
new Route(new RegExp(`^/flow/flows/(?<slug>${SLUG_REGEX})$`), async (args) => { new Route<SlugParameters>(`/flow/flows/:slug(${SLUG_PATTERN})`, async ({ slug }) => {
await import("@goauthentik/admin/flows/FlowViewPage"); await import("@goauthentik/admin/flows/FlowViewPage");
return html`<ak-flow-view .flowSlug=${args.slug}></ak-flow-view>`;
return html`<ak-flow-view .flowSlug=${slug}></ak-flow-view>`;
}), }),
new Route(new RegExp("^/events/log$"), async () => { new Route("/events/log", async () => {
await import("@goauthentik/admin/events/EventListPage"); await import("@goauthentik/admin/events/EventListPage");
return html`<ak-event-list></ak-event-list>`; return html`<ak-event-list></ak-event-list>`;
}), }),
new Route(new RegExp(`^/events/log/(?<id>${UUID_REGEX})$`), async (args) => { new Route<IDParameters>(`/events/log/:id(${UUID_PATTERN})`, async ({ id }) => {
await import("@goauthentik/admin/events/EventViewPage"); await import("@goauthentik/admin/events/EventViewPage");
return html`<ak-event-view .eventID=${args.id}></ak-event-view>`;
return html`<ak-event-view .eventID=${id}></ak-event-view>`;
}), }),
new Route(new RegExp("^/events/transports$"), async () => { new Route("/events/transports", async () => {
await import("@goauthentik/admin/events/TransportListPage"); await import("@goauthentik/admin/events/TransportListPage");
return html`<ak-event-transport-list></ak-event-transport-list>`; return html`<ak-event-transport-list></ak-event-transport-list>`;
}), }),
new Route(new RegExp("^/events/rules$"), async () => { new Route("/events/rules", async () => {
await import("@goauthentik/admin/events/RuleListPage"); await import("@goauthentik/admin/events/RuleListPage");
return html`<ak-event-rule-list></ak-event-rule-list>`; return html`<ak-event-rule-list></ak-event-rule-list>`;
}), }),
new Route(new RegExp("^/outpost/outposts$"), async () => { new Route("/outpost/outposts", async () => {
await import("@goauthentik/admin/outposts/OutpostListPage"); await import("@goauthentik/admin/outposts/OutpostListPage");
return html`<ak-outpost-list></ak-outpost-list>`; return html`<ak-outpost-list></ak-outpost-list>`;
}), }),
new Route(new RegExp("^/outpost/integrations$"), async () => { new Route("/outpost/integrations", async () => {
await import("@goauthentik/admin/outposts/ServiceConnectionListPage"); await import("@goauthentik/admin/outposts/ServiceConnectionListPage");
return html`<ak-outpost-service-connection-list></ak-outpost-service-connection-list>`; return html`<ak-outpost-service-connection-list></ak-outpost-service-connection-list>`;
}), }),
new Route(new RegExp("^/crypto/certificates$"), async () => { new Route("/crypto/certificates", async () => {
await import("@goauthentik/admin/crypto/CertificateKeyPairListPage"); await import("@goauthentik/admin/crypto/CertificateKeyPairListPage");
return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`; return html`<ak-crypto-certificate-list></ak-crypto-certificate-list>`;
}), }),
new Route(new RegExp("^/admin/settings$"), async () => { new Route("/admin/settings", async () => {
await import("@goauthentik/admin/admin-settings/AdminSettingsPage"); await import("@goauthentik/admin/admin-settings/AdminSettingsPage");
return html`<ak-admin-settings></ak-admin-settings>`; return html`<ak-admin-settings></ak-admin-settings>`;
}), }),
new Route(new RegExp("^/blueprints/instances$"), async () => { new Route("/blueprints/instances", async () => {
await import("@goauthentik/admin/blueprints/BlueprintListPage"); await import("@goauthentik/admin/blueprints/BlueprintListPage");
return html`<ak-blueprint-list></ak-blueprint-list>`; return html`<ak-blueprint-list></ak-blueprint-list>`;
}), }),
new Route(new RegExp("^/debug$"), async () => { new Route("/debug", async () => {
await import("@goauthentik/admin/DebugPage"); await import("@goauthentik/admin/DebugPage");
return html`<ak-admin-debug-page></ak-admin-debug-page>`; return html`<ak-admin-debug-page></ak-admin-debug-page>`;
}), }),
new Route(new RegExp("^/enterprise/licenses$"), async () => { new Route("/enterprise/licenses", async () => {
await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage"); await import("@goauthentik/admin/enterprise/EnterpriseLicenseListPage");
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`; return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
}), }),
]; ] satisfies Route<never>[];

View File

@ -16,7 +16,7 @@ import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/cards/AggregatePromiseCard"; import "@goauthentik/elements/cards/AggregatePromiseCard";
import "@goauthentik/elements/cards/QuickActionsCard.js"; import "@goauthentik/elements/cards/QuickActionsCard.js";
import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js"; import type { QuickAction } from "@goauthentik/elements/cards/QuickActionsCard.js";
import { paramURL } from "@goauthentik/elements/router/RouterOutlet"; import { formatRouteHash } from "@goauthentik/elements/router";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, TemplateResult, css, html, nothing } from "lit";
@ -79,10 +79,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
} }
quickActions: QuickAction[] = [ quickActions: QuickAction[] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })], [
[msg("Check the logs"), paramURL("/events/log")], msg("Create a new application"),
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true], [msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), paramURL("/identity/users")], [msg("Manage users"), formatRouteHash("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true], [msg("Check the release notes"), `https://goauthentik.io/docs/releases/${RELEASE}`, true],
]; ];
@ -195,10 +198,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`; const release = `${versionFamily()}#fixed-in-${VERSION.replaceAll(".", "")}`;
const quickActions: [string, string][] = [ const quickActions: [string, string][] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })], [
[msg("Check the logs"), paramURL("/events/log")], msg("Create a new application"),
formatRouteHash("/core/applications", { createForm: true }),
],
[msg("Check the logs"), formatRouteHash("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/"], [msg("Explore integrations"), "https://goauthentik.io/integrations/"],
[msg("Manage users"), paramURL("/identity/users")], [msg("Manage users"), formatRouteHash("/identity/users")],
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`], [msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`],
]; ];

View File

@ -1,16 +1,13 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { PFSize } from "@goauthentik/common/enums.js"; import { PFSize } from "@goauthentik/common/enums.js";
import {
APIError,
parseAPIResponseError,
pluckErrorDetail,
} from "@goauthentik/common/errors/network";
import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard"; import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { PropertyValues, TemplateResult, html, nothing } from "lit"; import { PropertyValues, TemplateResult, html, nothing } from "lit";
import { state } from "lit/decorators.js"; import { state } from "lit/decorators.js";
import { ResponseError } from "@goauthentik/api";
export interface AdminStatus { export interface AdminStatus {
icon: string; icon: string;
message?: TemplateResult; message?: TemplateResult;
@ -32,7 +29,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
// Current error state if any request fails // Current error state if any request fails
@state() @state()
protected error?: APIError; protected error?: string;
// Abstract methods to be implemented by subclasses // Abstract methods to be implemented by subclasses
abstract getPrimaryValue(): Promise<T>; abstract getPrimaryValue(): Promise<T>;
@ -62,9 +59,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.value = value; // Triggers shouldUpdate this.value = value; // Triggers shouldUpdate
this.error = undefined; this.error = undefined;
}) })
.catch(async (error: unknown) => { .catch((err: ResponseError) => {
this.status = undefined; this.status = undefined;
this.error = await parseAPIResponseError(error); this.error = err?.response?.statusText ?? msg("Unknown error");
}); });
} }
@ -82,9 +79,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.status = status; this.status = status;
this.error = undefined; this.error = undefined;
}) })
.catch(async (error: unknown) => { .catch((err: ResponseError) => {
this.status = undefined; this.status = undefined;
this.error = await parseAPIResponseError(error); this.error = err?.response?.statusText ?? msg("Unknown error");
}); });
// Prevent immediate re-render if only value changed // Prevent immediate re-render if only value changed
@ -123,8 +120,8 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
*/ */
private renderError(error: string): TemplateResult { private renderError(error: string): TemplateResult {
return html` return html`
<p><i class="fa fa-times"></i>&nbsp;${msg("Failed to fetch")}</p> <p><i class="fa fa-times"></i>&nbsp;${error}</p>
<p class="subtext">${error}</p> <p class="subtext">${msg("Failed to fetch")}</p>
`; `;
} }
@ -149,7 +146,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
this.status this.status
? this.renderStatus(this.status) // Status available ? this.renderStatus(this.status) // Status available
: this.error : this.error
? this.renderError(pluckErrorDetail(this.error)) // Error state ? this.renderError(this.error) // Error state
: this.renderLoading() // Loading state : this.renderLoading() // Loading state
} }
</p> </p>

View File

@ -10,7 +10,6 @@ import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table"; import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
@ -69,7 +68,7 @@ export class RecentEventsCard extends Table<Event> {
</div>`; </div>`;
} }
row(item: EventWithContext): SlottedTemplateResult[] { row(item: EventWithContext): TemplateResult[] {
return [ return [
html`<div><a href="${`#/events/log/${item.pk}`}">${actionToLabel(item.action)}</a></div> html`<div><a href="${`#/events/log/${item.pk}`}">${actionToLabel(item.action)}</a></div>
<small>${item.app}</small>`, <small>${item.app}</small>`,
@ -82,11 +81,7 @@ export class RecentEventsCard extends Table<Event> {
]; ];
} }
renderEmpty(inner?: SlottedTemplateResult): TemplateResult { renderEmpty(): TemplateResult {
if (this.error) {
return super.renderEmpty(inner);
}
return super.renderEmpty( return super.renderEmpty(
html`<ak-empty-state header=${msg("No Events found.")}> html`<ak-empty-state header=${msg("No Events found.")}>
<div slot="body">${msg("No matching events could be found.")}</div> <div slot="body">${msg("No matching events could be found.")}</div>

View File

@ -7,7 +7,7 @@ import "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch"; import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -71,7 +71,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
} }
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return TablePage.styles.concat(PFCard, applicationListStyle); return super.styles.concat(PFCard, applicationListStyle);
} }
columns(): TableColumn[] { columns(): TableColumn[] {
@ -156,7 +156,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
} }
renderObjectCreate(): TemplateResult { renderObjectCreate(): TemplateResult {
return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}> return html` <ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
<button <button
slot="trigger" slot="trigger"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
@ -165,7 +165,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
${msg("Create with Provider")} ${msg("Create with Provider")}
</button> </button>
</ak-application-wizard> </ak-application-wizard>
<ak-forms-modal .open=${getURLParam("createForm", false)}> <ak-forms-modal .open=${getRouteParameter("createForm", false)}>
<span slot="submit"> ${msg("Create")} </span> <span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Application")} </span> <span slot="header"> ${msg("Create Application")} </span>
<ak-application-form slot="form"> </ak-application-form> <ak-application-form slot="form"> </ak-application-form>

View File

@ -8,7 +8,7 @@ import "@goauthentik/components/ak-hint/ak-hint-body";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Label"; import "@goauthentik/elements/Label";
import "@goauthentik/elements/buttons/ActionButton/ak-action-button"; import "@goauthentik/elements/buttons/ActionButton/ak-action-button";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch"; import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { css, html } from "lit"; import { css, html } from "lit";
@ -110,7 +110,7 @@ export class AkApplicationWizardHint extends AKElement implements ShowHintContro
the same time with our new Application Wizard. the same time with our new Application Wizard.
<!-- <a href="(link to docs)">Learn more about the wizard here.</a> --> <!-- <a href="(link to docs)">Learn more about the wizard here.</a> -->
</p> </p>
<ak-application-wizard .open=${getURLParam("createWizard", false)}> <ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
<button <button
slot="trigger" slot="trigger"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"

View File

@ -1,7 +1,7 @@
import { styles } from "@goauthentik/admin/applications/wizard/ApplicationWizardFormStepStyles.css.js"; import { styles } from "@goauthentik/admin/applications/wizard/ApplicationWizardFormStepStyles.css.js";
import { WizardStep } from "@goauthentik/components/ak-wizard/WizardStep.js"; import { WizardStep } from "@goauthentik/components/ak-wizard/WizardStep.js";
import { import {
NavigationEventInit, NavigationUpdate,
WizardNavigationEvent, WizardNavigationEvent,
WizardUpdateEvent, WizardUpdateEvent,
} from "@goauthentik/components/ak-wizard/events"; } from "@goauthentik/components/ak-wizard/events";
@ -14,9 +14,9 @@ import { property, query } from "lit/decorators.js";
import { ValidationError } from "@goauthentik/api"; import { ValidationError } from "@goauthentik/api";
import { import {
ApplicationTransactionValidationError,
type ApplicationWizardState, type ApplicationWizardState,
type ApplicationWizardStateUpdate, type ApplicationWizardStateUpdate,
ExtendedValidationError,
} from "./types"; } from "./types";
export class ApplicationWizardStep extends WizardStep { export class ApplicationWizardStep extends WizardStep {
@ -48,7 +48,7 @@ export class ApplicationWizardStep extends WizardStep {
} }
protected removeErrors( protected removeErrors(
keyToDelete: keyof ApplicationTransactionValidationError, keyToDelete: keyof ExtendedValidationError,
): ValidationError | undefined { ): ValidationError | undefined {
if (!this.wizard.errors) { if (!this.wizard.errors) {
return undefined; return undefined;
@ -71,7 +71,7 @@ export class ApplicationWizardStep extends WizardStep {
public handleUpdate( public handleUpdate(
update?: ApplicationWizardStateUpdate, update?: ApplicationWizardStateUpdate,
destination?: string, destination?: string,
enable?: NavigationEventInit, enable?: NavigationUpdate,
) { ) {
// Inform ApplicationWizard of content state // Inform ApplicationWizard of content state
if (update) { if (update) {

View File

@ -1,7 +1,6 @@
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js"; import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js"; import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js"; import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { isSlug } from "@goauthentik/common/utils.js";
import { camelToSnake } from "@goauthentik/common/utils.js"; import { camelToSnake } from "@goauthentik/common/utils.js";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input"; import "@goauthentik/components/ak-slug-input";
@ -11,6 +10,7 @@ import { type NavigableButton, type WizardButton } from "@goauthentik/components
import { type KeyUnknown } from "@goauthentik/elements/forms/Form"; import { type KeyUnknown } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { isSlug } from "@goauthentik/elements/router";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { html } from "lit"; import { html } from "lit";

View File

@ -1,10 +1,9 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js"; import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIResponseError } from "@goauthentik/common/errors/network"; import { parseAPIError } from "@goauthentik/common/errors";
import { WizardNavigationEvent } from "@goauthentik/components/ak-wizard/events.js"; import { WizardNavigationEvent } from "@goauthentik/components/ak-wizard/events.js";
import { type WizardButton } from "@goauthentik/components/ak-wizard/types"; import { type WizardButton } from "@goauthentik/components/ak-wizard/types";
import { showAPIErrorMessage } from "@goauthentik/elements/messages/MessageContainer";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { P, match } from "ts-pattern"; import { P, match } from "ts-pattern";
@ -31,11 +30,10 @@ import {
type TransactionApplicationRequest, type TransactionApplicationRequest,
type TransactionApplicationResponse, type TransactionApplicationResponse,
type TransactionPolicyBindingRequest, type TransactionPolicyBindingRequest,
instanceOfValidationError,
} from "@goauthentik/api"; } from "@goauthentik/api";
import { ApplicationWizardStep } from "../ApplicationWizardStep.js"; import { ApplicationWizardStep } from "../ApplicationWizardStep.js";
import { OneOfProvider, isApplicationTransactionValidationError } from "../types.js"; import { ExtendedValidationError, OneOfProvider } from "../types.js";
import { providerRenderers } from "./SubmitStepOverviewRenderers.js"; import { providerRenderers } from "./SubmitStepOverviewRenderers.js";
const _submitStates = ["reviewing", "running", "submitted"] as const; const _submitStates = ["reviewing", "running", "submitted"] as const;
@ -133,46 +131,39 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
this.state = "running"; this.state = "running";
return new CoreApi(DEFAULT_CONFIG) return (
.coreTransactionalApplicationsUpdate({ new CoreApi(DEFAULT_CONFIG)
transactionApplicationRequest: request, .coreTransactionalApplicationsUpdate({
}) transactionApplicationRequest: request,
.then((_response: TransactionApplicationResponse) => { })
this.dispatchCustomEvent(EVENT_REFRESH); .then((_response: TransactionApplicationResponse) => {
this.state = "submitted"; this.dispatchCustomEvent(EVENT_REFRESH);
}) this.state = "submitted";
})
.catch(async (error) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedError = await parseAPIResponseError(error); .catch(async (resolution: any) => {
const errors = (await parseAPIError(
await resolution,
)) as ExtendedValidationError;
if (!instanceOfValidationError(parsedError)) { // THIS is a really gross special case; if the user is duplicating the name of
showAPIErrorMessage(parsedError); // an existing provider, the error appears on the `app` (!) error object. We
// have to move that to the `provider.name` error field so it shows up in the
return; // right place.
} if (Array.isArray(errors?.app?.provider)) {
const providerError = errors.app.provider;
if (isApplicationTransactionValidationError(parsedError)) { errors.provider = errors.provider ?? {};
// THIS is a really gross special case; if the user is duplicating the name of an existing provider, the error appears on the `app` (!) error object. errors.provider.name = providerError;
// We have to move that to the `provider.name` error field so it shows up in the right place. delete errors.app.provider;
if (Array.isArray(parsedError.app?.provider)) { if (Object.keys(errors.app).length === 0) {
const providerError = parsedError.app.provider; delete errors.app;
parsedError.provider = {
...parsedError.provider,
name: providerError,
};
delete parsedError.app.provider;
if (Object.keys(parsedError.app).length === 0) {
delete parsedError.app;
} }
} }
} this.handleUpdate({ errors });
this.state = "reviewing";
this.handleUpdate({ errors: parsedError }); })
this.state = "reviewing"; );
});
} }
override handleButton(button: WizardButton) { override handleButton(button: WizardButton) {
@ -234,20 +225,22 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
} }
renderError() { renderError() {
const { errors } = this.wizard; if (Object.keys(this.wizard.errors).length === 0) {
return nothing;
if (Object.keys(errors).length === 0) return nothing; }
const navTo = (step: string) => () => this.dispatchEvent(new WizardNavigationEvent(step));
const errors = this.wizard.errors;
return html` <hr class="pf-c-divider" /> return html` <hr class="pf-c-divider" />
${match(errors) ${match(errors as ExtendedValidationError)
.with( .with(
{ app: P.nonNullable }, { app: P.nonNullable },
() => () =>
html`<p>${msg("There was an error in the application.")}</p> html`<p>${msg("There was an error in the application.")}</p>
<p> <p>
<a @click=${WizardNavigationEvent.toListener(this, "application")}> <a @click=${navTo("application")}
${msg("Review the application.")} >${msg("Review the application.")}</a
</a> >
</p>`, </p>`,
) )
.with( .with(
@ -255,20 +248,13 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
() => () =>
html`<p>${msg("There was an error in the provider.")}</p> html`<p>${msg("There was an error in the provider.")}</p>
<p> <p>
<a @click=${WizardNavigationEvent.toListener(this, "provider")} <a @click=${navTo("provider")}>${msg("Review the provider.")}</a>
>${msg("Review the provider.")}</a
>
</p>`, </p>`,
) )
.with( .with(
{ detail: P.nonNullable }, { detail: P.nonNullable },
() => () =>
html`<p> `<p>${msg("There was an error. Please go back and review the application.")}: ${errors.detail}</p>`,
${msg(
"There was an error. Please go back and review the application.",
)}:
${errors.detail}
</p>`,
) )
.with( .with(
{ {
@ -278,7 +264,7 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
html`<p>${msg("There was an error:")}:</p> html`<p>${msg("There was an error:")}:</p>
<ul> <ul>
${(errors.nonFieldErrors ?? []).map( ${(errors.nonFieldErrors ?? []).map(
(reason) => html`<li>${reason}</li>`, (e: string) => html`<li>${e}</li>`,
)} )}
</ul> </ul>
<p>${msg("Please go back and review the application.")}</p>`, <p>${msg("Please go back and review the application.")}</p>`,

View File

@ -9,7 +9,7 @@ import { customElement, state } from "lit/decorators.js";
import { OAuth2ProviderRequest, SourcesApi } from "@goauthentik/api"; import { OAuth2ProviderRequest, SourcesApi } from "@goauthentik/api";
import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api"; import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api";
import { ApplicationTransactionValidationError } from "../../types.js"; import { ExtendedValidationError } from "../../types.js";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js"; import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js";
@customElement("ak-application-wizard-provider-for-oauth") @customElement("ak-application-wizard-provider-for-oauth")
@ -34,7 +34,7 @@ export class ApplicationWizardOauth2ProviderForm extends ApplicationWizardProvid
}); });
} }
renderForm(provider: OAuth2Provider, errors: ApplicationTransactionValidationError) { renderForm(provider: OAuth2Provider, errors: ExtendedValidationError) {
const showClientSecretCallback = (show: boolean) => { const showClientSecretCallback = (show: boolean) => {
this.showClientSecret = show; this.showClientSecret = show;
}; };

View File

@ -25,30 +25,16 @@ export type OneOfProvider =
export type ValidationRecord = { [key: string]: string[] }; export type ValidationRecord = { [key: string]: string[] };
/** // TODO: Elf, extend this type and apply it to every object in the wizard. Then run
* An error that occurs during the creation or modification of an application. // the type-checker again.
*
* @todo (Elf) Extend this type to include all possible errors that can occur during the creation or modification of an application. export type ExtendedValidationError = ValidationError & {
*/
export interface ApplicationTransactionValidationError extends ValidationError {
app?: ValidationRecord; app?: ValidationRecord;
provider?: ValidationRecord; provider?: ValidationRecord;
bindings?: ValidationRecord; bindings?: ValidationRecord;
detail?: unknown; // eslint-disable-next-line @typescript-eslint/no-explicit-any
} detail?: any;
};
/**
* Type-guard to determine if an API response is shaped like an {@linkcode ApplicationTransactionValidationError}.
*/
export function isApplicationTransactionValidationError(
error: ValidationError,
): error is ApplicationTransactionValidationError {
if ("app" in error) return true;
if ("provider" in error) return true;
if ("bindings" in error) return true;
return false;
}
// We use the PolicyBinding instead of the PolicyBindingRequest here, because that gives us a slot // We use the PolicyBinding instead of the PolicyBindingRequest here, because that gives us a slot
// in which to preserve the retrieved policy, group, or user object from the SearchSelect used to // in which to preserve the retrieved policy, group, or user object from the SearchSelect used to
@ -63,7 +49,7 @@ export interface ApplicationWizardState {
proxyMode: ProxyMode; proxyMode: ProxyMode;
bindings: PolicyBinding[]; bindings: PolicyBinding[];
currentBinding: number; currentBinding: number;
errors: ValidationError | ApplicationTransactionValidationError; errors: ExtendedValidationError;
} }
export interface ApplicationWizardStateUpdate { export interface ApplicationWizardStateUpdate {

View File

@ -8,7 +8,6 @@ import "@goauthentik/components/ak-event-info";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@ -73,7 +72,7 @@ export class EventListPage extends TablePage<Event> {
`; `;
} }
row(item: EventWithContext): SlottedTemplateResult[] { row(item: EventWithContext): TemplateResult[] {
return [ return [
html`<div>${actionToLabel(item.action)}</div> html`<div>${actionToLabel(item.action)}</div>
<small>${item.app}</small>`, <small>${item.app}</small>`,

View File

@ -1,31 +1,27 @@
import { EventWithContext } from "@goauthentik/common/events"; import { EventWithContext } from "@goauthentik/common/events";
import { truncate } from "@goauthentik/common/utils"; import { truncate } from "@goauthentik/common/utils";
import { SlottedTemplateResult } from "@goauthentik/elements/types"; import { KeyUnknown } from "@goauthentik/elements/forms/Form";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { html, nothing } from "lit"; import { TemplateResult, html } from "lit";
/** export function EventGeo(event: EventWithContext): TemplateResult {
* Given event with a geographical context, format it into a string for display. let geo: KeyUnknown | undefined = undefined;
*/ if (Object.hasOwn(event.context, "geo")) {
export function EventGeo(event: EventWithContext): SlottedTemplateResult { geo = event.context.geo as KeyUnknown;
if (!event.context.geo) return nothing; const parts = [geo.city, geo.country, geo.continent].filter(
(v) => v !== "" && v !== undefined,
const { city, country, continent } = event.context.geo; );
return html`${parts.join(", ")}`;
const parts = [city, country, continent].filter(Boolean); }
return html``;
return html`${parts.join(", ")}`;
} }
export function EventUser( export function EventUser(event: EventWithContext, truncateUsername?: number): TemplateResult {
event: EventWithContext, if (!event.user.username) {
truncateUsername?: number, return html`-`;
): SlottedTemplateResult { }
if (!event.user.username) return html`-`; let body = html``;
let body: SlottedTemplateResult = nothing;
if (event.user.is_anonymous) { if (event.user.is_anonymous) {
body = html`<div>${msg("Anonymous user")}</div>`; body = html`<div>${msg("Anonymous user")}</div>`;
} else { } else {
@ -37,14 +33,12 @@ export function EventUser(
> >
</div>`; </div>`;
} }
if (event.user.on_behalf_of) { if (event.user.on_behalf_of) {
return html`${body}<small> body = html`${body}<small>
<a href="#/identity/users/${event.user.on_behalf_of.pk}" <a href="#/identity/users/${event.user.on_behalf_of.pk}"
>${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}</a >${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}</a
> >
</small>`; </small>`;
} }
return body; return body;
} }

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/sentry"; import { SentryIgnoredError } from "@goauthentik/common/errors";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
import "@goauthentik/elements/events/LogViewer"; import "@goauthentik/elements/events/LogViewer";
import { Form } from "@goauthentik/elements/forms/Form"; import { Form } from "@goauthentik/elements/forms/Form";

View File

@ -1,7 +1,7 @@
import "@goauthentik/admin/flows/FlowForm"; import "@goauthentik/admin/flows/FlowForm";
import "@goauthentik/admin/flows/FlowImportForm"; import "@goauthentik/admin/flows/FlowImportForm";
import { DesignationToLabel } from "@goauthentik/admin/flows/utils"; import { DesignationToLabel, formatFlowURL } from "@goauthentik/admin/flows/utils";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { groupBy } from "@goauthentik/common/utils"; import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ConfirmationForm"; import "@goauthentik/elements/forms/ConfirmationForm";
@ -107,10 +107,9 @@ export class FlowListPage extends TablePage<Flow> {
<button <button
class="pf-c-button pf-m-plain" class="pf-c-button pf-m-plain"
@click=${() => { @click=${() => {
const finalURL = `${window.location.origin}/if/flow/${item.slug}/${AndNext( const url = formatFlowURL(item);
`${window.location.pathname}#${window.location.hash}`,
)}`; window.open(url, "_blank");
window.open(finalURL, "_blank");
}} }}
> >
<pf-tooltip position="top" content=${msg("Execute")}> <pf-tooltip position="top" content=${msg("Execute")}>

View File

@ -1,11 +1,10 @@
import "@goauthentik/admin/flows/BoundStagesList"; import "@goauthentik/admin/flows/BoundStagesList";
import "@goauthentik/admin/flows/FlowDiagram"; import "@goauthentik/admin/flows/FlowDiagram";
import "@goauthentik/admin/flows/FlowForm"; import "@goauthentik/admin/flows/FlowForm";
import { DesignationToLabel } from "@goauthentik/admin/flows/utils"; import { DesignationToLabel, applyNextParam, formatFlowURL } from "@goauthentik/admin/flows/utils";
import "@goauthentik/admin/policies/BoundPoliciesList"; import "@goauthentik/admin/policies/BoundPoliciesList";
import "@goauthentik/admin/rbac/ObjectPermissionsPage"; import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/PageHeader";
@ -24,7 +23,12 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Flow, FlowsApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api"; import {
Flow,
FlowsApi,
RbacPermissionsAssignedByUsersListModelEnum,
ResponseError,
} from "@goauthentik/api";
@customElement("ak-flow-view") @customElement("ak-flow-view")
export class FlowViewPage extends AKElement { export class FlowViewPage extends AKElement {
@ -147,12 +151,9 @@ export class FlowViewPage extends AKElement {
<button <button
class="pf-c-button pf-m-block pf-m-primary" class="pf-c-button pf-m-block pf-m-primary"
@click=${() => { @click=${() => {
const finalURL = `${ const url = formatFlowURL(this.flow);
window.location.origin
}/if/flow/${this.flow.slug}/${AndNext( window.open(url, "_blank");
`${window.location.pathname}#${window.location.hash}`,
)}`;
window.open(finalURL, "_blank");
}} }}
> >
${msg("Normal")} ${msg("Normal")}
@ -164,12 +165,16 @@ export class FlowViewPage extends AKElement {
.flowsInstancesExecuteRetrieve({ .flowsInstancesExecuteRetrieve({
slug: this.flow.slug, slug: this.flow.slug,
}) })
.then((link) => { .then(({ link }) => {
const finalURL = `${ const finalURL = URL.canParse(link)
link.link ? new URL(link)
}${AndNext( : new URL(
`${window.location.pathname}#${window.location.hash}`, link,
)}`; window.location.origin,
);
applyNextParam(finalURL);
window.open(finalURL, "_blank"); window.open(finalURL, "_blank");
}); });
}} }}
@ -191,15 +196,13 @@ export class FlowViewPage extends AKElement {
)}`; )}`;
window.open(finalURL, "_blank"); window.open(finalURL, "_blank");
}) })
.catch(async (error: unknown) => { .catch((exc: ResponseError) => {
if (isResponseErrorLike(error)) { // This request can return a HTTP 400 when a flow
// This request can return a HTTP 400 when a flow // is not applicable.
// is not applicable. window.open(
window.open( exc.response.url,
error.response.url, "_blank",
"_blank", );
);
}
}); });
}} }}
> >

View File

@ -43,3 +43,51 @@ export function LayoutToLabel(layout: FlowLayoutEnum): string {
return msg("Unknown layout"); return msg("Unknown layout");
} }
} }
/**
* Applies the next URL as a query parameter to the given URL or URLSearchParams object.
*
* @todo deprecate this once hash routing is removed.
*/
export function applyNextParam(
target: URL | URLSearchParams,
destination: string | URL = window.location.pathname + "#" + window.location.hash,
): void {
const searchParams = target instanceof URL ? target.searchParams : target;
searchParams.set("next", destination.toString());
}
/**
* Creates a URLSearchParams object with the next URL as a query parameter.
*
* @todo deprecate this once hash routing is removed.
*/
export function createNextSearchParams(
destination: string | URL = window.location.pathname + "#" + window.location.hash,
): URLSearchParams {
const searchParams = new URLSearchParams();
applyNextParam(searchParams, destination);
return searchParams;
}
/**
* Creates a URL to a flow, with the next URL as a query parameter.
*
* @param flow The flow to create the URL for.
* @param destination The next URL to redirect to after the flow is completed, `true` to use the current route.
*/
export function formatFlowURL(
flow: Flow,
destination: string | URL | null = window.location.pathname + "#" + window.location.hash,
): URL {
const url = new URL(`/if/flow/${flow.slug}/`, window.location.origin);
if (destination) {
applyNextParam(url, destination);
}
return url;
}

View File

@ -6,7 +6,6 @@ import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm"; import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js"; import { PFSize } from "@goauthentik/common/enums.js";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages"; import { MessageLevel } from "@goauthentik/common/messages";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils"; import { getRelativeTime } from "@goauthentik/common/utils";
@ -23,7 +22,7 @@ import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table"; import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { UserOption } from "@goauthentik/elements/user/utils"; import { UserOption } from "@goauthentik/elements/user/utils";
@ -38,7 +37,14 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CoreApi, CoreUsersListTypeEnum, Group, SessionUser, User } from "@goauthentik/api"; import {
CoreApi,
CoreUsersListTypeEnum,
Group,
ResponseError,
SessionUser,
User,
} from "@goauthentik/api";
@customElement("ak-user-related-add") @customElement("ak-user-related-add")
export class RelatedUserAdd extends Form<{ users: number[] }> { export class RelatedUserAdd extends Form<{ users: number[] }> {
@ -121,13 +127,13 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
order = "last_login"; order = "last_login";
@property({ type: Boolean }) @property({ type: Boolean })
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true); hideServiceAccounts = getRouteParameter<boolean>("hideServiceAccounts", true);
@state() @state()
me?: SessionUser; me?: SessionUser;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return Table.styles.concat(PFDescriptionList, PFAlert, PFBanner); return super.styles.concat(PFDescriptionList, PFAlert, PFBanner);
} }
async apiEndpoint(): Promise<PaginatedResponse<User>> { async apiEndpoint(): Promise<PaginatedResponse<User>> {
@ -313,16 +319,14 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
description: rec.link, description: rec.link,
}); });
}) })
.catch(async (error: unknown) => { .catch((ex: ResponseError) => {
const parsedError = ex.response.json().then(() => {
await parseAPIResponseError( showMessage({
error, level: MessageLevel.error,
); message: msg(
"No recovery flow is configured.",
showMessage({ ),
level: MessageLevel.error, });
message:
pluckErrorDetail(parsedError),
}); });
}); });
}} }}
@ -462,7 +466,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
this.hideServiceAccounts = !this.hideServiceAccounts; this.hideServiceAccounts = !this.hideServiceAccounts;
this.page = 1; this.page = 1;
this.fetch(); this.fetch();
updateURLParams({ patchRouteParams({
hideServiceAccounts: this.hideServiceAccounts, hideServiceAccounts: this.hideServiceAccounts,
}); });
}} }}

View File

@ -19,7 +19,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/forms/ProxyForm"; import "@goauthentik/elements/forms/ProxyForm";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -54,7 +54,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
order = "name"; order = "name";
@state() @state()
hideManaged = getURLParam<boolean>("hideManaged", true); hideManaged = getRouteParameter<boolean>("hideManaged", true);
async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> { async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> {
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({ return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({
@ -148,7 +148,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
this.hideManaged = !this.hideManaged; this.hideManaged = !this.hideManaged;
this.page = 1; this.page = 1;
this.fetch(); this.fetch();
updateURLParams({ patchRouteParams({
hideManaged: this.hideManaged, hideManaged: this.hideManaged,
}); });
}} }}

View File

@ -3,7 +3,6 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/rbac/ObjectPermissionsPage"; import "@goauthentik/admin/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { convertToSlug } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import MDCaddyStandalone from "@goauthentik/docs/add-secure-apps/providers/proxy/_caddy_standalone.md"; import MDCaddyStandalone from "@goauthentik/docs/add-secure-apps/providers/proxy/_caddy_standalone.md";
@ -21,7 +20,8 @@ import "@goauthentik/elements/ak-mdx";
import type { Replacer } from "@goauthentik/elements/ak-mdx"; import type { Replacer } from "@goauthentik/elements/ak-mdx";
import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch"; import { formatAsSlug } from "@goauthentik/elements/router";
import { getRouteParameter } from "@goauthentik/elements/router/utils";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit"; import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
@ -156,7 +156,7 @@ export class ProxyProviderViewPage extends AKElement {
(input: string): string => { (input: string): string => {
// The generated config is pretty unreliable currently so // The generated config is pretty unreliable currently so
// put it behind a flag // put it behind a flag
if (!getURLParam("generatedConfig", false)) { if (!getRouteParameter("generatedConfig", false)) {
return input; return input;
} }
if (!this.provider) { if (!this.provider) {
@ -183,7 +183,7 @@ export class ProxyProviderViewPage extends AKElement {
return html`<ak-tabs pageIdentifier="proxy-setup"> return html`<ak-tabs pageIdentifier="proxy-setup">
${servers.map((server) => { ${servers.map((server) => {
return html`<section return html`<section
slot="page-${convertToSlug(server.label)}" slot="page-${formatAsSlug(server.label)}"
data-tab-title="${server.label}" data-tab-title="${server.label}"
class="pf-c-page__main-section pf-m-no-padding-mobile ak-markdown-section" class="pf-c-page__main-section pf-m-no-padding-mobile ak-markdown-section"
> >

View File

@ -1,6 +1,6 @@
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/sentry"; import { SentryIgnoredError } from "@goauthentik/common/errors";
import { Form } from "@goauthentik/elements/forms/Form"; import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/forms/SearchSelect";

View File

@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network"; import { parseAPIError } from "@goauthentik/common/errors";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
@ -21,8 +21,9 @@ import {
Prompt, Prompt,
PromptChallenge, PromptChallenge,
PromptTypeEnum, PromptTypeEnum,
ResponseError,
StagesApi, StagesApi,
instanceOfValidationError, ValidationError,
} from "@goauthentik/api"; } from "@goauthentik/api";
class PreviewStageHost implements StageHost { class PreviewStageHost implements StageHost {
@ -77,22 +78,15 @@ export class PromptForm extends ModelForm<Prompt, string> {
return; return;
} }
} }
try {
return new StagesApi(DEFAULT_CONFIG) this.preview = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsPreviewCreate({
.stagesPromptPromptsPreviewCreate({
promptRequest: prompt, promptRequest: prompt,
})
.then((nextPreview) => {
this.preview = nextPreview;
this.previewError = undefined;
})
.catch(async (error: unknown) => {
const parsedError = await parseAPIResponseError(error);
this.previewError = instanceOfValidationError(parsedError)
? parsedError.nonFieldErrors
: [pluckErrorDetail(parsedError, msg("Failed to preview prompt"))];
}); });
this.previewError = undefined;
} catch (exc) {
const errorMessage = parseAPIError(exc as ResponseError);
this.previewError = (errorMessage as ValidationError).nonFieldErrors;
}
} }
getSuccessMessage(): string { getSuccessMessage(): string {

View File

@ -1,4 +1,3 @@
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages"; import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import { DeleteForm } from "@goauthentik/elements/forms/DeleteForm"; import { DeleteForm } from "@goauthentik/elements/forms/DeleteForm";
@ -17,14 +16,10 @@ export class UserActiveForm extends DeleteForm {
}); });
} }
onError(error: unknown): Promise<void> { onError(e: Error): void {
return parseAPIResponseError(error).then((parsedError) => { showMessage({
showMessage({ message: msg(str`Failed to update ${this.objectLabel}: ${e.toString()}`),
message: msg( level: MessageLevel.error,
str`Failed to update ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
),
level: MessageLevel.error,
});
}); });
} }

View File

@ -1,6 +1,6 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { deviceTypeName } from "@goauthentik/common/labels"; import { deviceTypeName } from "@goauthentik/common/labels";
import { SentryIgnoredError } from "@goauthentik/common/sentry";
import { getRelativeTime } from "@goauthentik/common/utils"; import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";

View File

@ -7,10 +7,9 @@ import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm"; import "@goauthentik/admin/users/UserResetEmailForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js"; import { PFSize } from "@goauthentik/common/enums.js";
import { parseAPIResponseError } from "@goauthentik/common/errors/network";
import { userTypeToLabel } from "@goauthentik/common/labels"; import { userTypeToLabel } from "@goauthentik/common/labels";
import { MessageLevel } from "@goauthentik/common/messages"; import { MessageLevel } from "@goauthentik/common/messages";
import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config"; import { createUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils"; import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
@ -24,8 +23,8 @@ import "@goauthentik/elements/TreeView";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { showAPIErrorMessage, showMessage } from "@goauthentik/elements/messages/MessageContainer"; import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -40,7 +39,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CoreApi, SessionUser, User, UserPath } from "@goauthentik/api"; import { CoreApi, ResponseError, SessionUser, User, UserPath } from "@goauthentik/api";
export const requestRecoveryLink = (user: User) => export const requestRecoveryLink = (user: User) =>
new CoreApi(DEFAULT_CONFIG) new CoreApi(DEFAULT_CONFIG)
@ -58,7 +57,16 @@ export const requestRecoveryLink = (user: User) =>
}), }),
), ),
) )
.catch((error: unknown) => parseAPIResponseError(error).then(showAPIErrorMessage)); .catch((ex: ResponseError) =>
ex.response.json().then(() =>
showMessage({
level: MessageLevel.error,
message: msg(
"The current brand must have a recovery flow configured to use a recovery link",
),
}),
),
);
export const renderRecoveryEmailRequest = (user: User) => export const renderRecoveryEmailRequest = (user: User) =>
html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request"> html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request">
@ -109,7 +117,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
activePath; activePath;
@state() @state()
hideDeactivated = getURLParam<boolean>("hideDeactivated", false); hideDeactivated = getRouteParameter<boolean>("hideDeactivated", false);
@state() @state()
userPaths?: UserPath; userPaths?: UserPath;
@ -118,13 +126,15 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
me?: SessionUser; me?: SessionUser;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [...TablePage.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles]; return [...super.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
} }
constructor() { constructor() {
super(); super();
const defaultPath = new DefaultUIConfig().defaults.userPath;
this.activePath = getURLParam<string>("path", defaultPath); const defaultPath = createUIConfig().defaults.userPath;
this.activePath = getRouteParameter("path", defaultPath);
uiConfig().then((c) => { uiConfig().then((c) => {
if (c.defaults.userPath !== defaultPath) { if (c.defaults.userPath !== defaultPath) {
this.activePath = c.defaults.userPath; this.activePath = c.defaults.userPath;
@ -135,7 +145,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
async apiEndpoint(): Promise<PaginatedResponse<User>> { async apiEndpoint(): Promise<PaginatedResponse<User>> {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({ const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
...(await this.defaultEndpointConfig()), ...(await this.defaultEndpointConfig()),
pathStartswith: getURLParam("path", ""), pathStartswith: getRouteParameter("path", ""),
isActive: this.hideDeactivated ? true : undefined, isActive: this.hideDeactivated ? true : undefined,
includeGroups: false, includeGroups: false,
}); });
@ -217,7 +227,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
this.hideDeactivated = !this.hideDeactivated; this.hideDeactivated = !this.hideDeactivated;
this.page = 1; this.page = 1;
this.fetch(); this.fetch();
updateURLParams({ patchRouteParams({
hideDeactivated: this.hideDeactivated, hideDeactivated: this.hideDeactivated,
}); });
}} }}

View File

@ -79,11 +79,4 @@ export const DEFAULT_CONFIG = new Configuration({
], ],
}); });
// This is just a function so eslint doesn't complain about
// missing-whitespace-between-attributes or
// unexpected-character-in-attribute-name
export function AndNext(url: string): string {
return `?next=${encodeURIComponent(url)}`;
}
console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`); console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`);

View File

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

View File

@ -1,170 +1,60 @@
/** /**
* @file * @file Client-side utilities.
* Client-side observer for ESBuild events.
*/ */
import type { Message as ESBuildMessage } from "esbuild"; import { TITLE_DEFAULT } from "@goauthentik/common/constants";
import { isAdminRoute } from "@goauthentik/elements/router";
const logPrefix = "👷 [ESBuild]"; import { msg } from "@lit/localize";
const log = console.debug.bind(console, logPrefix);
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void; import type { CurrentBrand } from "@goauthentik/api";
type BrandTitleLike = Partial<Pick<CurrentBrand, "brandingTitle">>;
/** /**
* A client-side watcher for ESBuild. * Create a title for the page.
* *
* Note that this should be conditionally imported in your code, so that * @param brand - The brand object to append to the title.
* ESBuild may tree-shake it out of production builds. * @param segments - The segments to prepend to the title.
*
* ```ts
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
* const { ESBuildObserver } = await import("@goauthentik/common/client");
*
* new ESBuildObserver(process.env.WATCHER_URL);
* }
* ```
}
*/ */
export class ESBuildObserver extends EventSource { export function formatPageTitle(
/** brand: BrandTitleLike | undefined,
* Whether the watcher has a recent connection to the server. ...segments: Array<string | undefined>
*/ ): string;
alive = true; /**
* Create a title for the page.
*
* @param segments - The segments to prepend to the title.
*/
export function formatPageTitle(...segments: Array<string | undefined>): string;
/**
* Create a title for the page.
*
* @param args - The segments to prepend to the title.
* @param args - The brand object to append to the title.
*/
export function formatPageTitle(
...args: [BrandTitleLike | string | undefined, ...Array<string | undefined>]
): string {
const segments: string[] = [];
/** if (isAdminRoute()) {
* The number of errors that have occurred since the watcher started. segments.push(msg("Admin"));
*/
errorCount = 0;
/**
* Whether a reload has been requested while offline.
*/
deferredReload = false;
/**
* The last time a message was received from the server.
*/
lastUpdatedAt = Date.now();
/**
* Whether the browser considers itself online.
*/
online = true;
/**
* The ID of the animation frame for the reload.
*/
#reloadFrameID = -1;
/**
* The interval for the keep-alive check.
*/
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
#trackActivity = () => {
this.lastUpdatedAt = Date.now();
this.alive = true;
};
#startListener: BuildEventListener = () => {
this.#trackActivity();
log("⏰ Build started...");
};
#internalErrorListener = () => {
this.errorCount += 1;
if (this.errorCount > 100) {
clearTimeout(this.#keepAliveInterval);
this.close();
log("⛔️ Closing connection");
}
};
#errorListener: BuildEventListener<string> = (event) => {
this.#trackActivity();
// eslint-disable-next-line no-console
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
for (const error of esbuildErrorMessages) {
console.warn(error.text);
if (error.location) {
console.debug(
`file://${error.location.file}:${error.location.line}:${error.location.column}`,
);
console.debug(error.location.lineText);
}
}
// eslint-disable-next-line no-console
console.groupEnd();
};
#endListener: BuildEventListener = () => {
cancelAnimationFrame(this.#reloadFrameID);
this.#trackActivity();
if (!this.online) {
log("🚫 Build finished while offline.");
this.deferredReload = true;
return;
}
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.
this.#reloadFrameID = requestAnimationFrame(() => {
window.location.reload();
});
};
#keepAliveListener: BuildEventListener = () => {
this.#trackActivity();
log("🏓 Keep-alive");
};
constructor(url: string | URL) {
super(url);
this.addEventListener("esbuild:start", this.#startListener);
this.addEventListener("esbuild:end", this.#endListener);
this.addEventListener("esbuild:error", this.#errorListener);
this.addEventListener("esbuild:keep-alive", this.#keepAliveListener);
this.addEventListener("error", this.#internalErrorListener);
window.addEventListener("offline", () => {
this.online = false;
});
window.addEventListener("online", () => {
this.online = true;
if (!this.deferredReload) return;
log("🛎️ Reloading after offline build...");
this.deferredReload = false;
window.location.reload();
});
log("🛎️ Listening for build changes...");
this.#keepAliveInterval = setInterval(() => {
const now = Date.now();
if (now - this.lastUpdatedAt < 10_000) return;
this.alive = false;
log("👋 Waiting for build to start...");
}, 15_000);
} }
const [arg1, ...rest] = args;
if (typeof arg1 === "object") {
const { brandingTitle = TITLE_DEFAULT } = arg1;
segments.push(brandingTitle);
} else {
segments.push(TITLE_DEFAULT);
}
for (const segment of rest) {
if (segment) {
segments.push(segment);
}
}
return segments.join(" - ");
} }

View File

@ -5,7 +5,6 @@ export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current"; export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2025.2.3"; export const VERSION = "2025.2.3";
export const TITLE_DEFAULT = "authentik"; export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";";
export const EVENT_REFRESH = "ak-refresh"; export const EVENT_REFRESH = "ak-refresh";
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle"; export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";

36
web/src/common/errors.ts Normal file
View File

@ -0,0 +1,36 @@
import {
GenericError,
GenericErrorFromJSON,
ResponseError,
ValidationError,
ValidationErrorFromJSON,
} from "@goauthentik/api";
export class SentryIgnoredError extends Error {}
export class NotFoundError extends Error {}
export class RequestError extends Error {}
export type APIErrorTypes = ValidationError | GenericError;
export const HTTP_BAD_REQUEST = 400;
export const HTTP_INTERNAL_SERVICE_ERROR = 500;
export async function parseAPIError(error: Error): Promise<APIErrorTypes> {
if (!(error instanceof ResponseError)) {
return error;
}
if (
error.response.status < HTTP_BAD_REQUEST ||
error.response.status >= HTTP_INTERNAL_SERVICE_ERROR
) {
return error;
}
const body = await error.response.json();
if (error.response.status === 400) {
return ValidationErrorFromJSON(body);
}
if (error.response.status === 403) {
return GenericErrorFromJSON(body);
}
return body;
}

View File

@ -1,184 +0,0 @@
import {
GenericError,
GenericErrorFromJSON,
ResponseError,
ValidationError,
ValidationErrorFromJSON,
} from "@goauthentik/api";
//#region HTTP
/**
* Common HTTP status names used in the API and their corresponding codes.
*/
export const HTTPStatusCode = {
BadRequest: 400,
Forbidden: 403,
InternalServiceError: 500,
} as const satisfies Record<string, number>;
export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode];
export type HTTPErrorJSONTransformer<T = unknown> = (json: T) => APIError;
export const HTTPStatusCodeTransformer: Record<number, HTTPErrorJSONTransformer> = {
[HTTPStatusCode.BadRequest]: ValidationErrorFromJSON,
[HTTPStatusCode.Forbidden]: GenericErrorFromJSON,
} as const;
/**
* Type guard to check if a response contains a JSON body.
*
* This is useful to guard against parsing errors when attempting to read the response body.
*/
export function isJSONResponse(response: Response): boolean {
return Boolean(response.headers.get("content-type")?.includes("application/json"));
}
//#endregion
//#region API
/**
* An API response error, typically derived from a {@linkcode Response} body.
*
* @see {@linkcode parseAPIResponseError}
*/
export type APIError = ValidationError | GenericError;
/**
* Given an error-like object, attempts to normalize it into a {@linkcode GenericError}
* suitable for display to the user.
*/
export function createSyntheticGenericError(detail?: string): GenericError {
const syntheticGenericError: GenericError = {
detail: detail || ResponseErrorMessages[HTTPStatusCode.InternalServiceError].reason,
};
return syntheticGenericError;
}
/**
* An error that contains a native response object.
*
* @see {@linkcode isResponseErrorLike} to determine if an error contains a response object.
*/
export type APIErrorWithResponse = Pick<ResponseError, "response" | "message">;
/**
* Type guard to check if an error contains a HTTP {@linkcode Response} object.
*
* @see {@linkcode parseAPIResponseError} to parse the response body into a {@linkcode APIError}.
*/
export function isResponseErrorLike(errorLike: unknown): errorLike is APIErrorWithResponse {
if (!errorLike || typeof errorLike !== "object") return false;
return "response" in errorLike && errorLike.response instanceof Response;
}
/**
* A descriptor to provide a human readable error message for a given HTTP status code.
*
* @see {@linkcode ResponseErrorMessages} for a list of fallback error messages.
*/
interface ResponseErrorDescriptor {
headline: string;
reason: string;
}
/**
* Fallback error messages for HTTP status codes used when a more specific error message is not available in the response.
*/
export const ResponseErrorMessages: Record<number, ResponseErrorDescriptor> = {
[HTTPStatusCode.BadRequest]: {
headline: "Bad request",
reason: "The server did not understand the request",
},
[HTTPStatusCode.InternalServiceError]: {
headline: "Internal server error",
reason: "An unexpected error occurred",
},
} as const;
/**
* Composes a human readable error message from a {@linkcode ResponseErrorDescriptor}.
*
* Note that this is kept separate from localization to lower the complexity of the error handling code.
*/
export function composeResponseErrorDescriptor(descriptor: ResponseErrorDescriptor): string {
return `${descriptor.headline}: ${descriptor.reason}`;
}
/**
* Attempts to pluck a human readable error message from a {@linkcode ValidationError}.
*/
export function pluckErrorDetail(validationError: ValidationError, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from a {@linkcode GenericError}.
*/
export function pluckErrorDetail(genericError: GenericError, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from an `Error` object.
*/
export function pluckErrorDetail(error: Error, fallback?: string): string;
/**
* Attempts to pluck a human readable error message from an error-like object.
*
* Prioritizes the `detail` key, then the `message` key.
*
*/
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string;
export function pluckErrorDetail(errorLike: unknown, fallback?: string): string {
fallback ||= composeResponseErrorDescriptor(
ResponseErrorMessages[HTTPStatusCode.InternalServiceError],
);
if (!errorLike || typeof errorLike !== "object") {
return fallback;
}
if ("detail" in errorLike && typeof errorLike.detail === "string") {
return errorLike.detail;
}
if ("message" in errorLike && typeof errorLike.message === "string") {
return errorLike.message;
}
return fallback;
}
/**
* Given API error, parses the response body and transforms it into a {@linkcode APIError}.
*/
export async function parseAPIResponseError<T extends APIError = APIError>(
error: unknown,
): Promise<T> {
if (!isResponseErrorLike(error)) {
const message = error instanceof Error ? error.message : String(error);
return createSyntheticGenericError(message) as T;
}
const { response, message } = error;
if (!isJSONResponse(response)) {
return createSyntheticGenericError(message || response.statusText) as T;
}
return response
.json()
.then((body) => {
const transformer = HTTPStatusCodeTransformer[response.status];
const transformedBody = transformer ? transformer(body) : body;
return transformedBody as unknown as T;
})
.catch((transformerError: unknown) => {
console.error("Failed to parse response error body", transformerError);
return createSyntheticGenericError(message || response.statusText) as T;
});
}
//#endregion

View File

@ -8,10 +8,13 @@ export interface EventUser {
is_anonymous?: boolean; is_anonymous?: boolean;
} }
export interface EventGeo { export interface EventContext {
city?: string; [key: string]: EventContext | EventModel | string | number | string[];
country?: string; }
continent?: string;
export interface EventWithContext extends Event {
user: EventUser;
context: EventContext;
} }
export interface EventModel { export interface EventModel {
@ -25,16 +28,3 @@ export interface EventRequest {
path: string; path: string;
method: string; method: string;
} }
export type EventContextProperty = EventModel | EventGeo | string | number | string[] | undefined;
// TODO: Events should have more specific types.
export interface EventContext {
[key: string]: EventContext | EventContextProperty;
geo?: EventGeo;
}
export interface EventWithContext extends Event {
user: EventUser;
context: EventContext;
}

View File

@ -1,5 +1,5 @@
import { VERSION } from "@goauthentik/common/constants"; import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/sentry"; import { SentryIgnoredError } from "@goauthentik/common/errors";
export interface PlexPinResponse { export interface PlexPinResponse {
// Only has the fields we care about // Only has the fields we care about

View File

@ -0,0 +1,145 @@
import * as base64js from "base64-js";
import { msg } from "@lit/localize";
export function b64enc(buf: Uint8Array): string {
return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}
export function b64RawEnc(buf: Uint8Array): string {
return base64js.fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
}
export function u8arr(input: string): Uint8Array {
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
c.charCodeAt(0),
);
}
export function checkWebAuthnSupport() {
if ("credentials" in navigator) {
return;
}
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
throw new Error(msg("WebAuthn requires this page to be accessed via HTTPS."));
}
throw new Error(msg("WebAuthn not supported by browser."));
}
/**
* Transforms items in the credentialCreateOptions generated on the server
* into byte arrays expected by the navigator.credentials.create() call
*/
export function transformCredentialCreateOptions(
credentialCreateOptions: PublicKeyCredentialCreationOptions,
userId: string,
): PublicKeyCredentialCreationOptions {
const user = credentialCreateOptions.user;
// Because json can't contain raw bytes, the server base64-encodes the User ID
// So to get the base64 encoded byte array, we first need to convert it to a regular
// string, then a byte array, re-encode it and wrap that in an array.
const stringId = decodeURIComponent(window.atob(userId));
user.id = u8arr(b64enc(u8arr(stringId)));
const challenge = u8arr(credentialCreateOptions.challenge.toString());
return {
...credentialCreateOptions,
challenge,
user,
};
}
export interface Assertion {
id: string;
rawId: string;
type: string;
registrationClientExtensions: string;
response: {
clientDataJSON: string;
attestationObject: string;
};
}
/**
* Transforms the binary data in the credential into base64 strings
* for posting to the server.
* @param {PublicKeyCredential} newAssertion
*/
export function transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
const attObj = new Uint8Array(
(newAssertion.response as AuthenticatorAttestationResponse).attestationObject,
);
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const registrationClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: b64enc(rawId),
type: newAssertion.type,
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
response: {
clientDataJSON: b64enc(clientDataJSON),
attestationObject: b64enc(attObj),
},
};
}
export function transformCredentialRequestOptions(
credentialRequestOptions: PublicKeyCredentialRequestOptions,
): PublicKeyCredentialRequestOptions {
const challenge = u8arr(credentialRequestOptions.challenge.toString());
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
(credentialDescriptor) => {
const id = u8arr(credentialDescriptor.id.toString());
return Object.assign({}, credentialDescriptor, { id });
},
);
return {
...credentialRequestOptions,
challenge,
allowCredentials,
};
}
export interface AuthAssertion {
id: string;
rawId: string;
type: string;
assertionClientExtensions: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
}
/**
* Encodes the binary data in the assertion into strings for posting to the server.
* @param {PublicKeyCredential} newAssertion
*/
export function transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion {
const response = newAssertion.response as AuthenticatorAssertionResponse;
const authData = new Uint8Array(response.authenticatorData);
const clientDataJSON = new Uint8Array(response.clientDataJSON);
const rawId = new Uint8Array(newAssertion.rawId);
const sig = new Uint8Array(response.signature);
const assertionClientExtensions = newAssertion.getClientExtensionResults();
return {
id: newAssertion.id,
rawId: b64enc(rawId),
type: newAssertion.type,
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
response: {
clientDataJSON: b64RawEnc(clientDataJSON),
signature: b64RawEnc(sig),
authenticatorData: b64RawEnc(authData),
userHandle: null,
},
};
}

28
web/src/common/http.ts Normal file
View File

@ -0,0 +1,28 @@
/**
* @file HTTP utilities.
*/
/**
* Get the value of a cookie by its name.
*
* @param cookieName - The name of the cookie to retrieve.
* @returns The value of the cookie, or an empty string if the cookie is not found.
*/
export function getCookie(cookieName: string): string {
if (!cookieName) return "";
if (typeof document === "undefined") return "";
if (typeof document.cookie !== "string") return "";
if (!document.cookie) return "";
const search = cookieName + "=";
// Split the cookie string into individual name=value pairs...
const keyValPairs = document.cookie.split(";").map((cookie) => cookie.trim());
for (const pair of keyValPairs) {
if (!pair.startsWith(search)) continue;
return decodeURIComponent(pair.substring(search.length));
}
return "";
}

View File

@ -1,6 +1,8 @@
import { config } from "@goauthentik/common/api/config"; import { config } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants"; import { VERSION } from "@goauthentik/common/constants";
import { SentryIgnoredError } from "@goauthentik/common/errors";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
import { import {
ErrorEvent, ErrorEvent,
EventHint, EventHint,
@ -12,11 +14,6 @@ import {
import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api"; import { CapabilitiesEnum, Config, ResponseError } from "@goauthentik/api";
/**
* A generic error that can be thrown without triggering Sentry's reporting.
*/
export class SentryIgnoredError extends Error {}
export const TAG_SENTRY_COMPONENT = "authentik.component"; export const TAG_SENTRY_COMPONENT = "authentik.component";
export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities"; export const TAG_SENTRY_CAPABILITIES = "authentik.capabilities";
@ -68,7 +65,7 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
}); });
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(",")); setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
if (window.location.pathname.includes("if/")) { if (window.location.pathname.includes("if/")) {
setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`); setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
} }
if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) { if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) {
const Spotlight = await import("@spotlightjs/spotlight"); const Spotlight = await import("@spotlightjs/spotlight");
@ -86,13 +83,3 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
} }
return cfg; return cfg;
} }
// Get the interface name from URL
export function currentInterface(): string {
const pathMatches = window.location.pathname.match(/.+if\/(\w+)\//);
let currentInterface = "unknown";
if (pathMatches && pathMatches.length >= 2) {
currentInterface = pathMatches[1];
}
return currentInterface.toLowerCase();
}

View File

@ -1,7 +1,7 @@
import { currentInterface } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { isUserRoute } from "@goauthentik/elements/router";
import { UiThemeEnum, UserSelf } from "@goauthentik/api"; import { UiThemeEnum } from "@goauthentik/api";
export enum UserDisplay { export enum UserDisplay {
username = "username", username = "username",
@ -18,15 +18,27 @@ export enum LayoutType {
export interface UIConfig { export interface UIConfig {
enabledFeatures: { enabledFeatures: {
// API Request drawer in navbar /**
* Whether to show the API request drawer in the navbar.
*/
apiDrawer: boolean; apiDrawer: boolean;
// Notification drawer in navbar /**
* Whether to show the notification drawer in the navbar.
*/
notificationDrawer: boolean; notificationDrawer: boolean;
// Settings in user dropdown /**
* Whether to show the settings in the user dropdown.
*/
settings: boolean; settings: boolean;
// Application edit in library (only shown when user is superuser) /**
* Whether to show the application edit button in the library.
*
* This is only shown when the user is a superuser.
*/
applicationEdit: boolean; applicationEdit: boolean;
// Search bar /**
* Whether to show the search bar.
*/
search: boolean; search: boolean;
}; };
navbar: { navbar: {
@ -38,68 +50,77 @@ export interface UIConfig {
cardBackground: string; cardBackground: string;
}; };
pagination: { pagination: {
/**
* Number of items to show per page in paginated lists.
*/
perPage: number; perPage: number;
}; };
layout: { layout: {
/**
* Layout type to use for the application.
*/
type: LayoutType; type: LayoutType;
}; };
/**
* Locale to use for the application.
*/
locale: string; locale: string;
/**
* Default values.
*/
defaults: { defaults: {
/**
* Default path to use for user API calls.
*/
userPath: string; userPath: string;
}; };
} }
export class DefaultUIConfig implements UIConfig { export function createUIConfig(overrides: Partial<UIConfig> = {}): UIConfig {
enabledFeatures = { const uiConfig: UIConfig = {
apiDrawer: true, enabledFeatures: {
notificationDrawer: true, // TODO: Is the intent that only user routes should have the API drawer disabled,
settings: true, // or only admin routes?
applicationEdit: true, apiDrawer: !isUserRoute(),
search: true, notificationDrawer: true,
}; settings: true,
layout = { applicationEdit: true,
type: LayoutType.row, search: true,
}; },
navbar = { layout: {
userDisplay: UserDisplay.username, type: LayoutType.row,
}; },
theme = { navbar: {
base: UiThemeEnum.Automatic, userDisplay: UserDisplay.username,
background: "", },
cardBackground: "", theme: {
}; base: UiThemeEnum.Automatic,
pagination = { background: "",
perPage: 20, cardBackground: "",
}; },
locale = ""; pagination: {
defaults = { perPage: 20,
userPath: "users", },
locale: "",
defaults: {
userPath: "users",
},
}; };
constructor() { // TODO: Should we deep merge the overrides instead of shallow?
if (currentInterface() === "user") { Object.assign(uiConfig, overrides);
this.enabledFeatures.apiDrawer = false;
} return uiConfig;
}
} }
let globalUiConfig: Promise<UIConfig>; let cachedUIConfig: UIConfig | null = null;
export function getConfigForUser(user: UserSelf): UIConfig {
const settings = user.settings;
let config = new DefaultUIConfig();
if (!settings) {
return config;
}
config = Object.assign(new DefaultUIConfig(), settings);
return config;
}
export function uiConfig(): Promise<UIConfig> { export function uiConfig(): Promise<UIConfig> {
if (!globalUiConfig) { if (cachedUIConfig) return Promise.resolve(cachedUIConfig);
globalUiConfig = me().then((user) => {
return getConfigForUser(user.user); return me().then((session) => {
}); cachedUIConfig = createUIConfig(session.user.settings);
}
return globalUiConfig; return cachedUIConfig;
});
} }

View File

@ -1,96 +1,63 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants"; import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
import { CoreApi, SessionUser } from "@goauthentik/api"; import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
/** let globalMePromise: Promise<SessionUser> | undefined;
* Create a guest session for unauthenticated users.
*
* @see {@linkcode me} for the actual session retrieval.
*/
function createGuestSession(): SessionUser {
const guest: SessionUser = {
user: {
pk: -1,
isSuperuser: false,
isActive: true,
groups: [],
avatar: "",
uid: "",
username: "",
name: "",
settings: {},
systemPermissions: [],
},
};
return guest;
}
let memoizedSession: SessionUser | null = null;
/**
* Refresh the current user session.
*/
export function refreshMe(): Promise<SessionUser> { export function refreshMe(): Promise<SessionUser> {
memoizedSession = null; globalMePromise = undefined;
return me(); return me();
} }
/** export function me(): Promise<SessionUser> {
* Retrieve the current user session. if (!globalMePromise) {
* globalMePromise = new CoreApi(DEFAULT_CONFIG)
* This is a memoized function, so it will only make one request per page load. .coreUsersMeRetrieve()
* .then((user) => {
* @see {@linkcode refreshMe} to force a refresh. if (!user.user.settings || !("locale" in user.user.settings)) {
*/ return user;
export async function me(): Promise<SessionUser> {
if (memoizedSession) return memoizedSession;
return new CoreApi(DEFAULT_CONFIG)
.coreUsersMeRetrieve()
.then((nextSession) => {
const locale: string | undefined = nextSession.user.settings.locale;
if (locale) {
console.debug(`authentik/locale: Activating user's configured locale '${locale}'`);
window.dispatchEvent(
new CustomEvent(EVENT_LOCALE_REQUEST, {
composed: true,
bubbles: true,
detail: { locale },
}),
);
}
return nextSession;
})
.catch(async (error: unknown) => {
if (isResponseErrorLike(error)) {
const { response } = error;
if (response.status === 401 || response.status === 403) {
const { pathname, search, hash } = window.location;
const authFlowRedirectURL = new URL(
`/flows/-/default/authentication/`,
window.location.origin,
);
authFlowRedirectURL.searchParams.set("next", `${pathname}${search}${hash}`);
window.location.assign(authFlowRedirectURL);
} }
} const locale: string | undefined = user.user.settings.locale;
if (locale && locale !== "") {
console.debug("authentik/users: Failed to retrieve user session", error); console.debug(
`authentik/locale: Activating user's configured locale '${locale}'`,
return createGuestSession(); );
}) window.dispatchEvent(
.then((nextSession) => { new CustomEvent(EVENT_LOCALE_REQUEST, {
memoizedSession = nextSession; composed: true,
return nextSession; bubbles: true,
}); detail: { locale },
}),
);
}
return user;
})
.catch((ex: ResponseError) => {
const defaultUser: SessionUser = {
user: {
pk: -1,
isSuperuser: false,
isActive: true,
groups: [],
avatar: "",
uid: "",
username: "",
name: "",
settings: {},
systemPermissions: [],
},
};
if (ex.response?.status === 401 || ex.response?.status === 403) {
const relativeUrl = window.location
.toString()
.substring(window.location.origin.length);
window.location.assign(
`/flows/-/default/authentication/?next=${encodeURIComponent(relativeUrl)}`,
);
}
return defaultUser;
});
}
return globalMePromise;
} }

View File

@ -1,36 +1,7 @@
import { SentryIgnoredError } from "@goauthentik/common/sentry"; import { SentryIgnoredError } from "@goauthentik/common/errors";
import { CSSResult, css } from "lit"; import { CSSResult, css } from "lit";
export function getCookie(name: string): string {
let cookieValue = "";
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
export function convertToSlug(text: string): string {
return text
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]+/g, "");
}
export function isSlug(text: string): boolean {
const lowered = text.toLowerCase();
const forbidden = /([^\w-]|\s)/.test(lowered);
return lowered === text && !forbidden;
}
/** /**
* Truncate a string based on maximum word count * Truncate a string based on maximum word count
*/ */
@ -63,17 +34,29 @@ export function snakeToCamel(key: string) {
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> { export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
const m = new Map<string, T[]>(); const m = new Map<string, T[]>();
objects.forEach((obj) => { objects.forEach((obj) => {
const group = callback(obj); const group = callback(obj);
if (!m.has(group)) { if (!m.has(group)) {
m.set(group, []); m.set(group, []);
} }
const tProviders = m.get(group) || []; const tProviders = m.get(group) || [];
tProviders.push(obj); tProviders.push(obj);
}); });
return Array.from(m).sort(); return Array.from(m).sort();
} }
/**
* Returns the first non-null and non-undefined argument.
*
* @deprecated Use nullish coalescing operator (??) instead.
* @remarks
*
* This needs a deeper look. Some instances of this function use `new Date()`
* which may cause issues during rendering.
*/
export function first<T>(...args: Array<T | undefined | null>): T { export function first<T>(...args: Array<T | undefined | null>): T {
for (let index = 0; index < args.length; index++) { for (let index = 0; index < args.length; index++) {
const element = args[index]; const element = args[index];

View File

@ -1,16 +1,10 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { VERSION } from "@goauthentik/common/constants"; import { VERSION } from "@goauthentik/common/constants";
import { PFSize } from "@goauthentik/common/enums.js"; import { PFSize } from "@goauthentik/common/enums.js";
import { import { EventContext, EventModel, EventWithContext } from "@goauthentik/common/events";
EventContext,
EventContextProperty,
EventModel,
EventWithContext,
} from "@goauthentik/common/events";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Expand"; import "@goauthentik/elements/Expand";
import "@goauthentik/elements/Spinner"; import "@goauthentik/elements/Spinner";
import { SlottedTemplateResult } from "@goauthentik/elements/types";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
@ -29,15 +23,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EventActions, FlowsApi } from "@goauthentik/api"; import { EventActions, FlowsApi } from "@goauthentik/api";
// TODO: Settle these types. It's too hard to make sense of what we're expecting here. type Pair = [string, string | number | EventContext | EventModel | string[] | TemplateResult];
type EventSlotValueType =
| number
| SlottedTemplateResult
| undefined
| EventContext
| EventContextProperty;
type FieldLabelTuple<V extends EventSlotValueType = EventSlotValueType> = [label: string, value: V];
// https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-issues/about-automation-for-issues-and-pull-requests-with-query-parameters // https://docs.github.com/en/issues/tracking-your-work-with-issues/creating-issues/about-automation-for-issues-and-pull-requests-with-query-parameters
@ -118,7 +104,7 @@ export class EventInfo extends AKElement {
]; ];
} }
renderDescriptionGroup([term, description]: FieldLabelTuple) { renderDescriptionGroup([term, description]: Pair) {
return html` <div class="pf-c-description-list__group"> return html` <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${term}</span> <span class="pf-c-description-list__text">${term}</span>
@ -134,7 +120,7 @@ export class EventInfo extends AKElement {
return html`<span>-</span>`; return html`<span>-</span>`;
} }
const modelFields: FieldLabelTuple[] = [ const modelFields: Pair[] = [
[msg("UID"), context.pk], [msg("UID"), context.pk],
[msg("Name"), context.name], [msg("Name"), context.name],
[msg("App"), context.app], [msg("App"), context.app],
@ -148,23 +134,20 @@ export class EventInfo extends AKElement {
</div>`; </div>`;
} }
getEmailInfo(context: EventContext): SlottedTemplateResult { getEmailInfo(context: EventContext): TemplateResult {
if (context === null) { if (context === null) {
return html`<span>-</span>`; return html`<span>-</span>`;
} }
const emailFields = [ // prettier-ignore
// --- const emailFields: Pair[] = [
[msg("Message"), context.message], [msg("Message"), context.message],
[msg("Subject"), context.subject], [msg("Subject"), context.subject],
[msg("From"), context.from_email], [msg("From"), context.from_email],
[ [msg("To"), html`${(context.to_email as string[]).map((to) => {
msg("To"),
html`${(context.to_email as string[]).map((to) => {
return html`<li>${to}</li>`; return html`<li>${to}</li>`;
})}`, })}`],
], ];
] satisfies FieldLabelTuple<EventSlotValueType>[];
return html`<dl class="pf-c-description-list pf-m-horizontal"> return html`<dl class="pf-c-description-list pf-m-horizontal">
${map(emailFields, this.renderDescriptionGroup)} ${map(emailFields, this.renderDescriptionGroup)}

View File

@ -1,4 +1,4 @@
import { convertToSlug } from "@goauthentik/common/utils"; import { formatAsSlug } from "@goauthentik/elements/router";
import { html } from "lit"; import { html } from "lit";
import { customElement, property, query } from "lit/decorators.js"; import { customElement, property, query } from "lit/decorators.js";
@ -34,7 +34,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// Do not stop propagation of this event; it must be sent up the tree so that a parent // Do not stop propagation of this event; it must be sent up the tree so that a parent
// component, such as a custom forms manager, may receive it. // component, such as a custom forms manager, may receive it.
handleTouch(ev: Event) { handleTouch(ev: Event) {
this.input.value = convertToSlug(this.input.value); this.input.value = formatAsSlug(this.input.value);
this.value = this.input.value; this.value = this.input.value;
if (this.origin && this.origin.value === "" && this.input.value === "") { if (this.origin && this.origin.value === "" && this.input.value === "") {
@ -67,7 +67,7 @@ export class AkSlugInput extends HorizontalLightComponent<string> {
// "any event which adds or removes a character but leaves the rest of the slug looking like // "any event which adds or removes a character but leaves the rest of the slug looking like
// the previous iteration, set it to the current iteration." // the previous iteration, set it to the current iteration."
const newSlug = convertToSlug(ev.target.value); const newSlug = formatAsSlug(ev.target.value);
const oldSlug = this.input.value; const oldSlug = this.input.value;
const [shorter, longer] = const [shorter, longer] =
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug]; newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];

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