Compare commits
2 Commits
sfe-packag
...
router-tid
Author | SHA1 | Date | |
---|---|---|---|
e3f2ed0436 | |||
a5bb22a66a |
5
.github/workflows/api-ts-publish.yml
vendored
5
.github/workflows/api-ts-publish.yml
vendored
@ -36,6 +36,11 @@ jobs:
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").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
|
||||
id: cpr
|
||||
with:
|
||||
|
@ -30,6 +30,7 @@ WORKDIR /work/web
|
||||
|
||||
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/packages/sfe/package.json,src=./web/packages/sfe/package.json \
|
||||
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
|
||||
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
|
||||
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"
|
||||
|
||||
# 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
|
||||
FROM ghcr.io/goauthentik/fips-python:3.12.9-slim-bookworm-fips AS python-base
|
||||
|
||||
|
@ -179,13 +179,10 @@ class UserSourceConnectionSerializer(SourceSerializer):
|
||||
"user",
|
||||
"source",
|
||||
"source_obj",
|
||||
"identifier",
|
||||
"created",
|
||||
"last_updated",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"created": {"read_only": True},
|
||||
"last_updated": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
@ -202,7 +199,7 @@ class UserSourceConnectionViewSet(
|
||||
queryset = UserSourceConnection.objects.all()
|
||||
serializer_class = UserSourceConnectionSerializer
|
||||
filterset_fields = ["user", "source__slug"]
|
||||
search_fields = ["user__username", "source__slug", "identifier"]
|
||||
search_fields = ["source__slug"]
|
||||
ordering = ["source__slug", "pk"]
|
||||
owner_field = "user"
|
||||
|
||||
@ -221,11 +218,9 @@ class GroupSourceConnectionSerializer(SourceSerializer):
|
||||
"source_obj",
|
||||
"identifier",
|
||||
"created",
|
||||
"last_updated",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"created": {"read_only": True},
|
||||
"last_updated": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
@ -242,5 +237,6 @@ class GroupSourceConnectionViewSet(
|
||||
queryset = GroupSourceConnection.objects.all()
|
||||
serializer_class = GroupSourceConnectionSerializer
|
||||
filterset_fields = ["group", "source__slug"]
|
||||
search_fields = ["group__name", "source__slug", "identifier"]
|
||||
search_fields = ["source__slug"]
|
||||
ordering = ["source__slug", "pk"]
|
||||
owner_field = "user"
|
||||
|
@ -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,
|
||||
),
|
||||
]
|
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
@ -824,7 +824,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
source = models.ForeignKey(Source, on_delete=models.CASCADE)
|
||||
identifier = models.TextField()
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
@ -838,10 +837,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
|
||||
|
||||
class Meta:
|
||||
unique_together = (("user", "source"),)
|
||||
indexes = (
|
||||
models.Index(fields=("identifier",)),
|
||||
models.Index(fields=("source", "identifier")),
|
||||
)
|
||||
|
||||
|
||||
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
|
||||
|
@ -13,11 +13,7 @@ from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
|
||||
from authentik.core.api.groups import GroupViewSet
|
||||
from authentik.core.api.property_mappings import PropertyMappingViewSet
|
||||
from authentik.core.api.providers import ProviderViewSet
|
||||
from authentik.core.api.sources import (
|
||||
GroupSourceConnectionViewSet,
|
||||
SourceViewSet,
|
||||
UserSourceConnectionViewSet,
|
||||
)
|
||||
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
|
||||
from authentik.core.api.tokens import TokenViewSet
|
||||
from authentik.core.api.transactional_applications import TransactionalApplicationView
|
||||
from authentik.core.api.users import UserViewSet
|
||||
@ -85,7 +81,6 @@ api_urlpatterns = [
|
||||
("core/tokens", TokenViewSet),
|
||||
("sources/all", SourceViewSet),
|
||||
("sources/user_connections/all", UserSourceConnectionViewSet),
|
||||
("sources/group_connections/all", GroupSourceConnectionViewSet),
|
||||
("providers/all", ProviderViewSet),
|
||||
("propertymappings/all", PropertyMappingViewSet),
|
||||
("authenticators/all", DeviceViewSet, "device"),
|
||||
|
@ -49,6 +49,6 @@
|
||||
</main>
|
||||
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
|
||||
</div>
|
||||
<script src="{% static 'dist/sfe/main.js' %}"></script>
|
||||
<script src="{% static 'dist/sfe/index.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,11 +1,13 @@
|
||||
"""Kerberos Source Serializer"""
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.sources import (
|
||||
GroupSourceConnectionSerializer,
|
||||
GroupSourceConnectionViewSet,
|
||||
UserSourceConnectionSerializer,
|
||||
UserSourceConnectionViewSet,
|
||||
)
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.sources.kerberos.models import (
|
||||
GroupKerberosSourceConnection,
|
||||
UserKerberosSourceConnection,
|
||||
@ -13,20 +15,33 @@ from authentik.sources.kerberos.models import (
|
||||
|
||||
|
||||
class UserKerberosSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||
class Meta(UserSourceConnectionSerializer.Meta):
|
||||
"""Kerberos Source Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = UserKerberosSourceConnection
|
||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
|
||||
|
||||
|
||||
class UserKerberosSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||
class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet):
|
||||
"""Source Viewset"""
|
||||
|
||||
queryset = UserKerberosSourceConnection.objects.all()
|
||||
serializer_class = UserKerberosSourceConnectionSerializer
|
||||
filterset_fields = ["source__slug"]
|
||||
search_fields = ["source__slug"]
|
||||
ordering = ["source__slug"]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||
"""OAuth Group-Source connection Serializer"""
|
||||
|
||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||
model = GroupKerberosSourceConnection
|
||||
|
||||
|
||||
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||
class GroupKerberosSourceConnectionViewSet(GroupSourceConnectionViewSet):
|
||||
"""Group-source connection Viewset"""
|
||||
|
||||
queryset = GroupKerberosSourceConnection.objects.all()
|
||||
serializer_class = GroupKerberosSourceConnectionSerializer
|
||||
|
@ -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",
|
||||
),
|
||||
]
|
@ -372,6 +372,8 @@ class KerberosSourcePropertyMapping(PropertyMapping):
|
||||
class UserKerberosSourceConnection(UserSourceConnection):
|
||||
"""Connection to configured Kerberos Sources."""
|
||||
|
||||
identifier = models.TextField()
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
from authentik.sources.kerberos.api.source_connection import (
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""OAuth Source Serializer"""
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.sources import (
|
||||
@ -10,9 +12,11 @@ from authentik.sources.oauth.models import GroupOAuthSourceConnection, UserOAuth
|
||||
|
||||
|
||||
class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||
"""OAuth Source Serializer"""
|
||||
|
||||
class Meta(UserSourceConnectionSerializer.Meta):
|
||||
model = UserOAuthSourceConnection
|
||||
fields = UserSourceConnectionSerializer.Meta.fields + ["access_token"]
|
||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier", "access_token"]
|
||||
extra_kwargs = {
|
||||
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
||||
"access_token": {"write_only": True},
|
||||
@ -20,15 +24,21 @@ class UserOAuthSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||
|
||||
|
||||
class UserOAuthSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||
"""Source Viewset"""
|
||||
|
||||
queryset = UserOAuthSourceConnection.objects.all()
|
||||
serializer_class = UserOAuthSourceConnectionSerializer
|
||||
|
||||
|
||||
class GroupOAuthSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||
"""OAuth Group-Source connection Serializer"""
|
||||
|
||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||
model = GroupOAuthSourceConnection
|
||||
|
||||
|
||||
class GroupOAuthSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||
"""Group-source connection Viewset"""
|
||||
|
||||
queryset = GroupOAuthSourceConnection.objects.all()
|
||||
serializer_class = GroupOAuthSourceConnectionSerializer
|
||||
|
@ -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",
|
||||
),
|
||||
]
|
@ -286,6 +286,7 @@ class OAuthSourcePropertyMapping(PropertyMapping):
|
||||
class UserOAuthSourceConnection(UserSourceConnection):
|
||||
"""Authorized remote OAuth provider."""
|
||||
|
||||
identifier = models.CharField(max_length=255)
|
||||
access_token = models.TextField(blank=True, null=True, default=None)
|
||||
|
||||
@property
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""Plex Source connection Serializer"""
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.sources import (
|
||||
@ -10,9 +12,14 @@ from authentik.sources.plex.models import GroupPlexSourceConnection, UserPlexSou
|
||||
|
||||
|
||||
class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||
"""Plex Source connection Serializer"""
|
||||
|
||||
class Meta(UserSourceConnectionSerializer.Meta):
|
||||
model = UserPlexSourceConnection
|
||||
fields = UserSourceConnectionSerializer.Meta.fields + ["plex_token"]
|
||||
fields = UserSourceConnectionSerializer.Meta.fields + [
|
||||
"identifier",
|
||||
"plex_token",
|
||||
]
|
||||
extra_kwargs = {
|
||||
**UserSourceConnectionSerializer.Meta.extra_kwargs,
|
||||
"plex_token": {"write_only": True},
|
||||
@ -20,15 +27,21 @@ class UserPlexSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||
|
||||
|
||||
class UserPlexSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||
"""Plex Source connection Serializer"""
|
||||
|
||||
queryset = UserPlexSourceConnection.objects.all()
|
||||
serializer_class = UserPlexSourceConnectionSerializer
|
||||
|
||||
|
||||
class GroupPlexSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||
"""Plex Group-Source connection Serializer"""
|
||||
|
||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||
model = GroupPlexSourceConnection
|
||||
|
||||
|
||||
class GroupPlexSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||
"""Group-source connection Viewset"""
|
||||
|
||||
queryset = GroupPlexSourceConnection.objects.all()
|
||||
serializer_class = GroupPlexSourceConnectionSerializer
|
||||
|
@ -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",
|
||||
),
|
||||
]
|
@ -141,6 +141,7 @@ class UserPlexSourceConnection(UserSourceConnection):
|
||||
"""Connect user and plex source"""
|
||||
|
||||
plex_token = models.TextField()
|
||||
identifier = models.TextField()
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
|
@ -1,3 +1,5 @@
|
||||
"""SAML Source Serializer"""
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.sources import (
|
||||
@ -10,20 +12,29 @@ from authentik.sources.saml.models import GroupSAMLSourceConnection, UserSAMLSou
|
||||
|
||||
|
||||
class UserSAMLSourceConnectionSerializer(UserSourceConnectionSerializer):
|
||||
"""SAML Source Serializer"""
|
||||
|
||||
class Meta(UserSourceConnectionSerializer.Meta):
|
||||
model = UserSAMLSourceConnection
|
||||
fields = UserSourceConnectionSerializer.Meta.fields + ["identifier"]
|
||||
|
||||
|
||||
class UserSAMLSourceConnectionViewSet(UserSourceConnectionViewSet, ModelViewSet):
|
||||
"""Source Viewset"""
|
||||
|
||||
queryset = UserSAMLSourceConnection.objects.all()
|
||||
serializer_class = UserSAMLSourceConnectionSerializer
|
||||
|
||||
|
||||
class GroupSAMLSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||
"""OAuth Group-Source connection Serializer"""
|
||||
|
||||
class Meta(GroupSourceConnectionSerializer.Meta):
|
||||
model = GroupSAMLSourceConnection
|
||||
|
||||
|
||||
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet, ModelViewSet):
|
||||
class GroupSAMLSourceConnectionViewSet(GroupSourceConnectionViewSet):
|
||||
"""Group-source connection Viewset"""
|
||||
|
||||
queryset = GroupSAMLSourceConnection.objects.all()
|
||||
serializer_class = GroupSAMLSourceConnectionSerializer
|
||||
|
@ -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",
|
||||
),
|
||||
]
|
@ -318,6 +318,8 @@ class SAMLSourcePropertyMapping(PropertyMapping):
|
||||
class UserSAMLSourceConnection(UserSourceConnection):
|
||||
"""Connection to configured SAML Sources."""
|
||||
|
||||
identifier = models.TextField()
|
||||
|
||||
@property
|
||||
def serializer(self) -> Serializer:
|
||||
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionSerializer
|
||||
|
@ -104,13 +104,6 @@ def send_mail(
|
||||
# can't be converted to json)
|
||||
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)
|
||||
backend.send_messages([message_object])
|
||||
Event.new(
|
||||
|
@ -97,37 +97,6 @@ class TestEmailStageSending(FlowTestCase):
|
||||
self.assertEqual(mail.outbox[0].subject, "authentik")
|
||||
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):
|
||||
"""Test with pending (fake) user"""
|
||||
self.flow.designation = FlowDesignation.RECOVERY
|
||||
|
@ -8231,6 +8231,7 @@
|
||||
},
|
||||
"identifier": {
|
||||
"type": "string",
|
||||
"maxLength": 255,
|
||||
"minLength": 1,
|
||||
"title": "Identifier"
|
||||
},
|
||||
|
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1007.0",
|
||||
"aws-cdk": "^2.1006.0",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -17,9 +17,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1007.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1007.0.tgz",
|
||||
"integrity": "sha512-/UOYOTGWUm+pP9qxg03tID5tL6euC+pb+xo0RBue+xhnUWwj/Bbsw6DbqbpOPMrNzTUxmM723/uMEQmM6S26dw==",
|
||||
"version": "2.1006.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1006.0.tgz",
|
||||
"integrity": "sha512-6qYnCt4mBN+3i/5F+FC2yMETkDHY/IL7gt3EuqKVPcaAO4jU7oXfVSlR60CYRkZWL4fnAurUV14RkJuJyVG/IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
@ -10,7 +10,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1007.0",
|
||||
"aws-cdk": "^2.1006.0",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
Binary file not shown.
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.2.3",
|
||||
"version": "2025.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.2.3"
|
||||
"version": "2025.2.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ dependencies = [
|
||||
"pydantic-scim",
|
||||
"pyjwt",
|
||||
"pyrad",
|
||||
"python-kadmin-rs ==0.6.0",
|
||||
"python-kadmin-rs ==0.5.3",
|
||||
"pyyaml",
|
||||
"requests-oauthlib",
|
||||
"scim2-filter-parser",
|
||||
|
545
schema.yml
545
schema.yml
@ -25938,243 +25938,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
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/:
|
||||
get:
|
||||
operationId: sources_group_connections_kerberos_list
|
||||
@ -26236,38 +25999,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
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}/:
|
||||
get:
|
||||
operationId: sources_group_connections_kerberos_retrieve
|
||||
@ -27048,38 +26779,6 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
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}/:
|
||||
get:
|
||||
operationId: sources_group_connections_saml_retrieve
|
||||
@ -30293,7 +29992,7 @@ paths:
|
||||
/sources/user_connections/kerberos/:
|
||||
get:
|
||||
operationId: sources_user_connections_kerberos_list
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
required: false
|
||||
@ -30323,10 +30022,6 @@ paths:
|
||||
name: source__slug
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: user
|
||||
schema:
|
||||
type: integer
|
||||
tags:
|
||||
- sources
|
||||
security:
|
||||
@ -30352,7 +30047,7 @@ paths:
|
||||
description: ''
|
||||
post:
|
||||
operationId: sources_user_connections_kerberos_create
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
tags:
|
||||
- sources
|
||||
requestBody:
|
||||
@ -30385,7 +30080,7 @@ paths:
|
||||
/sources/user_connections/kerberos/{id}/:
|
||||
get:
|
||||
operationId: sources_user_connections_kerberos_retrieve
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30419,7 +30114,7 @@ paths:
|
||||
description: ''
|
||||
put:
|
||||
operationId: sources_user_connections_kerberos_update
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30459,7 +30154,7 @@ paths:
|
||||
description: ''
|
||||
patch:
|
||||
operationId: sources_user_connections_kerberos_partial_update
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30498,7 +30193,7 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: sources_user_connections_kerberos_destroy
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30566,7 +30261,7 @@ paths:
|
||||
/sources/user_connections/oauth/:
|
||||
get:
|
||||
operationId: sources_user_connections_oauth_list
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
required: false
|
||||
@ -30625,7 +30320,7 @@ paths:
|
||||
description: ''
|
||||
post:
|
||||
operationId: sources_user_connections_oauth_create
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
tags:
|
||||
- sources
|
||||
requestBody:
|
||||
@ -30658,7 +30353,7 @@ paths:
|
||||
/sources/user_connections/oauth/{id}/:
|
||||
get:
|
||||
operationId: sources_user_connections_oauth_retrieve
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30691,7 +30386,7 @@ paths:
|
||||
description: ''
|
||||
put:
|
||||
operationId: sources_user_connections_oauth_update
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30730,7 +30425,7 @@ paths:
|
||||
description: ''
|
||||
patch:
|
||||
operationId: sources_user_connections_oauth_partial_update
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30768,7 +30463,7 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: sources_user_connections_oauth_destroy
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30834,7 +30529,7 @@ paths:
|
||||
/sources/user_connections/plex/:
|
||||
get:
|
||||
operationId: sources_user_connections_plex_list
|
||||
description: User-source connection Viewset
|
||||
description: Plex Source connection Serializer
|
||||
parameters:
|
||||
- name: ordering
|
||||
required: false
|
||||
@ -30893,7 +30588,7 @@ paths:
|
||||
description: ''
|
||||
post:
|
||||
operationId: sources_user_connections_plex_create
|
||||
description: User-source connection Viewset
|
||||
description: Plex Source connection Serializer
|
||||
tags:
|
||||
- sources
|
||||
requestBody:
|
||||
@ -30926,7 +30621,7 @@ paths:
|
||||
/sources/user_connections/plex/{id}/:
|
||||
get:
|
||||
operationId: sources_user_connections_plex_retrieve
|
||||
description: User-source connection Viewset
|
||||
description: Plex Source connection Serializer
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30959,7 +30654,7 @@ paths:
|
||||
description: ''
|
||||
put:
|
||||
operationId: sources_user_connections_plex_update
|
||||
description: User-source connection Viewset
|
||||
description: Plex Source connection Serializer
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -30998,7 +30693,7 @@ paths:
|
||||
description: ''
|
||||
patch:
|
||||
operationId: sources_user_connections_plex_partial_update
|
||||
description: User-source connection Viewset
|
||||
description: Plex Source connection Serializer
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -31036,7 +30731,7 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: sources_user_connections_plex_destroy
|
||||
description: User-source connection Viewset
|
||||
description: Plex Source connection Serializer
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -31102,7 +30797,7 @@ paths:
|
||||
/sources/user_connections/saml/:
|
||||
get:
|
||||
operationId: sources_user_connections_saml_list
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- name: ordering
|
||||
required: false
|
||||
@ -31161,7 +30856,7 @@ paths:
|
||||
description: ''
|
||||
post:
|
||||
operationId: sources_user_connections_saml_create
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
tags:
|
||||
- sources
|
||||
requestBody:
|
||||
@ -31194,7 +30889,7 @@ paths:
|
||||
/sources/user_connections/saml/{id}/:
|
||||
get:
|
||||
operationId: sources_user_connections_saml_retrieve
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -31227,7 +30922,7 @@ paths:
|
||||
description: ''
|
||||
put:
|
||||
operationId: sources_user_connections_saml_update
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -31266,7 +30961,7 @@ paths:
|
||||
description: ''
|
||||
patch:
|
||||
operationId: sources_user_connections_saml_partial_update
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -31304,7 +30999,7 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: sources_user_connections_saml_destroy
|
||||
description: User-source connection Viewset
|
||||
description: Source Viewset
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@ -44758,7 +44453,7 @@ components:
|
||||
- users_obj
|
||||
GroupKerberosSourceConnection:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
@ -44780,21 +44475,16 @@ components:
|
||||
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
|
||||
GroupKerberosSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
@ -44894,7 +44584,7 @@ components:
|
||||
- username
|
||||
GroupOAuthSourceConnection:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
@ -44916,21 +44606,16 @@ components:
|
||||
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
|
||||
GroupOAuthSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
@ -44947,7 +44632,7 @@ components:
|
||||
- source
|
||||
GroupPlexSourceConnection:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: Plex Group-Source connection Serializer
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
@ -44969,21 +44654,16 @@ components:
|
||||
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
|
||||
GroupPlexSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: Plex Group-Source connection Serializer
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
@ -45028,7 +44708,7 @@ components:
|
||||
- name
|
||||
GroupSAMLSourceConnection:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
@ -45050,74 +44730,16 @@ components:
|
||||
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
|
||||
GroupSAMLSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
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
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
@ -48743,18 +48365,6 @@ components:
|
||||
required:
|
||||
- pagination
|
||||
- results
|
||||
PaginatedGroupSourceConnectionList:
|
||||
type: object
|
||||
properties:
|
||||
pagination:
|
||||
$ref: '#/components/schemas/Pagination'
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/GroupSourceConnection'
|
||||
required:
|
||||
- pagination
|
||||
- results
|
||||
PaginatedIdentificationStageList:
|
||||
type: object
|
||||
properties:
|
||||
@ -51135,7 +50745,7 @@ components:
|
||||
the remote system.
|
||||
PatchedGroupKerberosSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
@ -51148,7 +50758,7 @@ components:
|
||||
minLength: 1
|
||||
PatchedGroupOAuthSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
@ -51161,7 +50771,7 @@ components:
|
||||
minLength: 1
|
||||
PatchedGroupPlexSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: Plex Group-Source connection Serializer
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
@ -51200,20 +50810,7 @@ components:
|
||||
format: uuid
|
||||
PatchedGroupSAMLSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
format: uuid
|
||||
source:
|
||||
type: string
|
||||
format: uuid
|
||||
identifier:
|
||||
type: string
|
||||
minLength: 1
|
||||
PatchedGroupSourceConnectionRequest:
|
||||
type: object
|
||||
description: Group Source Connection
|
||||
description: OAuth Group-Source connection Serializer
|
||||
properties:
|
||||
group:
|
||||
type: string
|
||||
@ -53186,7 +52783,7 @@ components:
|
||||
$ref: '#/components/schemas/FlowSetRequest'
|
||||
PatchedUserKerberosSourceConnectionRequest:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: Kerberos Source Serializer
|
||||
properties:
|
||||
user:
|
||||
type: integer
|
||||
@ -53243,7 +52840,7 @@ components:
|
||||
$ref: '#/components/schemas/FlowSetRequest'
|
||||
PatchedUserOAuthSourceConnectionRequest:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: OAuth Source Serializer
|
||||
properties:
|
||||
user:
|
||||
type: integer
|
||||
@ -53253,13 +52850,14 @@ components:
|
||||
identifier:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
access_token:
|
||||
type: string
|
||||
writeOnly: true
|
||||
nullable: true
|
||||
PatchedUserPlexSourceConnectionRequest:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: Plex Source connection Serializer
|
||||
properties:
|
||||
user:
|
||||
type: integer
|
||||
@ -53313,7 +52911,7 @@ components:
|
||||
$ref: '#/components/schemas/UserTypeEnum'
|
||||
PatchedUserSAMLSourceConnectionRequest:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: SAML Source Serializer
|
||||
properties:
|
||||
user:
|
||||
type: integer
|
||||
@ -53332,9 +52930,6 @@ components:
|
||||
source:
|
||||
type: string
|
||||
format: uuid
|
||||
identifier:
|
||||
type: string
|
||||
minLength: 1
|
||||
PatchedUserWriteStageRequest:
|
||||
type: object
|
||||
description: UserWriteStage Serializer
|
||||
@ -58422,7 +58017,7 @@ components:
|
||||
- name
|
||||
UserKerberosSourceConnection:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: Kerberos Source Serializer
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
@ -58437,27 +58032,22 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Source'
|
||||
readOnly: true
|
||||
identifier:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
last_updated:
|
||||
identifier:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- created
|
||||
- identifier
|
||||
- last_updated
|
||||
- pk
|
||||
- source
|
||||
- source_obj
|
||||
- user
|
||||
UserKerberosSourceConnectionRequest:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: Kerberos Source Serializer
|
||||
properties:
|
||||
user:
|
||||
type: integer
|
||||
@ -58684,7 +58274,7 @@ components:
|
||||
- logins_failed
|
||||
UserOAuthSourceConnection:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: OAuth Source Serializer
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
@ -58699,27 +58289,23 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Source'
|
||||
readOnly: true
|
||||
identifier:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
last_updated:
|
||||
identifier:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
maxLength: 255
|
||||
required:
|
||||
- created
|
||||
- identifier
|
||||
- last_updated
|
||||
- pk
|
||||
- source
|
||||
- source_obj
|
||||
- user
|
||||
UserOAuthSourceConnectionRequest:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: OAuth Source Serializer
|
||||
properties:
|
||||
user:
|
||||
type: integer
|
||||
@ -58729,6 +58315,7 @@ components:
|
||||
identifier:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
access_token:
|
||||
type: string
|
||||
writeOnly: true
|
||||
@ -58786,7 +58373,7 @@ components:
|
||||
- paths
|
||||
UserPlexSourceConnection:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: Plex Source connection Serializer
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
@ -58801,27 +58388,22 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Source'
|
||||
readOnly: true
|
||||
identifier:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
last_updated:
|
||||
identifier:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- created
|
||||
- identifier
|
||||
- last_updated
|
||||
- pk
|
||||
- source
|
||||
- source_obj
|
||||
- user
|
||||
UserPlexSourceConnectionRequest:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: Plex Source connection Serializer
|
||||
properties:
|
||||
user:
|
||||
type: integer
|
||||
@ -58883,7 +58465,7 @@ components:
|
||||
- username
|
||||
UserSAMLSourceConnection:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: SAML Source Serializer
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
@ -58898,27 +58480,22 @@ components:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Source'
|
||||
readOnly: true
|
||||
identifier:
|
||||
type: string
|
||||
created:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
last_updated:
|
||||
identifier:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- created
|
||||
- identifier
|
||||
- last_updated
|
||||
- pk
|
||||
- source
|
||||
- source_obj
|
||||
- user
|
||||
UserSAMLSourceConnectionRequest:
|
||||
type: object
|
||||
description: User source connection
|
||||
description: SAML Source Serializer
|
||||
properties:
|
||||
user:
|
||||
type: integer
|
||||
@ -59082,20 +58659,12 @@ components:
|
||||
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
|
||||
- identifier
|
||||
- last_updated
|
||||
- pk
|
||||
- source
|
||||
- source_obj
|
||||
@ -59109,11 +58678,7 @@ components:
|
||||
source:
|
||||
type: string
|
||||
format: uuid
|
||||
identifier:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- identifier
|
||||
- source
|
||||
- user
|
||||
UserTypeEnum:
|
||||
|
18
uv.lock
generated
18
uv.lock
generated
@ -310,7 +310,7 @@ requires-dist = [
|
||||
{ name = "pydantic-scim" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pyrad" },
|
||||
{ name = "python-kadmin-rs", specifier = "==0.6.0" },
|
||||
{ name = "python-kadmin-rs", specifier = "==0.5.3" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests-oauthlib" },
|
||||
{ name = "scim2-filter-parser" },
|
||||
@ -2599,16 +2599,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-kadmin-rs"
|
||||
version = "0.6.0"
|
||||
version = "0.5.3"
|
||||
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 = [
|
||||
{ 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/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/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/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/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/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/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/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/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/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/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/9a/65/705f179cf4bf4d16fc1daeac0810def57da2f4514a5b79ca60f24d7efb90/python_kadmin_rs-0.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6799a0faddb4ccf200acfa87da38e5fa2af54970d066b2c876e752bbf794b204", size = 1590360 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
@ -48,9 +48,6 @@ export default [
|
||||
"lit/no-template-bind": "error",
|
||||
"no-unused-vars": "off",
|
||||
"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/no-unused-vars": [
|
||||
"error",
|
||||
@ -74,18 +71,8 @@ export default [
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
files: [
|
||||
// TODO:Remove after project-wide ESLint config is properly set up.
|
||||
"scripts/**/*.mjs",
|
||||
"authentication/**/*.js",
|
||||
"sfe/**/*.js",
|
||||
"*.ts",
|
||||
"*.mjs",
|
||||
],
|
||||
files: ["scripts/**/*.mjs", "*.ts", "*.mjs"],
|
||||
rules: {
|
||||
"no-undef": "off",
|
||||
// TODO: TypeScript already handles this.
|
||||
// Remove after project-wide ESLint config is properly set up.
|
||||
"no-unused-vars": "off",
|
||||
// We WANT our scripts to output to the console!
|
||||
"no-console": "off",
|
||||
|
1914
web/package-lock.json
generated
1914
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -57,14 +57,9 @@
|
||||
"ts-pattern": "^5.4.0",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"webcomponent-qr-code": "^1.2.0",
|
||||
"yaml": "^2.5.1",
|
||||
"bootstrap": "^4.6.1",
|
||||
"formdata-polyfill": "^4.0.10",
|
||||
"jquery": "^3.7.1",
|
||||
"weakmap-polyfill": "^2.0.4"
|
||||
"yaml": "^2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jquery": "^3.5.31",
|
||||
"@eslint/js": "^9.11.1",
|
||||
"@hcaptcha/types": "^1.0.4",
|
||||
"@lit/localize-tools": "^0.8.0",
|
||||
@ -95,8 +90,6 @@
|
||||
"@wdio/spec-reporter": "^9.1.2",
|
||||
"chromedriver": "^131.0.1",
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-copy": "^2.1.1",
|
||||
"esbuild-plugin-es5": "^2.1.1",
|
||||
"esbuild-plugin-polyfill-node": "^0.3.0",
|
||||
"esbuild-plugins-node-modules-polyfill": "^1.7.0",
|
||||
"eslint": "^9.11.1",
|
||||
@ -168,12 +161,6 @@
|
||||
"watch": "run-s build-locales esbuild:watch"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
"./paths": "./paths.js",
|
||||
"./authentication": "./authentication/index.js",
|
||||
"./scripts/*": "./scripts/*.mjs"
|
||||
},
|
||||
"wireit": {
|
||||
"build": {
|
||||
"#comment": [
|
||||
@ -206,7 +193,8 @@
|
||||
"./dist/patternfly.min.css"
|
||||
],
|
||||
"dependencies": [
|
||||
"build-locales"
|
||||
"build-locales",
|
||||
"./packages/sfe:build"
|
||||
],
|
||||
"env": {
|
||||
"NODE_RUNNER": {
|
||||
@ -216,7 +204,12 @@
|
||||
}
|
||||
},
|
||||
"build:sfe": {
|
||||
"command": "node scripts/build-sfe.mjs"
|
||||
"dependencies": [
|
||||
"./packages/sfe:build"
|
||||
],
|
||||
"files": [
|
||||
"./packages/sfe/**/*.ts"
|
||||
]
|
||||
},
|
||||
"build-proxy": {
|
||||
"command": "node scripts/build-web.mjs --proxy",
|
||||
@ -249,6 +242,11 @@
|
||||
"lint:package"
|
||||
]
|
||||
},
|
||||
"format:packages": {
|
||||
"dependencies": [
|
||||
"./packages/sfe:prettier"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"command": "eslint --max-warnings 0 --fix",
|
||||
"env": {
|
||||
@ -276,6 +274,11 @@
|
||||
"shell": true,
|
||||
"command": "sh ./scripts/lint-lockfile.sh package-lock.json"
|
||||
},
|
||||
"lint:lockfiles": {
|
||||
"dependencies": [
|
||||
"./packages/sfe:lint:lockfile"
|
||||
]
|
||||
},
|
||||
"lint:package": {
|
||||
"command": "syncpack format -i ' '"
|
||||
},
|
||||
@ -311,7 +314,9 @@
|
||||
"lint:spelling",
|
||||
"lint:package",
|
||||
"lint:lockfile",
|
||||
"lint:precommit"
|
||||
"lint:lockfiles",
|
||||
"lint:precommit",
|
||||
"format:packages"
|
||||
]
|
||||
},
|
||||
"prettier": {
|
||||
|
23
web/packages/sfe/.prettierrc.json
Normal file
23
web/packages/sfe/.prettierrc.json
Normal 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"]
|
||||
}
|
18
web/packages/sfe/LICENSE.txt
Normal file
18
web/packages/sfe/LICENSE.txt
Normal 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.
|
68
web/packages/sfe/package.json
Normal file
68
web/packages/sfe/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
43
web/packages/sfe/rollup.config.js
Normal file
43
web/packages/sfe/rollup.config.js
Normal 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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
527
web/packages/sfe/src/index.ts
Normal file
527
web/packages/sfe/src/index.ts
Normal 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();
|
7
web/packages/sfe/tsconfig.json
Normal file
7
web/packages/sfe/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["jquery"],
|
||||
"esModuleInterop": true,
|
||||
"lib": ["DOM", "ES2015", "ES2017"]
|
||||
}
|
||||
}
|
25
web/paths.js
25
web/paths.js
@ -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")
|
||||
);
|
@ -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);
|
||||
});
|
@ -1,4 +1,3 @@
|
||||
import { DistDirectory, PackageRoot } from "@goauthentik/web/paths";
|
||||
import { execFileSync } from "child_process";
|
||||
import { deepmerge } from "deepmerge-ts";
|
||||
import esbuild from "esbuild";
|
||||
@ -171,7 +170,7 @@ function composeVersionID() {
|
||||
* @throws {Error} on build failure
|
||||
*/
|
||||
function createEntryPointOptions([source, dest], overrides = {}) {
|
||||
const outdir = path.join(DistDirectory, dest);
|
||||
const outdir = path.join(__dirname, "..", "dist", dest);
|
||||
|
||||
/**
|
||||
* @type {esbuild.BuildOptions}
|
||||
@ -234,7 +233,7 @@ async function doWatch() {
|
||||
buildObserverPlugin({
|
||||
serverURL,
|
||||
logPrefix: entryPoint[1],
|
||||
relativeRoot: PackageRoot,
|
||||
relativeRoot: path.join(__dirname, ".."),
|
||||
}),
|
||||
],
|
||||
define: {
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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");
|
||||
}
|
||||
}
|
@ -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";
|
@ -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);
|
||||
}
|
@ -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();
|
@ -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"
|
||||
]
|
||||
}
|
@ -19,7 +19,7 @@ import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthent
|
||||
@customElement("ak-about-modal")
|
||||
export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) {
|
||||
static get styles() {
|
||||
return ModalButton.styles.concat(
|
||||
return super.styles.concat(
|
||||
PFAbout,
|
||||
css`
|
||||
.pf-c-about-modal-box__hero {
|
||||
|
@ -16,8 +16,8 @@ import "@goauthentik/elements/messages/MessageContainer";
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import "@goauthentik/elements/notifications/APIDrawer";
|
||||
import "@goauthentik/elements/notifications/NotificationDrawer";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import "@goauthentik/elements/router/RouterOutlet";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import "@goauthentik/elements/sidebar/Sidebar";
|
||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
||||
|
||||
@ -37,10 +37,10 @@ import "./AdminSidebar";
|
||||
@customElement("ak-interface-admin")
|
||||
export class AdminInterface extends AuthenticatedInterface {
|
||||
@property({ type: Boolean })
|
||||
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
notificationDrawerOpen = getRouteParameter("notificationDrawerOpen", false);
|
||||
|
||||
@property({ type: Boolean })
|
||||
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
|
||||
apiDrawerOpen = getRouteParameter("apiDrawerOpen", false);
|
||||
|
||||
ws: WebsocketClient;
|
||||
|
||||
@ -93,14 +93,14 @@ export class AdminInterface extends AuthenticatedInterface {
|
||||
|
||||
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
|
||||
this.notificationDrawerOpen = !this.notificationDrawerOpen;
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
notificationDrawerOpen: this.notificationDrawerOpen,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
|
||||
this.apiDrawerOpen = !this.apiDrawerOpen;
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
apiDrawerOpen: this.apiDrawerOpen,
|
||||
});
|
||||
});
|
||||
@ -123,7 +123,7 @@ export class AdminInterface extends AuthenticatedInterface {
|
||||
super.connectedCallback();
|
||||
|
||||
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);
|
||||
}
|
||||
@ -158,7 +158,7 @@ export class AdminInterface extends AuthenticatedInterface {
|
||||
class="pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
defaultUrl="/administration/overview"
|
||||
defaultURL="/administration/overview"
|
||||
.routes=${ROUTES}
|
||||
>
|
||||
</ak-router-outlet>
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
WithCapabilitiesConfig,
|
||||
} from "@goauthentik/elements/Interface/capabilitiesProvider";
|
||||
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 { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
@ -95,62 +95,127 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
|
||||
}
|
||||
|
||||
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 = [
|
||||
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,
|
||||
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[],
|
||||
];
|
||||
|
||||
// prettier-ignore
|
||||
const sidebarContent: SidebarEntry[] = [
|
||||
[null, msg("Dashboards"), { "?expanded": true }, [
|
||||
["/administration/overview", msg("Overview")],
|
||||
["/administration/dashboard/users", msg("User Statistics")],
|
||||
["/administration/system-tasks", msg("System Tasks")]]],
|
||||
[null, msg("Applications"), null, [
|
||||
["/core/applications", msg("Applications"), [`^/core/applications/(?<slug>${SLUG_REGEX})$`]],
|
||||
["/core/providers", msg("Providers"), [`^/core/providers/(?<id>${ID_REGEX})$`]],
|
||||
["/outpost/outposts", msg("Outposts")]]],
|
||||
[null, msg("Events"), null, [
|
||||
["/events/log", msg("Logs"), [`^/events/log/(?<id>${UUID_REGEX})$`]],
|
||||
["/events/rules", msg("Notification Rules")],
|
||||
["/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_REGEX})$`]],
|
||||
["/flow/stages", msg("Stages")],
|
||||
["/flow/stages/prompts", msg("Prompts")]]],
|
||||
[null, msg("Directory"), null, [
|
||||
["/identity/users", msg("Users"), [`^/identity/users/(?<id>${ID_REGEX})$`]],
|
||||
["/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")],
|
||||
["/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")]]],
|
||||
// ---
|
||||
[
|
||||
null,
|
||||
msg("Dashboards"),
|
||||
{ "?expanded": true },
|
||||
[
|
||||
["/administration/overview", msg("Overview")],
|
||||
["/administration/dashboard/users", msg("User Statistics")],
|
||||
["/administration/system-tasks", msg("System Tasks")],
|
||||
],
|
||||
],
|
||||
[
|
||||
null,
|
||||
msg("Applications"),
|
||||
null,
|
||||
[
|
||||
[
|
||||
"/core/applications",
|
||||
msg("Applications"),
|
||||
[`/core/applications/:slug(${SLUG_PATTERN})`],
|
||||
],
|
||||
["/core/providers", msg("Providers"), [`/core/providers/:id(${ID_PATTERN})`]],
|
||||
["/outpost/outposts", msg("Outposts")],
|
||||
],
|
||||
],
|
||||
[
|
||||
null,
|
||||
msg("Events"),
|
||||
null,
|
||||
[
|
||||
["/events/log", msg("Logs"), [`/events/log/:id(${UUID_PATTERN})`]],
|
||||
["/events/rules", msg("Notification Rules")],
|
||||
["/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
|
||||
type SidebarRenderer = (_: SidebarEntry) => TemplateResult;
|
||||
|
||||
const renderOneSidebarItem: SidebarRenderer = ([path, label, attributes, children]) => {
|
||||
const renderOneSidebarItem: SidebarRenderer = ([pathname, label, attributes, children]) => {
|
||||
const properties = Array.isArray(attributes)
|
||||
? { ".activeWhen": attributes }
|
||||
: (attributes ?? {});
|
||||
if (path) {
|
||||
properties.path = path;
|
||||
|
||||
if (pathname) {
|
||||
properties.pathname = pathname;
|
||||
}
|
||||
|
||||
return html`<ak-sidebar-item ${spread(properties)}>
|
||||
${label ? html`<span slot="label">${label}</span>` : nothing}
|
||||
${map(children, renderOneSidebarItem)}
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/PageHeader";
|
||||
@ -55,12 +54,10 @@ export class DebugPage extends AKElement {
|
||||
message: "Success",
|
||||
});
|
||||
})
|
||||
.catch(async (error) => {
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
|
||||
.catch((exc) => {
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: pluckErrorDetail(parsedError),
|
||||
message: exc,
|
||||
});
|
||||
});
|
||||
}}
|
||||
|
@ -1,155 +1,210 @@
|
||||
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";
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
interface IDParameters {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface SlugParameters {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
interface UUIDParameters {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export const ROUTES = [
|
||||
// Prevent infinite Shell loops
|
||||
new Route(new RegExp("^/$")).redirect("/administration/overview"),
|
||||
new Route(new RegExp("^#.*")).redirect("/administration/overview"),
|
||||
new Route(new RegExp("^/library$")).redirect("/if/user/", true),
|
||||
Route.redirect("^/$", "/administration/overview"),
|
||||
Route.redirect("^#.*", "/administration/overview"),
|
||||
Route.redirect("^/library$", "/if/user/", true),
|
||||
// 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>`;
|
||||
}),
|
||||
new Route(new RegExp("^/administration/dashboard/users$"), async () => {
|
||||
new Route("/administration/dashboard/users", async () => {
|
||||
await import("@goauthentik/admin/admin-overview/DashboardUserPage");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
return html`<ak-provider-list></ak-provider-list>`;
|
||||
}),
|
||||
new Route(new RegExp(`^/core/providers/(?<id>${ID_REGEX})$`), async (args) => {
|
||||
await import("@goauthentik/admin/providers/ProviderViewPage");
|
||||
return html`<ak-provider-view .providerID=${parseInt(args.id, 10)}></ak-provider-view>`;
|
||||
}),
|
||||
new Route(new RegExp("^/core/applications$"), async () => {
|
||||
new Route<IDParameters>(
|
||||
new URLPattern({
|
||||
pathname: `/core/providers/:id(${ID_PATTERN})`,
|
||||
}),
|
||||
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");
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
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");
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
return html`<ak-blueprint-list></ak-blueprint-list>`;
|
||||
}),
|
||||
new Route(new RegExp("^/debug$"), async () => {
|
||||
new Route("/debug", async () => {
|
||||
await import("@goauthentik/admin/DebugPage");
|
||||
|
||||
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");
|
||||
|
||||
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
|
||||
}),
|
||||
];
|
||||
] satisfies Route<never>[];
|
||||
|
@ -16,7 +16,7 @@ import "@goauthentik/elements/PageHeader";
|
||||
import "@goauthentik/elements/cards/AggregatePromiseCard";
|
||||
import "@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 { CSSResult, TemplateResult, css, html, nothing } from "lit";
|
||||
@ -79,10 +79,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
||||
}
|
||||
|
||||
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("Manage users"), paramURL("/identity/users")],
|
||||
[msg("Manage users"), formatRouteHash("/identity/users")],
|
||||
[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 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("Manage users"), paramURL("/identity/users")],
|
||||
[msg("Manage users"), formatRouteHash("/identity/users")],
|
||||
[msg("Check the release notes"), `https://goauthentik.io/docs/releases/${release}`],
|
||||
];
|
||||
|
||||
|
@ -1,16 +1,13 @@
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import {
|
||||
APIError,
|
||||
parseAPIResponseError,
|
||||
pluckErrorDetail,
|
||||
} from "@goauthentik/common/errors/network";
|
||||
import { AggregateCard } from "@goauthentik/elements/cards/AggregateCard";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { PropertyValues, TemplateResult, html, nothing } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
|
||||
import { ResponseError } from "@goauthentik/api";
|
||||
|
||||
export interface AdminStatus {
|
||||
icon: string;
|
||||
message?: TemplateResult;
|
||||
@ -32,7 +29,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
|
||||
// Current error state if any request fails
|
||||
@state()
|
||||
protected error?: APIError;
|
||||
protected error?: string;
|
||||
|
||||
// Abstract methods to be implemented by subclasses
|
||||
abstract getPrimaryValue(): Promise<T>;
|
||||
@ -62,9 +59,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
this.value = value; // Triggers shouldUpdate
|
||||
this.error = undefined;
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
.catch((err: ResponseError) => {
|
||||
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.error = undefined;
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
.catch((err: ResponseError) => {
|
||||
this.status = undefined;
|
||||
this.error = await parseAPIResponseError(error);
|
||||
this.error = err?.response?.statusText ?? msg("Unknown error");
|
||||
});
|
||||
|
||||
// Prevent immediate re-render if only value changed
|
||||
@ -123,8 +120,8 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
*/
|
||||
private renderError(error: string): TemplateResult {
|
||||
return html`
|
||||
<p><i class="fa fa-times"></i> ${msg("Failed to fetch")}</p>
|
||||
<p class="subtext">${error}</p>
|
||||
<p><i class="fa fa-times"></i> ${error}</p>
|
||||
<p class="subtext">${msg("Failed to fetch")}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -149,7 +146,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
this.status
|
||||
? this.renderStatus(this.status) // Status available
|
||||
: this.error
|
||||
? this.renderError(pluckErrorDetail(this.error)) // Error state
|
||||
? this.renderError(this.error) // Error state
|
||||
: this.renderLoading() // Loading state
|
||||
}
|
||||
</p>
|
||||
|
@ -10,7 +10,6 @@ import "@goauthentik/elements/buttons/ModalButton";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import { PaginatedResponse } 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 { CSSResult, TemplateResult, css, html } from "lit";
|
||||
@ -69,7 +68,7 @@ export class RecentEventsCard extends Table<Event> {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
row(item: EventWithContext): SlottedTemplateResult[] {
|
||||
row(item: EventWithContext): TemplateResult[] {
|
||||
return [
|
||||
html`<div><a href="${`#/events/log/${item.pk}`}">${actionToLabel(item.action)}</a></div>
|
||||
<small>${item.app}</small>`,
|
||||
@ -82,11 +81,7 @@ export class RecentEventsCard extends Table<Event> {
|
||||
];
|
||||
}
|
||||
|
||||
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
|
||||
if (this.error) {
|
||||
return super.renderEmpty(inner);
|
||||
}
|
||||
|
||||
renderEmpty(): TemplateResult {
|
||||
return super.renderEmpty(
|
||||
html`<ak-empty-state header=${msg("No Events found.")}>
|
||||
<div slot="body">${msg("No matching events could be found.")}</div>
|
||||
|
@ -7,7 +7,7 @@ import "@goauthentik/elements/ak-mdx";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
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 { TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import { TablePage } from "@goauthentik/elements/table/TablePage";
|
||||
@ -71,7 +71,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
}
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return TablePage.styles.concat(PFCard, applicationListStyle);
|
||||
return super.styles.concat(PFCard, applicationListStyle);
|
||||
}
|
||||
|
||||
columns(): TableColumn[] {
|
||||
@ -156,7 +156,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
}
|
||||
|
||||
renderObjectCreate(): TemplateResult {
|
||||
return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}>
|
||||
return html` <ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
|
||||
<button
|
||||
slot="trigger"
|
||||
class="pf-c-button pf-m-primary"
|
||||
@ -165,7 +165,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
${msg("Create with Provider")}
|
||||
</button>
|
||||
</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="header"> ${msg("Create Application")} </span>
|
||||
<ak-application-form slot="form"> </ak-application-form>
|
||||
|
@ -8,7 +8,7 @@ import "@goauthentik/components/ak-hint/ak-hint-body";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/Label";
|
||||
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 { css, html } from "lit";
|
||||
@ -110,7 +110,7 @@ export class AkApplicationWizardHint extends AKElement implements ShowHintContro
|
||||
the same time with our new Application Wizard.
|
||||
<!-- <a href="(link to docs)">Learn more about the wizard here.</a> -->
|
||||
</p>
|
||||
<ak-application-wizard .open=${getURLParam("createWizard", false)}>
|
||||
<ak-application-wizard .open=${getRouteParameter("createWizard", false)}>
|
||||
<button
|
||||
slot="trigger"
|
||||
class="pf-c-button pf-m-primary"
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { styles } from "@goauthentik/admin/applications/wizard/ApplicationWizardFormStepStyles.css.js";
|
||||
import { WizardStep } from "@goauthentik/components/ak-wizard/WizardStep.js";
|
||||
import {
|
||||
NavigationEventInit,
|
||||
NavigationUpdate,
|
||||
WizardNavigationEvent,
|
||||
WizardUpdateEvent,
|
||||
} from "@goauthentik/components/ak-wizard/events";
|
||||
@ -14,9 +14,9 @@ import { property, query } from "lit/decorators.js";
|
||||
import { ValidationError } from "@goauthentik/api";
|
||||
|
||||
import {
|
||||
ApplicationTransactionValidationError,
|
||||
type ApplicationWizardState,
|
||||
type ApplicationWizardStateUpdate,
|
||||
ExtendedValidationError,
|
||||
} from "./types";
|
||||
|
||||
export class ApplicationWizardStep extends WizardStep {
|
||||
@ -48,7 +48,7 @@ export class ApplicationWizardStep extends WizardStep {
|
||||
}
|
||||
|
||||
protected removeErrors(
|
||||
keyToDelete: keyof ApplicationTransactionValidationError,
|
||||
keyToDelete: keyof ExtendedValidationError,
|
||||
): ValidationError | undefined {
|
||||
if (!this.wizard.errors) {
|
||||
return undefined;
|
||||
@ -71,7 +71,7 @@ export class ApplicationWizardStep extends WizardStep {
|
||||
public handleUpdate(
|
||||
update?: ApplicationWizardStateUpdate,
|
||||
destination?: string,
|
||||
enable?: NavigationEventInit,
|
||||
enable?: NavigationUpdate,
|
||||
) {
|
||||
// Inform ApplicationWizard of content state
|
||||
if (update) {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
|
||||
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.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 "@goauthentik/components/ak-radio-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 "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { isSlug } from "@goauthentik/elements/router";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
|
@ -1,10 +1,9 @@
|
||||
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
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 { type WizardButton } from "@goauthentik/components/ak-wizard/types";
|
||||
import { showAPIErrorMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
import { P, match } from "ts-pattern";
|
||||
|
||||
@ -31,11 +30,10 @@ import {
|
||||
type TransactionApplicationRequest,
|
||||
type TransactionApplicationResponse,
|
||||
type TransactionPolicyBindingRequest,
|
||||
instanceOfValidationError,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { ApplicationWizardStep } from "../ApplicationWizardStep.js";
|
||||
import { OneOfProvider, isApplicationTransactionValidationError } from "../types.js";
|
||||
import { ExtendedValidationError, OneOfProvider } from "../types.js";
|
||||
import { providerRenderers } from "./SubmitStepOverviewRenderers.js";
|
||||
|
||||
const _submitStates = ["reviewing", "running", "submitted"] as const;
|
||||
@ -133,46 +131,39 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
|
||||
|
||||
this.state = "running";
|
||||
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreTransactionalApplicationsUpdate({
|
||||
transactionApplicationRequest: request,
|
||||
})
|
||||
.then((_response: TransactionApplicationResponse) => {
|
||||
this.dispatchCustomEvent(EVENT_REFRESH);
|
||||
this.state = "submitted";
|
||||
})
|
||||
return (
|
||||
new CoreApi(DEFAULT_CONFIG)
|
||||
.coreTransactionalApplicationsUpdate({
|
||||
transactionApplicationRequest: request,
|
||||
})
|
||||
.then((_response: TransactionApplicationResponse) => {
|
||||
this.dispatchCustomEvent(EVENT_REFRESH);
|
||||
this.state = "submitted";
|
||||
})
|
||||
|
||||
.catch(async (error) => {
|
||||
const parsedError = await parseAPIResponseError(error);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.catch(async (resolution: any) => {
|
||||
const errors = (await parseAPIError(
|
||||
await resolution,
|
||||
)) as ExtendedValidationError;
|
||||
|
||||
if (!instanceOfValidationError(parsedError)) {
|
||||
showAPIErrorMessage(parsedError);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isApplicationTransactionValidationError(parsedError)) {
|
||||
// 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.
|
||||
// We have to move that to the `provider.name` error field so it shows up in the right place.
|
||||
if (Array.isArray(parsedError.app?.provider)) {
|
||||
const providerError = parsedError.app.provider;
|
||||
|
||||
parsedError.provider = {
|
||||
...parsedError.provider,
|
||||
name: providerError,
|
||||
};
|
||||
|
||||
delete parsedError.app.provider;
|
||||
|
||||
if (Object.keys(parsedError.app).length === 0) {
|
||||
delete parsedError.app;
|
||||
// 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. We
|
||||
// have to move that to the `provider.name` error field so it shows up in the
|
||||
// right place.
|
||||
if (Array.isArray(errors?.app?.provider)) {
|
||||
const providerError = errors.app.provider;
|
||||
errors.provider = errors.provider ?? {};
|
||||
errors.provider.name = providerError;
|
||||
delete errors.app.provider;
|
||||
if (Object.keys(errors.app).length === 0) {
|
||||
delete errors.app;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.handleUpdate({ errors: parsedError });
|
||||
this.state = "reviewing";
|
||||
});
|
||||
this.handleUpdate({ errors });
|
||||
this.state = "reviewing";
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override handleButton(button: WizardButton) {
|
||||
@ -234,20 +225,22 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
|
||||
}
|
||||
|
||||
renderError() {
|
||||
const { errors } = this.wizard;
|
||||
|
||||
if (Object.keys(errors).length === 0) return nothing;
|
||||
if (Object.keys(this.wizard.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" />
|
||||
${match(errors)
|
||||
${match(errors as ExtendedValidationError)
|
||||
.with(
|
||||
{ app: P.nonNullable },
|
||||
() =>
|
||||
html`<p>${msg("There was an error in the application.")}</p>
|
||||
<p>
|
||||
<a @click=${WizardNavigationEvent.toListener(this, "application")}>
|
||||
${msg("Review the application.")}
|
||||
</a>
|
||||
<a @click=${navTo("application")}
|
||||
>${msg("Review the application.")}</a
|
||||
>
|
||||
</p>`,
|
||||
)
|
||||
.with(
|
||||
@ -255,20 +248,13 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
|
||||
() =>
|
||||
html`<p>${msg("There was an error in the provider.")}</p>
|
||||
<p>
|
||||
<a @click=${WizardNavigationEvent.toListener(this, "provider")}
|
||||
>${msg("Review the provider.")}</a
|
||||
>
|
||||
<a @click=${navTo("provider")}>${msg("Review the provider.")}</a>
|
||||
</p>`,
|
||||
)
|
||||
.with(
|
||||
{ detail: P.nonNullable },
|
||||
() =>
|
||||
html`<p>
|
||||
${msg(
|
||||
"There was an error. Please go back and review the application.",
|
||||
)}:
|
||||
${errors.detail}
|
||||
</p>`,
|
||||
`<p>${msg("There was an error. Please go back and review the application.")}: ${errors.detail}</p>`,
|
||||
)
|
||||
.with(
|
||||
{
|
||||
@ -278,7 +264,7 @@ export class ApplicationWizardSubmitStep extends CustomEmitterElement(Applicatio
|
||||
html`<p>${msg("There was an error:")}:</p>
|
||||
<ul>
|
||||
${(errors.nonFieldErrors ?? []).map(
|
||||
(reason) => html`<li>${reason}</li>`,
|
||||
(e: string) => html`<li>${e}</li>`,
|
||||
)}
|
||||
</ul>
|
||||
<p>${msg("Please go back and review the application.")}</p>`,
|
||||
|
@ -9,7 +9,7 @@ import { customElement, state } from "lit/decorators.js";
|
||||
import { OAuth2ProviderRequest, SourcesApi } 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";
|
||||
|
||||
@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) => {
|
||||
this.showClientSecret = show;
|
||||
};
|
||||
|
@ -25,30 +25,16 @@ export type OneOfProvider =
|
||||
|
||||
export type ValidationRecord = { [key: string]: string[] };
|
||||
|
||||
/**
|
||||
* An error that occurs during the creation or modification of an application.
|
||||
*
|
||||
* @todo (Elf) Extend this type to include all possible errors that can occur during the creation or modification of an application.
|
||||
*/
|
||||
export interface ApplicationTransactionValidationError extends ValidationError {
|
||||
// TODO: Elf, extend this type and apply it to every object in the wizard. Then run
|
||||
// the type-checker again.
|
||||
|
||||
export type ExtendedValidationError = ValidationError & {
|
||||
app?: ValidationRecord;
|
||||
provider?: ValidationRecord;
|
||||
bindings?: ValidationRecord;
|
||||
detail?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
detail?: any;
|
||||
};
|
||||
|
||||
// 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
|
||||
@ -63,7 +49,7 @@ export interface ApplicationWizardState {
|
||||
proxyMode: ProxyMode;
|
||||
bindings: PolicyBinding[];
|
||||
currentBinding: number;
|
||||
errors: ValidationError | ApplicationTransactionValidationError;
|
||||
errors: ExtendedValidationError;
|
||||
}
|
||||
|
||||
export interface ApplicationWizardStateUpdate {
|
||||
|
@ -8,7 +8,6 @@ import "@goauthentik/components/ak-event-info";
|
||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||
import { TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import { TablePage } from "@goauthentik/elements/table/TablePage";
|
||||
import { SlottedTemplateResult } from "@goauthentik/elements/types";
|
||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@ -73,7 +72,7 @@ export class EventListPage extends TablePage<Event> {
|
||||
`;
|
||||
}
|
||||
|
||||
row(item: EventWithContext): SlottedTemplateResult[] {
|
||||
row(item: EventWithContext): TemplateResult[] {
|
||||
return [
|
||||
html`<div>${actionToLabel(item.action)}</div>
|
||||
<small>${item.app}</small>`,
|
||||
|
@ -1,31 +1,27 @@
|
||||
import { EventWithContext } from "@goauthentik/common/events";
|
||||
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 { html, nothing } from "lit";
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
/**
|
||||
* Given event with a geographical context, format it into a string for display.
|
||||
*/
|
||||
export function EventGeo(event: EventWithContext): SlottedTemplateResult {
|
||||
if (!event.context.geo) return nothing;
|
||||
|
||||
const { city, country, continent } = event.context.geo;
|
||||
|
||||
const parts = [city, country, continent].filter(Boolean);
|
||||
|
||||
return html`${parts.join(", ")}`;
|
||||
export function EventGeo(event: EventWithContext): TemplateResult {
|
||||
let geo: KeyUnknown | undefined = undefined;
|
||||
if (Object.hasOwn(event.context, "geo")) {
|
||||
geo = event.context.geo as KeyUnknown;
|
||||
const parts = [geo.city, geo.country, geo.continent].filter(
|
||||
(v) => v !== "" && v !== undefined,
|
||||
);
|
||||
return html`${parts.join(", ")}`;
|
||||
}
|
||||
return html``;
|
||||
}
|
||||
|
||||
export function EventUser(
|
||||
event: EventWithContext,
|
||||
truncateUsername?: number,
|
||||
): SlottedTemplateResult {
|
||||
if (!event.user.username) return html`-`;
|
||||
|
||||
let body: SlottedTemplateResult = nothing;
|
||||
|
||||
export function EventUser(event: EventWithContext, truncateUsername?: number): TemplateResult {
|
||||
if (!event.user.username) {
|
||||
return html`-`;
|
||||
}
|
||||
let body = html``;
|
||||
if (event.user.is_anonymous) {
|
||||
body = html`<div>${msg("Anonymous user")}</div>`;
|
||||
} else {
|
||||
@ -37,14 +33,12 @@ export function EventUser(
|
||||
>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (event.user.on_behalf_of) {
|
||||
return html`${body}<small>
|
||||
body = html`${body}<small>
|
||||
<a href="#/identity/users/${event.user.on_behalf_of.pk}"
|
||||
>${msg(str`On behalf of ${event.user.on_behalf_of.username}`)}</a
|
||||
>
|
||||
</small>`;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
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/elements/events/LogViewer";
|
||||
import { Form } from "@goauthentik/elements/forms/Form";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import "@goauthentik/admin/flows/FlowForm";
|
||||
import "@goauthentik/admin/flows/FlowImportForm";
|
||||
import { DesignationToLabel } from "@goauthentik/admin/flows/utils";
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { DesignationToLabel, formatFlowURL } from "@goauthentik/admin/flows/utils";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { groupBy } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import "@goauthentik/elements/forms/ConfirmationForm";
|
||||
@ -107,10 +107,9 @@ export class FlowListPage extends TablePage<Flow> {
|
||||
<button
|
||||
class="pf-c-button pf-m-plain"
|
||||
@click=${() => {
|
||||
const finalURL = `${window.location.origin}/if/flow/${item.slug}/${AndNext(
|
||||
`${window.location.pathname}#${window.location.hash}`,
|
||||
)}`;
|
||||
window.open(finalURL, "_blank");
|
||||
const url = formatFlowURL(item);
|
||||
|
||||
window.open(url, "_blank");
|
||||
}}
|
||||
>
|
||||
<pf-tooltip position="top" content=${msg("Execute")}>
|
||||
|
@ -1,11 +1,10 @@
|
||||
import "@goauthentik/admin/flows/BoundStagesList";
|
||||
import "@goauthentik/admin/flows/FlowDiagram";
|
||||
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/rbac/ObjectPermissionsPage";
|
||||
import { AndNext, DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import "@goauthentik/components/events/ObjectChangelog";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
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 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")
|
||||
export class FlowViewPage extends AKElement {
|
||||
@ -147,12 +151,9 @@ export class FlowViewPage extends AKElement {
|
||||
<button
|
||||
class="pf-c-button pf-m-block pf-m-primary"
|
||||
@click=${() => {
|
||||
const finalURL = `${
|
||||
window.location.origin
|
||||
}/if/flow/${this.flow.slug}/${AndNext(
|
||||
`${window.location.pathname}#${window.location.hash}`,
|
||||
)}`;
|
||||
window.open(finalURL, "_blank");
|
||||
const url = formatFlowURL(this.flow);
|
||||
|
||||
window.open(url, "_blank");
|
||||
}}
|
||||
>
|
||||
${msg("Normal")}
|
||||
@ -164,12 +165,16 @@ export class FlowViewPage extends AKElement {
|
||||
.flowsInstancesExecuteRetrieve({
|
||||
slug: this.flow.slug,
|
||||
})
|
||||
.then((link) => {
|
||||
const finalURL = `${
|
||||
link.link
|
||||
}${AndNext(
|
||||
`${window.location.pathname}#${window.location.hash}`,
|
||||
)}`;
|
||||
.then(({ link }) => {
|
||||
const finalURL = URL.canParse(link)
|
||||
? new URL(link)
|
||||
: new URL(
|
||||
link,
|
||||
window.location.origin,
|
||||
);
|
||||
|
||||
applyNextParam(finalURL);
|
||||
|
||||
window.open(finalURL, "_blank");
|
||||
});
|
||||
}}
|
||||
@ -191,15 +196,13 @@ export class FlowViewPage extends AKElement {
|
||||
)}`;
|
||||
window.open(finalURL, "_blank");
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
if (isResponseErrorLike(error)) {
|
||||
// This request can return a HTTP 400 when a flow
|
||||
// is not applicable.
|
||||
window.open(
|
||||
error.response.url,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
.catch((exc: ResponseError) => {
|
||||
// This request can return a HTTP 400 when a flow
|
||||
// is not applicable.
|
||||
window.open(
|
||||
exc.response.url,
|
||||
"_blank",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
@ -43,3 +43,51 @@ export function LayoutToLabel(layout: FlowLayoutEnum): string {
|
||||
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;
|
||||
}
|
||||
|
@ -6,7 +6,6 @@ import "@goauthentik/admin/users/UserPasswordForm";
|
||||
import "@goauthentik/admin/users/UserResetEmailForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
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/ModalForm";
|
||||
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 { Table, TableColumn } from "@goauthentik/elements/table/Table";
|
||||
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 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")
|
||||
export class RelatedUserAdd extends Form<{ users: number[] }> {
|
||||
@ -121,13 +127,13 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
order = "last_login";
|
||||
|
||||
@property({ type: Boolean })
|
||||
hideServiceAccounts = getURLParam<boolean>("hideServiceAccounts", true);
|
||||
hideServiceAccounts = getRouteParameter<boolean>("hideServiceAccounts", true);
|
||||
|
||||
@state()
|
||||
me?: SessionUser;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return Table.styles.concat(PFDescriptionList, PFAlert, PFBanner);
|
||||
return super.styles.concat(PFDescriptionList, PFAlert, PFBanner);
|
||||
}
|
||||
|
||||
async apiEndpoint(): Promise<PaginatedResponse<User>> {
|
||||
@ -313,16 +319,14 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
description: rec.link,
|
||||
});
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
const parsedError =
|
||||
await parseAPIResponseError(
|
||||
error,
|
||||
);
|
||||
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message:
|
||||
pluckErrorDetail(parsedError),
|
||||
.catch((ex: ResponseError) => {
|
||||
ex.response.json().then(() => {
|
||||
showMessage({
|
||||
level: MessageLevel.error,
|
||||
message: msg(
|
||||
"No recovery flow is configured.",
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
}}
|
||||
@ -462,7 +466,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
this.hideServiceAccounts = !this.hideServiceAccounts;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
hideServiceAccounts: this.hideServiceAccounts,
|
||||
});
|
||||
}}
|
||||
|
@ -19,7 +19,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
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 { TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import { TablePage } from "@goauthentik/elements/table/TablePage";
|
||||
@ -54,7 +54,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
|
||||
order = "name";
|
||||
|
||||
@state()
|
||||
hideManaged = getURLParam<boolean>("hideManaged", true);
|
||||
hideManaged = getRouteParameter<boolean>("hideManaged", true);
|
||||
|
||||
async apiEndpoint(): Promise<PaginatedResponse<PropertyMapping>> {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsAllList({
|
||||
@ -148,7 +148,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
|
||||
this.hideManaged = !this.hideManaged;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
hideManaged: this.hideManaged,
|
||||
});
|
||||
}}
|
||||
|
@ -3,7 +3,6 @@ import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
|
||||
import "@goauthentik/admin/rbac/ObjectPermissionsPage";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { convertToSlug } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-status-label";
|
||||
import "@goauthentik/components/events/ObjectChangelog";
|
||||
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 "@goauthentik/elements/buttons/ModalButton";
|
||||
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 { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
|
||||
@ -156,7 +156,7 @@ export class ProxyProviderViewPage extends AKElement {
|
||||
(input: string): string => {
|
||||
// The generated config is pretty unreliable currently so
|
||||
// put it behind a flag
|
||||
if (!getURLParam("generatedConfig", false)) {
|
||||
if (!getRouteParameter("generatedConfig", false)) {
|
||||
return input;
|
||||
}
|
||||
if (!this.provider) {
|
||||
@ -183,7 +183,7 @@ export class ProxyProviderViewPage extends AKElement {
|
||||
return html`<ak-tabs pageIdentifier="proxy-setup">
|
||||
${servers.map((server) => {
|
||||
return html`<section
|
||||
slot="page-${convertToSlug(server.label)}"
|
||||
slot="page-${formatAsSlug(server.label)}"
|
||||
data-tab-title="${server.label}"
|
||||
class="pf-c-page__main-section pf-m-no-padding-mobile ak-markdown-section"
|
||||
>
|
||||
|
@ -1,6 +1,6 @@
|
||||
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default";
|
||||
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 "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import "@goauthentik/elements/forms/SearchSelect";
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 "@goauthentik/elements/CodeMirror";
|
||||
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
|
||||
@ -21,8 +21,9 @@ import {
|
||||
Prompt,
|
||||
PromptChallenge,
|
||||
PromptTypeEnum,
|
||||
ResponseError,
|
||||
StagesApi,
|
||||
instanceOfValidationError,
|
||||
ValidationError,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
class PreviewStageHost implements StageHost {
|
||||
@ -77,22 +78,15 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return new StagesApi(DEFAULT_CONFIG)
|
||||
.stagesPromptPromptsPreviewCreate({
|
||||
try {
|
||||
this.preview = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsPreviewCreate({
|
||||
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 {
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import { DeleteForm } from "@goauthentik/elements/forms/DeleteForm";
|
||||
@ -17,14 +16,10 @@ export class UserActiveForm extends DeleteForm {
|
||||
});
|
||||
}
|
||||
|
||||
onError(error: unknown): Promise<void> {
|
||||
return parseAPIResponseError(error).then((parsedError) => {
|
||||
showMessage({
|
||||
message: msg(
|
||||
str`Failed to update ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
|
||||
),
|
||||
level: MessageLevel.error,
|
||||
});
|
||||
onError(e: Error): void {
|
||||
showMessage({
|
||||
message: msg(str`Failed to update ${this.objectLabel}: ${e.toString()}`),
|
||||
level: MessageLevel.error,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { deviceTypeName } from "@goauthentik/common/labels";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||
import { getRelativeTime } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||
|
@ -7,10 +7,9 @@ import "@goauthentik/admin/users/UserPasswordForm";
|
||||
import "@goauthentik/admin/users/UserResetEmailForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { parseAPIResponseError } from "@goauthentik/common/errors/network";
|
||||
import { userTypeToLabel } from "@goauthentik/common/labels";
|
||||
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 { getRelativeTime } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-status-label";
|
||||
@ -24,8 +23,8 @@ import "@goauthentik/elements/TreeView";
|
||||
import "@goauthentik/elements/buttons/ActionButton";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
import "@goauthentik/elements/forms/ModalForm";
|
||||
import { showAPIErrorMessage, showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
|
||||
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
|
||||
import { getRouteParameter, patchRouteParams } from "@goauthentik/elements/router/utils";
|
||||
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
|
||||
import { TableColumn } from "@goauthentik/elements/table/Table";
|
||||
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 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) =>
|
||||
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) =>
|
||||
html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request">
|
||||
@ -109,7 +117,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
activePath;
|
||||
|
||||
@state()
|
||||
hideDeactivated = getURLParam<boolean>("hideDeactivated", false);
|
||||
hideDeactivated = getRouteParameter<boolean>("hideDeactivated", false);
|
||||
|
||||
@state()
|
||||
userPaths?: UserPath;
|
||||
@ -118,13 +126,15 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
me?: SessionUser;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [...TablePage.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
|
||||
return [...super.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
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) => {
|
||||
if (c.defaults.userPath !== defaultPath) {
|
||||
this.activePath = c.defaults.userPath;
|
||||
@ -135,7 +145,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
async apiEndpoint(): Promise<PaginatedResponse<User>> {
|
||||
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
|
||||
...(await this.defaultEndpointConfig()),
|
||||
pathStartswith: getURLParam("path", ""),
|
||||
pathStartswith: getRouteParameter("path", ""),
|
||||
isActive: this.hideDeactivated ? true : undefined,
|
||||
includeGroups: false,
|
||||
});
|
||||
@ -217,7 +227,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
this.hideDeactivated = !this.hideDeactivated;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
patchRouteParams({
|
||||
hideDeactivated: this.hideDeactivated,
|
||||
});
|
||||
}}
|
||||
|
@ -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}`);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { EVENT_REQUEST_POST } from "@goauthentik/common/constants";
|
||||
import { getCookie } from "@goauthentik/common/utils";
|
||||
import { getCookie } from "@goauthentik/common/http";
|
||||
|
||||
import {
|
||||
CurrentBrand,
|
||||
|
@ -1,170 +1,60 @@
|
||||
/**
|
||||
* @file
|
||||
* Client-side observer for ESBuild events.
|
||||
* @file Client-side utilities.
|
||||
*/
|
||||
import type { Message as ESBuildMessage } from "esbuild";
|
||||
import { TITLE_DEFAULT } from "@goauthentik/common/constants";
|
||||
import { isAdminRoute } from "@goauthentik/elements/router";
|
||||
|
||||
const logPrefix = "👷 [ESBuild]";
|
||||
const log = console.debug.bind(console, logPrefix);
|
||||
import { msg } from "@lit/localize";
|
||||
|
||||
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
|
||||
* ESBuild may tree-shake it out of production builds.
|
||||
*
|
||||
* ```ts
|
||||
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
|
||||
* const { ESBuildObserver } = await import("@goauthentik/common/client");
|
||||
*
|
||||
* new ESBuildObserver(process.env.WATCHER_URL);
|
||||
* }
|
||||
* ```
|
||||
}
|
||||
|
||||
* @param brand - The brand object to append to the title.
|
||||
* @param segments - The segments to prepend to the title.
|
||||
*/
|
||||
export class ESBuildObserver extends EventSource {
|
||||
/**
|
||||
* Whether the watcher has a recent connection to the server.
|
||||
*/
|
||||
alive = true;
|
||||
export function formatPageTitle(
|
||||
brand: BrandTitleLike | undefined,
|
||||
...segments: Array<string | undefined>
|
||||
): string;
|
||||
/**
|
||||
* 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[] = [];
|
||||
|
||||
/**
|
||||
* The number of errors that have occurred since the watcher started.
|
||||
*/
|
||||
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);
|
||||
if (isAdminRoute()) {
|
||||
segments.push(msg("Admin"));
|
||||
}
|
||||
|
||||
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(" - ");
|
||||
}
|
||||
|
@ -5,7 +5,6 @@ export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2025.2.3";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
export const EVENT_REFRESH = "ak-refresh";
|
||||
export const EVENT_NOTIFICATION_DRAWER_TOGGLE = "ak-notification-toggle";
|
||||
|
36
web/src/common/errors.ts
Normal file
36
web/src/common/errors.ts
Normal 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;
|
||||
}
|
@ -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
|
@ -8,10 +8,13 @@ export interface EventUser {
|
||||
is_anonymous?: boolean;
|
||||
}
|
||||
|
||||
export interface EventGeo {
|
||||
city?: string;
|
||||
country?: string;
|
||||
continent?: string;
|
||||
export interface EventContext {
|
||||
[key: string]: EventContext | EventModel | string | number | string[];
|
||||
}
|
||||
|
||||
export interface EventWithContext extends Event {
|
||||
user: EventUser;
|
||||
context: EventContext;
|
||||
}
|
||||
|
||||
export interface EventModel {
|
||||
@ -25,16 +28,3 @@ export interface EventRequest {
|
||||
path: 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;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
|
||||
export interface PlexPinResponse {
|
||||
// Only has the fields we care about
|
||||
|
145
web/src/common/helpers/webauthn.ts
Normal file
145
web/src/common/helpers/webauthn.ts
Normal 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
28
web/src/common/http.ts
Normal 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 "";
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import { config } from "@goauthentik/common/api/config";
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { readInterfaceRouteParam } from "@goauthentik/elements/router/utils";
|
||||
import {
|
||||
ErrorEvent,
|
||||
EventHint,
|
||||
@ -12,11 +14,6 @@ import {
|
||||
|
||||
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_CAPABILITIES = "authentik.capabilities";
|
||||
|
||||
@ -68,7 +65,7 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
|
||||
});
|
||||
setTag(TAG_SENTRY_CAPABILITIES, cfg.capabilities.join(","));
|
||||
if (window.location.pathname.includes("if/")) {
|
||||
setTag(TAG_SENTRY_COMPONENT, `web/${currentInterface()}`);
|
||||
setTag(TAG_SENTRY_COMPONENT, `web/${readInterfaceRouteParam()}`);
|
||||
}
|
||||
if (cfg.capabilities.includes(CapabilitiesEnum.CanDebug)) {
|
||||
const Spotlight = await import("@spotlightjs/spotlight");
|
||||
@ -86,13 +83,3 @@ export async function configureSentry(canDoPpi = false): Promise<Config> {
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { currentInterface } from "@goauthentik/common/sentry";
|
||||
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 {
|
||||
username = "username",
|
||||
@ -18,15 +18,27 @@ export enum LayoutType {
|
||||
|
||||
export interface UIConfig {
|
||||
enabledFeatures: {
|
||||
// API Request drawer in navbar
|
||||
/**
|
||||
* Whether to show the API request drawer in the navbar.
|
||||
*/
|
||||
apiDrawer: boolean;
|
||||
// Notification drawer in navbar
|
||||
/**
|
||||
* Whether to show the notification drawer in the navbar.
|
||||
*/
|
||||
notificationDrawer: boolean;
|
||||
// Settings in user dropdown
|
||||
/**
|
||||
* Whether to show the settings in the user dropdown.
|
||||
*/
|
||||
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;
|
||||
// Search bar
|
||||
/**
|
||||
* Whether to show the search bar.
|
||||
*/
|
||||
search: boolean;
|
||||
};
|
||||
navbar: {
|
||||
@ -38,68 +50,77 @@ export interface UIConfig {
|
||||
cardBackground: string;
|
||||
};
|
||||
pagination: {
|
||||
/**
|
||||
* Number of items to show per page in paginated lists.
|
||||
*/
|
||||
perPage: number;
|
||||
};
|
||||
layout: {
|
||||
/**
|
||||
* Layout type to use for the application.
|
||||
*/
|
||||
type: LayoutType;
|
||||
};
|
||||
/**
|
||||
* Locale to use for the application.
|
||||
*/
|
||||
locale: string;
|
||||
/**
|
||||
* Default values.
|
||||
*/
|
||||
defaults: {
|
||||
/**
|
||||
* Default path to use for user API calls.
|
||||
*/
|
||||
userPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class DefaultUIConfig implements UIConfig {
|
||||
enabledFeatures = {
|
||||
apiDrawer: true,
|
||||
notificationDrawer: true,
|
||||
settings: true,
|
||||
applicationEdit: true,
|
||||
search: true,
|
||||
};
|
||||
layout = {
|
||||
type: LayoutType.row,
|
||||
};
|
||||
navbar = {
|
||||
userDisplay: UserDisplay.username,
|
||||
};
|
||||
theme = {
|
||||
base: UiThemeEnum.Automatic,
|
||||
background: "",
|
||||
cardBackground: "",
|
||||
};
|
||||
pagination = {
|
||||
perPage: 20,
|
||||
};
|
||||
locale = "";
|
||||
defaults = {
|
||||
userPath: "users",
|
||||
export function createUIConfig(overrides: Partial<UIConfig> = {}): UIConfig {
|
||||
const uiConfig: UIConfig = {
|
||||
enabledFeatures: {
|
||||
// TODO: Is the intent that only user routes should have the API drawer disabled,
|
||||
// or only admin routes?
|
||||
apiDrawer: !isUserRoute(),
|
||||
notificationDrawer: true,
|
||||
settings: true,
|
||||
applicationEdit: true,
|
||||
search: true,
|
||||
},
|
||||
layout: {
|
||||
type: LayoutType.row,
|
||||
},
|
||||
navbar: {
|
||||
userDisplay: UserDisplay.username,
|
||||
},
|
||||
theme: {
|
||||
base: UiThemeEnum.Automatic,
|
||||
background: "",
|
||||
cardBackground: "",
|
||||
},
|
||||
pagination: {
|
||||
perPage: 20,
|
||||
},
|
||||
locale: "",
|
||||
defaults: {
|
||||
userPath: "users",
|
||||
},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
if (currentInterface() === "user") {
|
||||
this.enabledFeatures.apiDrawer = false;
|
||||
}
|
||||
}
|
||||
// TODO: Should we deep merge the overrides instead of shallow?
|
||||
Object.assign(uiConfig, overrides);
|
||||
|
||||
return uiConfig;
|
||||
}
|
||||
|
||||
let globalUiConfig: Promise<UIConfig>;
|
||||
|
||||
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;
|
||||
}
|
||||
let cachedUIConfig: UIConfig | null = null;
|
||||
|
||||
export function uiConfig(): Promise<UIConfig> {
|
||||
if (!globalUiConfig) {
|
||||
globalUiConfig = me().then((user) => {
|
||||
return getConfigForUser(user.user);
|
||||
});
|
||||
}
|
||||
return globalUiConfig;
|
||||
if (cachedUIConfig) return Promise.resolve(cachedUIConfig);
|
||||
|
||||
return me().then((session) => {
|
||||
cachedUIConfig = createUIConfig(session.user.settings);
|
||||
|
||||
return cachedUIConfig;
|
||||
});
|
||||
}
|
||||
|
@ -1,96 +1,63 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
|
||||
import { isResponseErrorLike } from "@goauthentik/common/errors/network";
|
||||
|
||||
import { CoreApi, SessionUser } from "@goauthentik/api";
|
||||
import { CoreApi, ResponseError, SessionUser } from "@goauthentik/api";
|
||||
|
||||
/**
|
||||
* 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: [],
|
||||
},
|
||||
};
|
||||
let globalMePromise: Promise<SessionUser> | undefined;
|
||||
|
||||
return guest;
|
||||
}
|
||||
|
||||
let memoizedSession: SessionUser | null = null;
|
||||
|
||||
/**
|
||||
* Refresh the current user session.
|
||||
*/
|
||||
export function refreshMe(): Promise<SessionUser> {
|
||||
memoizedSession = null;
|
||||
globalMePromise = undefined;
|
||||
return me();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the current user session.
|
||||
*
|
||||
* This is a memoized function, so it will only make one request per page load.
|
||||
*
|
||||
* @see {@linkcode refreshMe} to force a refresh.
|
||||
*/
|
||||
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);
|
||||
export function me(): Promise<SessionUser> {
|
||||
if (!globalMePromise) {
|
||||
globalMePromise = new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersMeRetrieve()
|
||||
.then((user) => {
|
||||
if (!user.user.settings || !("locale" in user.user.settings)) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug("authentik/users: Failed to retrieve user session", error);
|
||||
|
||||
return createGuestSession();
|
||||
})
|
||||
.then((nextSession) => {
|
||||
memoizedSession = nextSession;
|
||||
return nextSession;
|
||||
});
|
||||
const locale: string | undefined = user.user.settings.locale;
|
||||
if (locale && 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 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;
|
||||
}
|
||||
|
@ -1,36 +1,7 @@
|
||||
import { SentryIgnoredError } from "@goauthentik/common/sentry";
|
||||
import { SentryIgnoredError } from "@goauthentik/common/errors";
|
||||
|
||||
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
|
||||
*/
|
||||
@ -63,17 +34,29 @@ export function snakeToCamel(key: string) {
|
||||
|
||||
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
|
||||
const m = new Map<string, T[]>();
|
||||
|
||||
objects.forEach((obj) => {
|
||||
const group = callback(obj);
|
||||
if (!m.has(group)) {
|
||||
m.set(group, []);
|
||||
}
|
||||
|
||||
const tProviders = m.get(group) || [];
|
||||
tProviders.push(obj);
|
||||
});
|
||||
|
||||
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 {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const element = args[index];
|
||||
|
@ -1,16 +1,10 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { VERSION } from "@goauthentik/common/constants";
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import {
|
||||
EventContext,
|
||||
EventContextProperty,
|
||||
EventModel,
|
||||
EventWithContext,
|
||||
} from "@goauthentik/common/events";
|
||||
import { EventContext, EventModel, EventWithContext } from "@goauthentik/common/events";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/Expand";
|
||||
import "@goauthentik/elements/Spinner";
|
||||
import { SlottedTemplateResult } from "@goauthentik/elements/types";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
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";
|
||||
|
||||
// TODO: Settle these types. It's too hard to make sense of what we're expecting here.
|
||||
type EventSlotValueType =
|
||||
| number
|
||||
| SlottedTemplateResult
|
||||
| undefined
|
||||
| EventContext
|
||||
| EventContextProperty;
|
||||
|
||||
type FieldLabelTuple<V extends EventSlotValueType = EventSlotValueType> = [label: string, value: V];
|
||||
type Pair = [string, string | number | EventContext | EventModel | string[] | TemplateResult];
|
||||
|
||||
// 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">
|
||||
<dt class="pf-c-description-list__term">
|
||||
<span class="pf-c-description-list__text">${term}</span>
|
||||
@ -134,7 +120,7 @@ export class EventInfo extends AKElement {
|
||||
return html`<span>-</span>`;
|
||||
}
|
||||
|
||||
const modelFields: FieldLabelTuple[] = [
|
||||
const modelFields: Pair[] = [
|
||||
[msg("UID"), context.pk],
|
||||
[msg("Name"), context.name],
|
||||
[msg("App"), context.app],
|
||||
@ -148,23 +134,20 @@ export class EventInfo extends AKElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
getEmailInfo(context: EventContext): SlottedTemplateResult {
|
||||
getEmailInfo(context: EventContext): TemplateResult {
|
||||
if (context === null) {
|
||||
return html`<span>-</span>`;
|
||||
}
|
||||
|
||||
const emailFields = [
|
||||
// ---
|
||||
// prettier-ignore
|
||||
const emailFields: Pair[] = [
|
||||
[msg("Message"), context.message],
|
||||
[msg("Subject"), context.subject],
|
||||
[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>`;
|
||||
})}`,
|
||||
],
|
||||
] satisfies FieldLabelTuple<EventSlotValueType>[];
|
||||
})}`],
|
||||
];
|
||||
|
||||
return html`<dl class="pf-c-description-list pf-m-horizontal">
|
||||
${map(emailFields, this.renderDescriptionGroup)}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { convertToSlug } from "@goauthentik/common/utils";
|
||||
import { formatAsSlug } from "@goauthentik/elements/router";
|
||||
|
||||
import { html } from "lit";
|
||||
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
|
||||
// component, such as a custom forms manager, may receive it.
|
||||
handleTouch(ev: Event) {
|
||||
this.input.value = convertToSlug(this.input.value);
|
||||
this.input.value = formatAsSlug(this.input.value);
|
||||
this.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
|
||||
// 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 [shorter, longer] =
|
||||
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user