diff --git a/authentik/core/api/object_types.py b/authentik/core/api/object_types.py new file mode 100644 index 0000000000..12acd4e4a7 --- /dev/null +++ b/authentik/core/api/object_types.py @@ -0,0 +1,79 @@ +"""API Utilities""" + +from drf_spectacular.utils import extend_schema +from rest_framework.decorators import action +from rest_framework.fields import ( + BooleanField, + CharField, +) +from rest_framework.request import Request +from rest_framework.response import Response + +from authentik.core.api.utils import PassiveSerializer +from authentik.enterprise.apps import EnterpriseConfig +from authentik.lib.utils.reflection import all_subclasses + + +class TypeCreateSerializer(PassiveSerializer): + """Types of an object that can be created""" + + name = CharField(required=True) + description = CharField(required=True) + component = CharField(required=True) + model_name = CharField(required=True) + + icon_url = CharField(required=False) + requires_enterprise = BooleanField(default=False) + + +class CreatableType: + """Class to inherit from to mark a model as creatable, even if the model itself is marked + as abstract""" + + +class NonCreatableType: + """Class to inherit from to mark a model as non-creatable even if it is not abstract""" + + +class TypesMixin: + """Mixin which adds an API endpoint to list all possible types that can be created""" + + @extend_schema(responses={200: TypeCreateSerializer(many=True)}) + @action(detail=False, pagination_class=None, filter_backends=[]) + def types(self, request: Request, additional: list[dict] | None = None) -> Response: + """Get all creatable types""" + data = [] + for subclass in all_subclasses(self.queryset.model): + instance = None + if subclass._meta.abstract: + if not issubclass(subclass, CreatableType): + continue + # Circumvent the django protection for not being able to instantiate + # abstract models. We need a model instance to access .component + # and further down .icon_url + instance = subclass.__new__(subclass) + # Django re-sets abstract = False so we need to override that + instance.Meta.abstract = True + else: + if issubclass(subclass, NonCreatableType): + continue + instance = subclass() + try: + data.append( + { + "name": subclass._meta.verbose_name, + "description": subclass.__doc__, + "component": instance.component, + "model_name": subclass._meta.model_name, + "icon_url": getattr(instance, "icon_url", None), + "requires_enterprise": isinstance( + subclass._meta.app_config, EnterpriseConfig + ), + } + ) + except NotImplementedError: + continue + if additional: + data.extend(additional) + data = sorted(data, key=lambda x: x["name"]) + return Response(TypeCreateSerializer(data, many=True).data) diff --git a/authentik/core/api/propertymappings.py b/authentik/core/api/propertymappings.py index 8cb9590977..06eeb7a396 100644 --- a/authentik/core/api/propertymappings.py +++ b/authentik/core/api/propertymappings.py @@ -15,12 +15,15 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField from rest_framework.viewsets import GenericViewSet from authentik.blueprints.api import ManagedSerializer +from authentik.core.api.object_types import TypesMixin from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer +from authentik.core.api.utils import ( + MetaNameSerializer, + PassiveSerializer, +) from authentik.core.expression.evaluator import PropertyMappingEvaluator from authentik.core.models import PropertyMapping from authentik.events.utils import sanitize_item -from authentik.lib.utils.reflection import all_subclasses from authentik.policies.api.exec import PolicyTestSerializer from authentik.rbac.decorators import permission_required @@ -64,6 +67,7 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri class PropertyMappingViewSet( + TypesMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, @@ -83,23 +87,6 @@ class PropertyMappingViewSet( def get_queryset(self): # pragma: no cover return PropertyMapping.objects.select_subclasses() - @extend_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False, pagination_class=None, filter_backends=[]) - def types(self, request: Request) -> Response: - """Get all creatable property-mapping types""" - data = [] - for subclass in all_subclasses(self.queryset.model): - subclass: PropertyMapping - data.append( - { - "name": subclass._meta.verbose_name, - "description": subclass.__doc__, - "component": subclass().component, - "model_name": subclass._meta.model_name, - } - ) - return Response(TypeCreateSerializer(data, many=True).data) - @permission_required("authentik_core.view_propertymapping") @extend_schema( request=PolicyTestSerializer(), diff --git a/authentik/core/api/providers.py b/authentik/core/api/providers.py index 76696c3a0d..2c33ccf1f6 100644 --- a/authentik/core/api/providers.py +++ b/authentik/core/api/providers.py @@ -5,20 +5,15 @@ from django.db.models.query import Q from django.utils.translation import gettext_lazy as _ from django_filters.filters import BooleanFilter from django_filters.filterset import FilterSet -from drf_spectacular.utils import extend_schema from rest_framework import mixins -from rest_framework.decorators import action from rest_framework.fields import ReadOnlyField -from rest_framework.request import Request -from rest_framework.response import Response from rest_framework.serializers import ModelSerializer, SerializerMethodField from rest_framework.viewsets import GenericViewSet +from authentik.core.api.object_types import TypesMixin from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer +from authentik.core.api.utils import MetaNameSerializer from authentik.core.models import Provider -from authentik.enterprise.apps import EnterpriseConfig -from authentik.lib.utils.reflection import all_subclasses class ProviderSerializer(ModelSerializer, MetaNameSerializer): @@ -86,6 +81,7 @@ class ProviderFilter(FilterSet): class ProviderViewSet( + TypesMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, @@ -104,31 +100,3 @@ class ProviderViewSet( def get_queryset(self): # pragma: no cover return Provider.objects.select_subclasses() - - @extend_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False, pagination_class=None, filter_backends=[]) - def types(self, request: Request) -> Response: - """Get all creatable provider types""" - data = [] - for subclass in all_subclasses(self.queryset.model): - subclass: Provider - if subclass._meta.abstract: - continue - data.append( - { - "name": subclass._meta.verbose_name, - "description": subclass.__doc__, - "component": subclass().component, - "model_name": subclass._meta.model_name, - "requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig), - } - ) - data.append( - { - "name": _("SAML Provider from Metadata"), - "description": _("Create a SAML Provider by importing its Metadata."), - "component": "ak-provider-saml-import-form", - "model_name": "", - } - ) - return Response(TypeCreateSerializer(data, many=True).data) diff --git a/authentik/core/api/sources.py b/authentik/core/api/sources.py index 04341d8804..977696c407 100644 --- a/authentik/core/api/sources.py +++ b/authentik/core/api/sources.py @@ -17,8 +17,9 @@ from structlog.stdlib import get_logger from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT +from authentik.core.api.object_types import TypesMixin from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer +from authentik.core.api.utils import MetaNameSerializer from authentik.core.models import Source, UserSourceConnection from authentik.core.types import UserSettingSerializer from authentik.lib.utils.file import ( @@ -27,7 +28,6 @@ from authentik.lib.utils.file import ( set_file, set_file_url, ) -from authentik.lib.utils.reflection import all_subclasses from authentik.policies.engine import PolicyEngine from authentik.rbac.decorators import permission_required @@ -74,6 +74,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer): class SourceViewSet( + TypesMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, @@ -132,30 +133,6 @@ class SourceViewSet( source: Source = self.get_object() return set_file_url(request, source, "icon") - @extend_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False, pagination_class=None, filter_backends=[]) - def types(self, request: Request) -> Response: - """Get all creatable source types""" - data = [] - for subclass in all_subclasses(self.queryset.model): - subclass: Source - component = "" - if len(subclass.__subclasses__()) > 0: - continue - if subclass._meta.abstract: - component = subclass.__bases__[0]().component - else: - component = subclass().component - data.append( - { - "name": subclass._meta.verbose_name, - "description": subclass.__doc__, - "component": component, - "model_name": subclass._meta.model_name, - } - ) - return Response(TypeCreateSerializer(data, many=True).data) - @extend_schema(responses={200: UserSettingSerializer(many=True)}) @action(detail=False, pagination_class=None, filter_backends=[]) def user_settings(self, request: Request) -> Response: diff --git a/authentik/core/api/utils.py b/authentik/core/api/utils.py index 9cc3d3fc5d..08e4a66f22 100644 --- a/authentik/core/api/utils.py +++ b/authentik/core/api/utils.py @@ -6,8 +6,16 @@ from django.db.models import Model from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.plumbing import build_basic_type from drf_spectacular.types import OpenApiTypes -from rest_framework.fields import BooleanField, CharField, IntegerField, JSONField -from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError +from rest_framework.fields import ( + CharField, + IntegerField, + JSONField, + SerializerMethodField, +) +from rest_framework.serializers import ( + Serializer, + ValidationError, +) def is_dict(value: Any): @@ -68,16 +76,6 @@ class MetaNameSerializer(PassiveSerializer): return f"{obj._meta.app_label}.{obj._meta.model_name}" -class TypeCreateSerializer(PassiveSerializer): - """Types of an object that can be created""" - - name = CharField(required=True) - description = CharField(required=True) - component = CharField(required=True) - model_name = CharField(required=True) - requires_enterprise = BooleanField(default=False) - - class CacheSerializer(PassiveSerializer): """Generic cache stats for an object""" diff --git a/authentik/core/models.py b/authentik/core/models.py index d12de39905..67a6710c25 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -377,6 +377,10 @@ class Provider(SerializerModel): Can return None for providers that are not URL-based""" return None + @property + def icon_url(self) -> str | None: + return None + @property def component(self) -> str: """Return component used to edit this object""" diff --git a/authentik/core/tests/test_models.py b/authentik/core/tests/test_models.py index 989301a020..6f95fcce9e 100644 --- a/authentik/core/tests/test_models.py +++ b/authentik/core/tests/test_models.py @@ -9,7 +9,6 @@ from freezegun import freeze_time from guardian.shortcuts import get_anonymous_user from authentik.core.models import Provider, Source, Token -from authentik.flows.models import Stage from authentik.lib.utils.reflection import all_subclasses @@ -31,7 +30,7 @@ class TestModels(TestCase): self.assertFalse(token.is_expired) -def source_tester_factory(test_model: type[Stage]) -> Callable: +def source_tester_factory(test_model: type[Source]) -> Callable: """Test source""" factory = RequestFactory() @@ -39,19 +38,19 @@ def source_tester_factory(test_model: type[Stage]) -> Callable: def tester(self: TestModels): model_class = None - if test_model._meta.abstract: # pragma: no cover - model_class = test_model.__bases__[0]() + if test_model._meta.abstract: + model_class = [x for x in test_model.__bases__ if issubclass(x, Source)][0]() else: model_class = test_model() model_class.slug = "test" self.assertIsNotNone(model_class.component) - _ = model_class.ui_login_button(request) - _ = model_class.ui_user_settings() + model_class.ui_login_button(request) + model_class.ui_user_settings() return tester -def provider_tester_factory(test_model: type[Stage]) -> Callable: +def provider_tester_factory(test_model: type[Provider]) -> Callable: """Test provider""" def tester(self: TestModels): diff --git a/authentik/enterprise/providers/google_workspace/models.py b/authentik/enterprise/providers/google_workspace/models.py index 4d3d831e60..12ace4cb39 100644 --- a/authentik/enterprise/providers/google_workspace/models.py +++ b/authentik/enterprise/providers/google_workspace/models.py @@ -5,6 +5,7 @@ from uuid import uuid4 from django.db import models from django.db.models import QuerySet +from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ from google.oauth2.service_account import Credentials from rest_framework.serializers import Serializer @@ -98,6 +99,10 @@ class GoogleWorkspaceProvider(OutgoingSyncProvider, BackchannelProvider): ).with_subject(self.delegated_subject), } + @property + def icon_url(self) -> str | None: + return static("authentik/sources/google.svg") + @property def component(self) -> str: return "ak-provider-google-workspace-form" diff --git a/authentik/enterprise/providers/microsoft_entra/models.py b/authentik/enterprise/providers/microsoft_entra/models.py index 3cee3bf15d..92d1725107 100644 --- a/authentik/enterprise/providers/microsoft_entra/models.py +++ b/authentik/enterprise/providers/microsoft_entra/models.py @@ -6,6 +6,7 @@ from uuid import uuid4 from azure.identity.aio import ClientSecretCredential from django.db import models from django.db.models import QuerySet +from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer @@ -87,6 +88,10 @@ class MicrosoftEntraProvider(OutgoingSyncProvider, BackchannelProvider): ) } + @property + def icon_url(self) -> str | None: + return static("authentik/sources/azuread.svg") + @property def component(self) -> str: return "ak-provider-microsoft-entra-form" diff --git a/authentik/enterprise/providers/rac/models.py b/authentik/enterprise/providers/rac/models.py index 3469f23831..42e7a266e0 100644 --- a/authentik/enterprise/providers/rac/models.py +++ b/authentik/enterprise/providers/rac/models.py @@ -7,6 +7,7 @@ from deepmerge import always_merger from django.db import models from django.db.models import QuerySet from django.http import HttpRequest +from django.templatetags.static import static from django.utils.translation import gettext as _ from rest_framework.serializers import Serializer from structlog.stdlib import get_logger @@ -63,6 +64,10 @@ class RACProvider(Provider): Can return None for providers that are not URL-based""" return "goauthentik.io://providers/rac/launch" + @property + def icon_url(self) -> str | None: + return static("authentik/sources/rac.svg") + @property def component(self) -> str: return "ak-provider-rac-form" diff --git a/authentik/events/api/events.py b/authentik/events/api/events.py index 5ddb1a55b7..2e4ae6f307 100644 --- a/authentik/events/api/events.py +++ b/authentik/events/api/events.py @@ -19,7 +19,8 @@ from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet from authentik.admin.api.metrics import CoordinateSerializer -from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer +from authentik.core.api.object_types import TypeCreateSerializer +from authentik.core.api.utils import PassiveSerializer from authentik.events.models import Event, EventAction diff --git a/authentik/flows/api/stages.py b/authentik/flows/api/stages.py index 81b3b1a8ab..a4973a62ec 100644 --- a/authentik/flows/api/stages.py +++ b/authentik/flows/api/stages.py @@ -10,10 +10,10 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField from rest_framework.viewsets import GenericViewSet from structlog.stdlib import get_logger +from authentik.core.api.object_types import TypesMixin from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer +from authentik.core.api.utils import MetaNameSerializer from authentik.core.types import UserSettingSerializer -from authentik.enterprise.apps import EnterpriseConfig from authentik.flows.api.flows import FlowSetSerializer from authentik.flows.models import ConfigurableStage, Stage from authentik.lib.utils.reflection import all_subclasses @@ -47,6 +47,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer): class StageViewSet( + TypesMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, @@ -63,25 +64,6 @@ class StageViewSet( def get_queryset(self): # pragma: no cover return Stage.objects.select_subclasses().prefetch_related("flow_set") - @extend_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False, pagination_class=None, filter_backends=[]) - def types(self, request: Request) -> Response: - """Get all creatable stage types""" - data = [] - for subclass in all_subclasses(self.queryset.model, False): - subclass: Stage - data.append( - { - "name": subclass._meta.verbose_name, - "description": subclass.__doc__, - "component": subclass().component, - "model_name": subclass._meta.model_name, - "requires_enterprise": isinstance(subclass._meta.app_config, EnterpriseConfig), - } - ) - data = sorted(data, key=lambda x: x["name"]) - return Response(TypeCreateSerializer(data, many=True).data) - @extend_schema(responses={200: UserSettingSerializer(many=True)}) @action(detail=False, pagination_class=None, filter_backends=[]) def user_settings(self, request: Request) -> Response: diff --git a/authentik/lib/utils/reflection.py b/authentik/lib/utils/reflection.py index b4eba76333..b8f8e4c08e 100644 --- a/authentik/lib/utils/reflection.py +++ b/authentik/lib/utils/reflection.py @@ -12,7 +12,7 @@ from authentik.lib.config import CONFIG SERVICE_HOST_ENV_NAME = "KUBERNETES_SERVICE_HOST" -def all_subclasses(cls, sort=True): +def all_subclasses[T](cls: T, sort=True) -> list[T] | set[T]: """Recursively return all subclassess of cls""" classes = set(cls.__subclasses__()).union( [s for c in cls.__subclasses__() for s in all_subclasses(c, sort=sort)] diff --git a/authentik/outposts/api/service_connections.py b/authentik/outposts/api/service_connections.py index 6fe8300d22..2c4484f83c 100644 --- a/authentik/outposts/api/service_connections.py +++ b/authentik/outposts/api/service_connections.py @@ -15,9 +15,12 @@ from rest_framework.response import Response from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import GenericViewSet, ModelViewSet +from authentik.core.api.object_types import TypesMixin from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer -from authentik.lib.utils.reflection import all_subclasses +from authentik.core.api.utils import ( + MetaNameSerializer, + PassiveSerializer, +) from authentik.outposts.models import ( DockerServiceConnection, KubernetesServiceConnection, @@ -57,6 +60,7 @@ class ServiceConnectionStateSerializer(PassiveSerializer): class ServiceConnectionViewSet( + TypesMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, @@ -70,23 +74,6 @@ class ServiceConnectionViewSet( search_fields = ["name"] filterset_fields = ["name"] - @extend_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False, pagination_class=None, filter_backends=[]) - def types(self, request: Request) -> Response: - """Get all creatable service connection types""" - data = [] - for subclass in all_subclasses(self.queryset.model): - subclass: OutpostServiceConnection - data.append( - { - "name": subclass._meta.verbose_name, - "description": subclass.__doc__, - "component": subclass().component, - "model_name": subclass._meta.model_name, - } - ) - return Response(TypeCreateSerializer(data, many=True).data) - @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) @action(detail=True, pagination_class=None, filter_backends=[]) def state(self, request: Request, pk: str) -> Response: diff --git a/authentik/policies/api/policies.py b/authentik/policies/api/policies.py index 8b1d2eba3e..93137a0ce8 100644 --- a/authentik/policies/api/policies.py +++ b/authentik/policies/api/policies.py @@ -13,10 +13,13 @@ from rest_framework.viewsets import GenericViewSet from structlog.stdlib import get_logger from authentik.core.api.applications import user_app_cache_key +from authentik.core.api.object_types import TypesMixin from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import CacheSerializer, MetaNameSerializer, TypeCreateSerializer +from authentik.core.api.utils import ( + CacheSerializer, + MetaNameSerializer, +) from authentik.events.logs import LogEventSerializer, capture_logs -from authentik.lib.utils.reflection import all_subclasses from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer from authentik.policies.models import Policy, PolicyBinding from authentik.policies.process import PolicyProcess @@ -69,6 +72,7 @@ class PolicySerializer(ModelSerializer, MetaNameSerializer): class PolicyViewSet( + TypesMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, UsedByMixin, @@ -89,23 +93,6 @@ class PolicyViewSet( def get_queryset(self): # pragma: no cover return Policy.objects.select_subclasses().prefetch_related("bindings", "promptstage_set") - @extend_schema(responses={200: TypeCreateSerializer(many=True)}) - @action(detail=False, pagination_class=None, filter_backends=[]) - def types(self, request: Request) -> Response: - """Get all creatable policy types""" - data = [] - for subclass in all_subclasses(self.queryset.model): - subclass: Policy - data.append( - { - "name": subclass._meta.verbose_name, - "description": subclass.__doc__, - "component": subclass().component, - "model_name": subclass._meta.model_name, - } - ) - return Response(TypeCreateSerializer(data, many=True).data) - @permission_required(None, ["authentik_policies.view_policy_cache"]) @extend_schema(responses={200: CacheSerializer(many=False)}) @action(detail=False, pagination_class=None, filter_backends=[]) diff --git a/authentik/providers/ldap/models.py b/authentik/providers/ldap/models.py index 5032ef3e47..3288b71498 100644 --- a/authentik/providers/ldap/models.py +++ b/authentik/providers/ldap/models.py @@ -3,6 +3,7 @@ from collections.abc import Iterable from django.db import models +from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer @@ -90,6 +91,10 @@ class LDAPProvider(OutpostModel, BackchannelProvider): def component(self) -> str: return "ak-provider-ldap-form" + @property + def icon_url(self) -> str | None: + return static("authentik/sources/ldap.png") + @property def serializer(self) -> type[Serializer]: from authentik.providers.ldap.api import LDAPProviderSerializer diff --git a/authentik/providers/oauth2/models.py b/authentik/providers/oauth2/models.py index d9029e1158..45b2d9e0b8 100644 --- a/authentik/providers/oauth2/models.py +++ b/authentik/providers/oauth2/models.py @@ -15,6 +15,7 @@ from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes from dacite.core import from_dict from django.db import models from django.http import HttpRequest +from django.templatetags.static import static from django.urls import reverse from django.utils.translation import gettext_lazy as _ from jwt import encode @@ -262,6 +263,10 @@ class OAuth2Provider(Provider): LOGGER.warning("Failed to format launch url", exc=exc) return None + @property + def icon_url(self) -> str | None: + return static("authentik/sources/openidconnect.svg") + @property def component(self) -> str: return "ak-provider-oauth2-form" diff --git a/authentik/providers/proxy/models.py b/authentik/providers/proxy/models.py index 4445be86c6..f824495309 100644 --- a/authentik/providers/proxy/models.py +++ b/authentik/providers/proxy/models.py @@ -6,6 +6,7 @@ from random import SystemRandom from urllib.parse import urljoin from django.db import models +from django.templatetags.static import static from django.utils.translation import gettext as _ from rest_framework.serializers import Serializer @@ -115,6 +116,10 @@ class ProxyProvider(OutpostModel, OAuth2Provider): def component(self) -> str: return "ak-provider-proxy-form" + @property + def icon_url(self) -> str | None: + return static("authentik/sources/proxy.svg") + @property def serializer(self) -> type[Serializer]: from authentik.providers.proxy.api import ProxyProviderSerializer diff --git a/authentik/providers/radius/models.py b/authentik/providers/radius/models.py index 83acd4f564..7e9784f191 100644 --- a/authentik/providers/radius/models.py +++ b/authentik/providers/radius/models.py @@ -1,6 +1,7 @@ """Radius Provider""" from django.db import models +from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer @@ -46,6 +47,10 @@ class RadiusProvider(OutpostModel, Provider): def component(self) -> str: return "ak-provider-radius-form" + @property + def icon_url(self) -> str | None: + return static("authentik/sources/radius.svg") + @property def serializer(self) -> type[Serializer]: from authentik.providers.radius.api import RadiusProviderSerializer diff --git a/authentik/providers/saml/models.py b/authentik/providers/saml/models.py index fafa4ad4ed..e0154bdf89 100644 --- a/authentik/providers/saml/models.py +++ b/authentik/providers/saml/models.py @@ -1,11 +1,13 @@ """authentik saml_idp Models""" from django.db import models +from django.templatetags.static import static from django.urls import reverse from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer from structlog.stdlib import get_logger +from authentik.core.api.object_types import CreatableType from authentik.core.models import PropertyMapping, Provider from authentik.crypto.models import CertificateKeyPair from authentik.lib.utils.time import timedelta_string_validator @@ -159,6 +161,10 @@ class SAMLProvider(Provider): except Provider.application.RelatedObjectDoesNotExist: return None + @property + def icon_url(self) -> str | None: + return static("authentik/sources/saml.png") + @property def serializer(self) -> type[Serializer]: from authentik.providers.saml.api.providers import SAMLProviderSerializer @@ -200,3 +206,20 @@ class SAMLPropertyMapping(PropertyMapping): class Meta: verbose_name = _("SAML Property Mapping") verbose_name_plural = _("SAML Property Mappings") + + +class SAMLProviderImportModel(CreatableType, Provider): + """Create a SAML Provider by importing its Metadata.""" + + @property + def component(self): + return "ak-provider-saml-import-form" + + @property + def icon_url(self) -> str | None: + return static("authentik/sources/saml.png") + + class Meta: + abstract = True + verbose_name = _("SAML Provider from Metadata") + verbose_name_plural = _("SAML Providers from Metadata") diff --git a/authentik/providers/scim/models.py b/authentik/providers/scim/models.py index 5225b0c01c..e7e8a0987c 100644 --- a/authentik/providers/scim/models.py +++ b/authentik/providers/scim/models.py @@ -5,6 +5,7 @@ from uuid import uuid4 from django.db import models from django.db.models import QuerySet +from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer @@ -32,6 +33,10 @@ class SCIMProvider(OutgoingSyncProvider, BackchannelProvider): help_text=_("Property mappings used for group creation/updating."), ) + @property + def icon_url(self) -> str | None: + return static("authentik/sources/scim.png") + def client_for_model( self, model: type[User | Group] ) -> BaseOutgoingSyncClient[User | Group, Any, Any, Self]: diff --git a/authentik/sources/ldap/models.py b/authentik/sources/ldap/models.py index 06ced2f9fa..587794ae04 100644 --- a/authentik/sources/ldap/models.py +++ b/authentik/sources/ldap/models.py @@ -8,6 +8,7 @@ from tempfile import NamedTemporaryFile, mkdtemp from django.core.cache import cache from django.db import connection, models +from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ from ldap3 import ALL, NONE, RANDOM, Connection, Server, ServerPool, Tls from ldap3.core.exceptions import LDAPException, LDAPInsufficientAccessRightsResult, LDAPSchemaError @@ -126,6 +127,10 @@ class LDAPSource(Source): return LDAPSourceSerializer + @property + def icon_url(self) -> str: + return static("authentik/sources/ldap.png") + def server(self, **kwargs) -> ServerPool: """Get LDAP Server/ServerPool""" servers = [] diff --git a/authentik/sources/oauth/models.py b/authentik/sources/oauth/models.py index b2dc3c9ea0..d05665bcb4 100644 --- a/authentik/sources/oauth/models.py +++ b/authentik/sources/oauth/models.py @@ -8,6 +8,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import Serializer +from authentik.core.api.object_types import CreatableType, NonCreatableType from authentik.core.models import Source, UserSourceConnection from authentik.core.types import UILoginButton, UserSettingSerializer @@ -15,7 +16,7 @@ if TYPE_CHECKING: from authentik.sources.oauth.types.registry import SourceType -class OAuthSource(Source): +class OAuthSource(NonCreatableType, Source): """Login using a Generic OAuth provider.""" provider_type = models.CharField(max_length=255) @@ -72,23 +73,35 @@ class OAuthSource(Source): return OAuthSourceSerializer + @property + def icon_url(self) -> str | None: + # When listing source types, this property might be retrieved from an abstract + # model. In that case we can't check self.provider_type or self.icon_url + # and as such we attempt to find the correct provider type based on the mode name + if self.Meta.abstract: + from authentik.sources.oauth.types.registry import registry + + provider_type = registry.find_type( + self._meta.model_name.replace(OAuthSource._meta.model_name, "") + ) + return provider_type().icon_url() + icon = super().icon_url + if not icon: + provider_type = self.source_type + provider = provider_type() + icon = provider.icon_url() + return icon + def ui_login_button(self, request: HttpRequest) -> UILoginButton: provider_type = self.source_type provider = provider_type() - icon = self.icon_url - if not icon: - icon = provider.icon_url() return UILoginButton( name=self.name, challenge=provider.login_challenge(self, request), - icon_url=icon, + icon_url=self.icon_url, ) def ui_user_settings(self) -> UserSettingSerializer | None: - provider_type = self.source_type - icon = self.icon_url - if not icon: - icon = provider_type().icon_url() return UserSettingSerializer( data={ "title": self.name, @@ -97,7 +110,7 @@ class OAuthSource(Source): "authentik_sources_oauth:oauth-client-login", kwargs={"source_slug": self.slug}, ), - "icon_url": icon, + "icon_url": self.icon_url, } ) @@ -109,7 +122,7 @@ class OAuthSource(Source): verbose_name_plural = _("OAuth Sources") -class GitHubOAuthSource(OAuthSource): +class GitHubOAuthSource(CreatableType, OAuthSource): """Social Login using GitHub.com or a GitHub-Enterprise Instance.""" class Meta: @@ -118,7 +131,7 @@ class GitHubOAuthSource(OAuthSource): verbose_name_plural = _("GitHub OAuth Sources") -class GitLabOAuthSource(OAuthSource): +class GitLabOAuthSource(CreatableType, OAuthSource): """Social Login using GitLab.com or a GitLab Instance.""" class Meta: @@ -127,7 +140,7 @@ class GitLabOAuthSource(OAuthSource): verbose_name_plural = _("GitLab OAuth Sources") -class TwitchOAuthSource(OAuthSource): +class TwitchOAuthSource(CreatableType, OAuthSource): """Social Login using Twitch.""" class Meta: @@ -136,7 +149,7 @@ class TwitchOAuthSource(OAuthSource): verbose_name_plural = _("Twitch OAuth Sources") -class MailcowOAuthSource(OAuthSource): +class MailcowOAuthSource(CreatableType, OAuthSource): """Social Login using Mailcow.""" class Meta: @@ -145,7 +158,7 @@ class MailcowOAuthSource(OAuthSource): verbose_name_plural = _("Mailcow OAuth Sources") -class TwitterOAuthSource(OAuthSource): +class TwitterOAuthSource(CreatableType, OAuthSource): """Social Login using Twitter.com""" class Meta: @@ -154,7 +167,7 @@ class TwitterOAuthSource(OAuthSource): verbose_name_plural = _("Twitter OAuth Sources") -class FacebookOAuthSource(OAuthSource): +class FacebookOAuthSource(CreatableType, OAuthSource): """Social Login using Facebook.com.""" class Meta: @@ -163,7 +176,7 @@ class FacebookOAuthSource(OAuthSource): verbose_name_plural = _("Facebook OAuth Sources") -class DiscordOAuthSource(OAuthSource): +class DiscordOAuthSource(CreatableType, OAuthSource): """Social Login using Discord.""" class Meta: @@ -172,7 +185,7 @@ class DiscordOAuthSource(OAuthSource): verbose_name_plural = _("Discord OAuth Sources") -class PatreonOAuthSource(OAuthSource): +class PatreonOAuthSource(CreatableType, OAuthSource): """Social Login using Patreon.""" class Meta: @@ -181,7 +194,7 @@ class PatreonOAuthSource(OAuthSource): verbose_name_plural = _("Patreon OAuth Sources") -class GoogleOAuthSource(OAuthSource): +class GoogleOAuthSource(CreatableType, OAuthSource): """Social Login using Google or Google Workspace (GSuite).""" class Meta: @@ -190,7 +203,7 @@ class GoogleOAuthSource(OAuthSource): verbose_name_plural = _("Google OAuth Sources") -class AzureADOAuthSource(OAuthSource): +class AzureADOAuthSource(CreatableType, OAuthSource): """Social Login using Azure AD.""" class Meta: @@ -199,7 +212,7 @@ class AzureADOAuthSource(OAuthSource): verbose_name_plural = _("Azure AD OAuth Sources") -class OpenIDConnectOAuthSource(OAuthSource): +class OpenIDConnectOAuthSource(CreatableType, OAuthSource): """Login using a Generic OpenID-Connect compliant provider.""" class Meta: @@ -208,7 +221,7 @@ class OpenIDConnectOAuthSource(OAuthSource): verbose_name_plural = _("OpenID OAuth Sources") -class AppleOAuthSource(OAuthSource): +class AppleOAuthSource(CreatableType, OAuthSource): """Social Login using Apple.""" class Meta: @@ -217,7 +230,7 @@ class AppleOAuthSource(OAuthSource): verbose_name_plural = _("Apple OAuth Sources") -class OktaOAuthSource(OAuthSource): +class OktaOAuthSource(CreatableType, OAuthSource): """Social Login using Okta.""" class Meta: @@ -226,7 +239,7 @@ class OktaOAuthSource(OAuthSource): verbose_name_plural = _("Okta OAuth Sources") -class RedditOAuthSource(OAuthSource): +class RedditOAuthSource(CreatableType, OAuthSource): """Social Login using reddit.com.""" class Meta: diff --git a/authentik/sources/plex/models.py b/authentik/sources/plex/models.py index 1e70d93dc1..13734cb3b3 100644 --- a/authentik/sources/plex/models.py +++ b/authentik/sources/plex/models.py @@ -60,10 +60,14 @@ class PlexSource(Source): return PlexSourceSerializer - def ui_login_button(self, request: HttpRequest) -> UILoginButton: - icon = self.icon_url + @property + def icon_url(self) -> str: + icon = super().icon_url if not icon: icon = static("authentik/sources/plex.svg") + return icon + + def ui_login_button(self, request: HttpRequest) -> UILoginButton: return UILoginButton( challenge=PlexAuthenticationChallenge( data={ @@ -73,20 +77,17 @@ class PlexSource(Source): "slug": self.slug, } ), - icon_url=icon, + icon_url=self.icon_url, name=self.name, ) def ui_user_settings(self) -> UserSettingSerializer | None: - icon = self.icon_url - if not icon: - icon = static("authentik/sources/plex.svg") return UserSettingSerializer( data={ "title": self.name, "component": "ak-user-settings-source-plex", "configure_url": self.client_id, - "icon_url": icon, + "icon_url": self.icon_url, } ) diff --git a/authentik/sources/saml/models.py b/authentik/sources/saml/models.py index 1bfab98ce6..c4b998bf26 100644 --- a/authentik/sources/saml/models.py +++ b/authentik/sources/saml/models.py @@ -181,6 +181,13 @@ class SAMLSource(Source): return SAMLSourceSerializer + @property + def icon_url(self) -> str: + icon = super().icon_url + if not icon: + return static("authentik/sources/saml.png") + return icon + def get_issuer(self, request: HttpRequest) -> str: """Get Source's Issuer, falling back to our Metadata URL if none is set""" if self.issuer is None: @@ -209,9 +216,6 @@ class SAMLSource(Source): ) def ui_user_settings(self) -> UserSettingSerializer | None: - icon = self.icon_url - if not icon: - icon = static(f"authentik/sources/{self.slug}.svg") return UserSettingSerializer( data={ "title": self.name, @@ -220,7 +224,7 @@ class SAMLSource(Source): "authentik_sources_saml:login", kwargs={"source_slug": self.slug}, ), - "icon_url": icon, + "icon_url": self.icon_url, } ) diff --git a/authentik/sources/scim/models.py b/authentik/sources/scim/models.py index 4684c4dfa8..de2149ee67 100644 --- a/authentik/sources/scim/models.py +++ b/authentik/sources/scim/models.py @@ -3,6 +3,7 @@ from uuid import uuid4 from django.db import models +from django.templatetags.static import static from django.utils.translation import gettext_lazy as _ from rest_framework.serializers import BaseSerializer @@ -27,6 +28,10 @@ class SCIMSource(Source): """Return component used to edit this object""" return "ak-source-scim-form" + @property + def icon_url(self) -> str: + return static("authentik/sources/scim.png") + @property def serializer(self) -> BaseSerializer: from authentik.sources.scim.api.sources import SCIMSourceSerializer diff --git a/authentik/stages/email/api.py b/authentik/stages/email/api.py index 67f0f26f06..14f5a0108a 100644 --- a/authentik/stages/email/api.py +++ b/authentik/stages/email/api.py @@ -7,8 +7,8 @@ from rest_framework.response import Response from rest_framework.serializers import ValidationError from rest_framework.viewsets import ModelViewSet +from authentik.core.api.object_types import TypeCreateSerializer from authentik.core.api.used_by import UsedByMixin -from authentik.core.api.utils import TypeCreateSerializer from authentik.flows.api.stages import StageSerializer from authentik.stages.email.models import EmailStage, get_template_choices diff --git a/schema.yml b/schema.yml index 7f1e5917ca..7b017f79c3 100644 --- a/schema.yml +++ b/schema.yml @@ -10047,7 +10047,7 @@ paths: /outposts/service_connections/all/types/: get: operationId: outposts_service_connections_all_types_list - description: Get all creatable service connection types + description: Get all creatable types tags: - outposts security: @@ -10881,7 +10881,7 @@ paths: /policies/all/types/: get: operationId: policies_all_types_list - description: Get all creatable policy types + description: Get all creatable types tags: - policies security: @@ -13439,7 +13439,7 @@ paths: /propertymappings/all/types/: get: operationId: propertymappings_all_types_list - description: Get all creatable property-mapping types + description: Get all creatable types tags: - propertymappings security: @@ -15899,7 +15899,7 @@ paths: /providers/all/types/: get: operationId: providers_all_types_list - description: Get all creatable provider types + description: Get all creatable types tags: - providers security: @@ -21898,7 +21898,7 @@ paths: /sources/all/types/: get: operationId: sources_all_types_list - description: Get all creatable source types + description: Get all creatable types tags: - sources security: @@ -25532,7 +25532,7 @@ paths: /stages/all/types/: get: operationId: stages_all_types_list - description: Get all creatable stage types + description: Get all creatable types tags: - stages security: @@ -37793,10 +37793,6 @@ components: type: string icon: type: string - nullable: true - description: |- - Get the URL to the Icon. If the name is /static or - starts with http it is returned as-is readOnly: true server_uri: type: string @@ -39106,9 +39102,6 @@ components: icon: type: string nullable: true - description: |- - Get the URL to the Icon. If the name is /static or - starts with http it is returned as-is readOnly: true provider_type: $ref: '#/components/schemas/ProviderTypeEnum' @@ -43800,10 +43793,6 @@ components: type: string icon: type: string - nullable: true - description: |- - Get the URL to the Icon. If the name is /static or - starts with http it is returned as-is readOnly: true client_id: type: string @@ -45863,10 +45852,6 @@ components: type: string icon: type: string - nullable: true - description: |- - Get the URL to the Icon. If the name is /static or - starts with http it is returned as-is readOnly: true pre_authentication_flow: type: string @@ -47484,6 +47469,8 @@ components: type: string model_name: type: string + icon_url: + type: string requires_enterprise: type: boolean default: false diff --git a/web/authentik/sources/ldap.png b/web/authentik/sources/ldap.png new file mode 100644 index 0000000000..8fda3be56a Binary files /dev/null and b/web/authentik/sources/ldap.png differ diff --git a/web/authentik/sources/proxy.svg b/web/authentik/sources/proxy.svg new file mode 100644 index 0000000000..13c6c0883a --- /dev/null +++ b/web/authentik/sources/proxy.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/web/authentik/sources/rac.svg b/web/authentik/sources/rac.svg new file mode 100644 index 0000000000..6b78fd3968 --- /dev/null +++ b/web/authentik/sources/rac.svg @@ -0,0 +1 @@ +desktop diff --git a/web/authentik/sources/radius.svg b/web/authentik/sources/radius.svg new file mode 100644 index 0000000000..9aca0e6b97 --- /dev/null +++ b/web/authentik/sources/radius.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/web/authentik/sources/saml.png b/web/authentik/sources/saml.png new file mode 100644 index 0000000000..81185a3304 Binary files /dev/null and b/web/authentik/sources/saml.png differ diff --git a/web/authentik/sources/scim.png b/web/authentik/sources/scim.png new file mode 100644 index 0000000000..1c337bcb7f Binary files /dev/null and b/web/authentik/sources/scim.png differ diff --git a/web/src/admin/admin-overview/charts/SyncStatusChart.ts b/web/src/admin/admin-overview/charts/SyncStatusChart.ts index aed6315fab..6c8887fb2b 100644 --- a/web/src/admin/admin-overview/charts/SyncStatusChart.ts +++ b/web/src/admin/admin-overview/charts/SyncStatusChart.ts @@ -1,7 +1,7 @@ -import { PaginatedResponse } from "@goauthentik/authentik/elements/table/Table"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKChart } from "@goauthentik/elements/charts/Chart"; import "@goauthentik/elements/forms/ConfirmationForm"; +import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { ChartData, ChartOptions } from "chart.js"; import { msg } from "@lit/localize"; diff --git a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts index d0e2c4d0c7..b0f1dfa83a 100644 --- a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts +++ b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts @@ -25,167 +25,152 @@ type ModelConverter = (provider: OneOfProvider) => ModelRequest; type ProviderNoteProvider = () => TemplateResult | undefined; type ProviderNote = ProviderNoteProvider | undefined; -/** - * There's an internal key and an API key because "Proxy" has three different subtypes. - */ -// prettier-ignore -type ProviderType = [ - string, // internal key used by the wizard to distinguish between providers - string, // Name of the provider - string, // Description - ProviderRenderer, // Function that returns the provider's wizard panel as a TemplateResult - ProviderModelEnumType, // key used by the API to distinguish between providers - ModelConverter, // Handler that takes a generic provider and returns one specifically typed to its panel - ProviderNote?, -]; - export type LocalTypeCreate = TypeCreate & { formName: string; modelName: ProviderModelEnumType; converter: ModelConverter; note?: ProviderNote; + renderer: ProviderRenderer; }; -// prettier-ignore -const _providerModelsTable: ProviderType[] = [ - [ - "oauth2provider", - msg("OAuth2/OIDC (Open Authorization/OpenID Connect)"), - msg("Modern applications, APIs and Single-page applications."), - () => +export const providerModelsList: LocalTypeCreate[] = [ + { + formName: "oauth2provider", + name: msg("OAuth2/OIDC (Open Authorization/OpenID Connect)"), + description: msg("Modern applications, APIs and Single-page applications."), + renderer: () => html``, - ProviderModelEnum.Oauth2Oauth2provider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.Oauth2Oauth2provider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.Oauth2Oauth2provider, ...(provider as OAuth2ProviderRequest), }), - ], - [ - "ldapprovider", - msg("LDAP (Lightweight Directory Access Protocol)"), - msg("Provide an LDAP interface for applications and users to authenticate against."), - () => + component: "", + iconUrl: "/static/authentik/sources/openidconnect.svg", + }, + { + formName: "ldapprovider", + name: msg("LDAP (Lightweight Directory Access Protocol)"), + description: msg( + "Provide an LDAP interface for applications and users to authenticate against.", + ), + renderer: () => html``, - ProviderModelEnum.LdapLdapprovider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.LdapLdapprovider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.LdapLdapprovider, ...(provider as LDAPProviderRequest), }), - ], - [ - "proxyprovider-proxy", - msg("Transparent Reverse Proxy"), - msg("For transparent reverse proxies with required authentication"), - () => + component: "", + iconUrl: "/static/authentik/sources/ldap.png", + }, + { + formName: "proxyprovider-proxy", + name: msg("Transparent Reverse Proxy"), + description: msg("For transparent reverse proxies with required authentication"), + renderer: () => html``, - ProviderModelEnum.ProxyProxyprovider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.ProxyProxyprovider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.ProxyProxyprovider, ...(provider as ProxyProviderRequest), mode: ProxyMode.Proxy, }), - ], - [ - "proxyprovider-forwardsingle", - msg("Forward Auth (Single Application)"), - msg("For nginx's auth_request or traefik's forwardAuth"), - () => + component: "", + iconUrl: "/static/authentik/sources/proxy.svg", + }, + { + formName: "proxyprovider-forwardsingle", + name: msg("Forward Auth (Single Application)"), + description: msg("For nginx's auth_request or traefik's forwardAuth"), + renderer: () => html``, - ProviderModelEnum.ProxyProxyprovider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.ProxyProxyprovider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.ProxyProxyprovider, ...(provider as ProxyProviderRequest), mode: ProxyMode.ForwardSingle, }), - ], - [ - "proxyprovider-forwarddomain", - msg("Forward Auth (Domain Level)"), - msg("For nginx's auth_request or traefik's forwardAuth per root domain"), - () => + component: "", + iconUrl: "/static/authentik/sources/proxy.svg", + }, + { + formName: "proxyprovider-forwarddomain", + name: msg("Forward Auth (Domain Level)"), + description: msg("For nginx's auth_request or traefik's forwardAuth per root domain"), + renderer: () => html``, - ProviderModelEnum.ProxyProxyprovider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.ProxyProxyprovider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.ProxyProxyprovider, ...(provider as ProxyProviderRequest), mode: ProxyMode.ForwardDomain, }), - ], - [ - "racprovider", - msg("Remote Access Provider"), - msg("Remotely access computers/servers via RDP/SSH/VNC"), - () => + component: "", + iconUrl: "/static/authentik/sources/proxy.svg", + }, + { + formName: "racprovider", + name: msg("Remote Access Provider"), + description: msg("Remotely access computers/servers via RDP/SSH/VNC"), + renderer: () => html``, - ProviderModelEnum.RacRacprovider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.RacRacprovider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.RacRacprovider, ...(provider as RACProviderRequest), }), - () => html`` - ], - [ - "samlprovider", - msg("SAML (Security Assertion Markup Language)"), - msg("Configure SAML provider manually"), - () => + note: () => html``, + requiresEnterprise: true, + component: "", + iconUrl: "/static/authentik/sources/rac.svg", + }, + { + formName: "samlprovider", + name: msg("SAML (Security Assertion Markup Language)"), + description: msg("Configure SAML provider manually"), + renderer: () => html``, - ProviderModelEnum.SamlSamlprovider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.SamlSamlprovider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.SamlSamlprovider, ...(provider as SAMLProviderRequest), }), - ], - [ - "radiusprovider", - msg("RADIUS (Remote Authentication Dial-In User Service)"), - msg("Configure RADIUS provider manually"), - () => + component: "", + iconUrl: "/static/authentik/sources/saml.png", + }, + { + formName: "radiusprovider", + name: msg("RADIUS (Remote Authentication Dial-In User Service)"), + description: msg("Configure RADIUS provider manually"), + renderer: () => html``, - ProviderModelEnum.RadiusRadiusprovider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.RadiusRadiusprovider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.RadiusRadiusprovider, ...(provider as RadiusProviderRequest), }), - ], - [ - "scimprovider", - msg("SCIM (System for Cross-domain Identity Management)"), - msg("Configure SCIM provider manually"), - () => + component: "", + iconUrl: "/static/authentik/sources/radius.svg", + }, + { + formName: "scimprovider", + name: msg("SCIM (System for Cross-domain Identity Management)"), + description: msg("Configure SCIM provider manually"), + renderer: () => html``, - ProviderModelEnum.ScimScimprovider, - (provider: OneOfProvider) => ({ + modelName: ProviderModelEnum.ScimScimprovider, + converter: (provider: OneOfProvider) => ({ providerModel: ProviderModelEnum.ScimScimprovider, ...(provider as SCIMProviderRequest), }), - ], + component: "", + iconUrl: "/static/authentik/sources/scim.png", + }, ]; -function mapProviders([ - formName, - name, - description, - _, - modelName, - converter, - note, -]: ProviderType): LocalTypeCreate { - return { - formName, - name, - description, - component: "", - modelName, - converter, - note, - }; -} - -export const providerModelsList = _providerModelsTable.map(mapProviders); - export const providerRendererList = new Map( - _providerModelsTable.map(([modelName, _0, _1, renderer]) => [modelName, renderer]), + providerModelsList.map((tc) => [tc.formName, tc.renderer]), ); export default providerModelsList; diff --git a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts index ff8196240b..9c89baf94f 100644 --- a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts +++ b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts @@ -3,63 +3,41 @@ import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-text-input"; import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/wizard/TypeCreateWizardPage"; +import { TypeCreateWizardPageLayouts } from "@goauthentik/elements/wizard/TypeCreateWizardPage"; import { msg } from "@lit/localize"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; -import { html, nothing } from "lit"; -import { map } from "lit/directives/map.js"; +import { html } from "lit"; import BasePanel from "../BasePanel"; -import providerModelsList from "./ak-application-wizard-authentication-method-choice.choices"; import type { LocalTypeCreate } from "./ak-application-wizard-authentication-method-choice.choices"; +import providerModelsList from "./ak-application-wizard-authentication-method-choice.choices"; @customElement("ak-application-wizard-authentication-method-choice") export class ApplicationWizardAuthenticationMethodChoice extends WithLicenseSummary(BasePanel) { - constructor() { - super(); - this.handleChoice = this.handleChoice.bind(this); - this.renderProvider = this.renderProvider.bind(this); - } - - handleChoice(ev: InputEvent) { - const target = ev.target as HTMLInputElement; - this.dispatchWizardUpdate({ - update: { - ...this.wizard, - providerModel: target.value, - errors: {}, - }, - status: this.valid ? "valid" : "invalid", - }); - } - - renderProvider(type: LocalTypeCreate) { - const method = this.wizard.providerModel; - - return html`
- - - ${type.description}${type.note ? type.note() : nothing} -
`; - } - render() { + const selectedTypes = providerModelsList.filter( + (t) => t.formName === this.wizard.providerModel, + ); return providerModelsList.length > 0 ? html`
- ${map(providerModelsList, this.renderProvider)} + 0 ? selectedTypes[0] : undefined} + @select=${(ev: CustomEvent) => { + this.dispatchWizardUpdate({ + update: { + ...this.wizard, + providerModel: ev.detail.formName, + errors: {}, + }, + status: this.valid ? "valid" : "invalid", + }); + }} + >
` : html``; } diff --git a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts index 3122830171..c3c7f9d336 100644 --- a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts +++ b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts @@ -9,7 +9,7 @@ import "@goauthentik/elements/forms/HorizontalFormElement"; import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; -import { TemplateResult, css, html, nothing } from "lit"; +import { PropertyValues, TemplateResult, css, html, nothing } from "lit"; import { classMap } from "lit/directives/class-map.js"; import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; @@ -94,8 +94,7 @@ export class ApplicationWizardCommitApplication extends BasePanel { response?: TransactionApplicationResponse; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - willUpdate(_changedProperties: Map) { + willUpdate(_changedProperties: PropertyValues) { if (this.commitState === idleState) { this.response = undefined; this.commitState = runningState; diff --git a/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts b/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts index 9c940c9424..28139bc465 100644 --- a/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts +++ b/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts @@ -12,8 +12,6 @@ import "./radius/ak-application-wizard-authentication-by-radius"; import "./saml/ak-application-wizard-authentication-by-saml-configuration"; import "./scim/ak-application-wizard-authentication-by-scim"; -// prettier-ignore - @customElement("ak-application-wizard-authentication-method") export class ApplicationWizardApplicationDetails extends BasePanel { render() { diff --git a/web/src/admin/common/ak-license-notice.ts b/web/src/admin/common/ak-license-notice.ts index db8eeca1fa..4cc978ac4f 100644 --- a/web/src/admin/common/ak-license-notice.ts +++ b/web/src/admin/common/ak-license-notice.ts @@ -9,15 +9,14 @@ import { customElement, property } from "lit/decorators.js"; @customElement("ak-license-notice") export class AkLicenceNotice extends WithLicenseSummary(AKElement) { @property() - notice = msg("This feature requires an enterprise license."); + notice = msg("Enterprise only"); render() { return this.hasEnterpriseLicense ? nothing : html` - - ${this.notice} - ${msg("Learn more")} + + ${this.notice} `; } diff --git a/web/src/admin/outposts/ServiceConnectionWizard.ts b/web/src/admin/outposts/ServiceConnectionWizard.ts index 5f221fee31..4feed47476 100644 --- a/web/src/admin/outposts/ServiceConnectionWizard.ts +++ b/web/src/admin/outposts/ServiceConnectionWizard.ts @@ -4,73 +4,24 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/forms/ProxyForm"; import "@goauthentik/elements/wizard/FormWizardPage"; +import "@goauthentik/elements/wizard/TypeCreateWizardPage"; import "@goauthentik/elements/wizard/Wizard"; -import { WizardPage } from "@goauthentik/elements/wizard/WizardPage"; +import type { Wizard } from "@goauthentik/elements/wizard/Wizard"; import { msg, str } from "@lit/localize"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; import { CSSResult, TemplateResult, html } from "lit"; -import { property } from "lit/decorators.js"; +import { property, query } from "lit/decorators.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; -import PFForm from "@patternfly/patternfly/components/Form/form.css"; -import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { OutpostsApi, TypeCreate } from "@goauthentik/api"; -@customElement("ak-service-connection-wizard-initial") -export class InitialServiceConnectionWizardPage extends WizardPage { - @property({ attribute: false }) - connectionTypes: TypeCreate[] = []; - - static get styles(): CSSResult[] { - return [PFBase, PFForm, PFButton, PFRadio]; - } - sidebarLabel = () => msg("Select type"); - - activeCallback: () => Promise = async () => { - this.host.isValid = false; - this.shadowRoot - ?.querySelectorAll("input[type=radio]") - .forEach((radio) => { - if (radio.checked) { - radio.dispatchEvent(new CustomEvent("change")); - } - }); - }; - - render(): TemplateResult { - return html`
- ${this.connectionTypes.map((type) => { - return html`
- { - this.host.steps = [ - "initial", - `type-${type.component}-${type.modelName}`, - ]; - this.host.isValid = true; - }} - /> - - ${type.description} -
`; - })} -
`; - } -} - @customElement("ak-service-connection-wizard") export class ServiceConnectionWizard extends AKElement { static get styles(): CSSResult[] { - return [PFBase, PFButton, PFRadio]; + return [PFBase, PFButton]; } @property() @@ -79,6 +30,9 @@ export class ServiceConnectionWizard extends AKElement { @property({ attribute: false }) connectionTypes: TypeCreate[] = []; + @query("ak-wizard") + wizard?: Wizard; + firstUpdated(): void { new OutpostsApi(DEFAULT_CONFIG).outpostsServiceConnectionsAllTypesList().then((types) => { this.connectionTypes = types; @@ -92,11 +46,19 @@ export class ServiceConnectionWizard extends AKElement { header=${msg("New outpost integration")} description=${msg("Create a new outpost integration.")} > - ) => { + if (!this.wizard) return; + this.wizard.steps = [ + "initial", + `type-${ev.detail.component}-${ev.detail.modelName}`, + ]; + this.wizard.isValid = true; + }} > - + ${this.connectionTypes.map((type) => { return html` msg("Select type"); - - activeCallback: () => Promise = async () => { - this.host.isValid = false; - this.shadowRoot - ?.querySelectorAll("input[type=radio]") - .forEach((radio) => { - if (radio.checked) { - radio.dispatchEvent(new CustomEvent("change")); - } - }); - }; - - render(): TemplateResult { - return html`
- ${this.policyTypes.map((type) => { - return html`
- { - const idx = this.host.steps.indexOf("initial") + 1; - // Exclude all current steps starting with type-, - // this happens when the user selects a type and then goes back - this.host.steps = this.host.steps.filter( - (step) => !step.startsWith("type-"), - ); - this.host.steps.splice( - idx, - 0, - `type-${type.component}-${type.modelName}`, - ); - this.host.isValid = true; - }} - /> - - ${type.description} -
`; - })} -
`; - } -} - @customElement("ak-policy-wizard") export class PolicyWizard extends AKElement { static get styles(): CSSResult[] { - return [PFBase, PFButton, PFRadio]; + return [PFBase, PFButton]; } @property() @@ -98,6 +42,9 @@ export class PolicyWizard extends AKElement { @property({ attribute: false }) policyTypes: TypeCreate[] = []; + @query("ak-wizard") + wizard?: Wizard; + firstUpdated(): void { new PoliciesApi(DEFAULT_CONFIG).policiesAllTypesList().then((types) => { this.policyTypes = types; @@ -111,8 +58,26 @@ export class PolicyWizard extends AKElement { header=${msg("New policy")} description=${msg("Create a new policy.")} > - - + ) => { + if (!this.wizard) return; + const idx = this.wizard.steps.indexOf("initial") + 1; + // Exclude all current steps starting with type-, + // this happens when the user selects a type and then goes back + this.wizard.steps = this.wizard.steps.filter( + (step) => !step.startsWith("type-"), + ); + this.wizard.steps.splice( + idx, + 0, + `type-${ev.detail.component}-${ev.detail.modelName}`, + ); + this.wizard.isValid = true; + }} + > + ${this.policyTypes.map((type) => { return html` msg("Select type"); - - activeCallback: () => Promise = async () => { - this.host.isValid = false; - this.shadowRoot - ?.querySelectorAll("input[type=radio]") - .forEach((radio) => { - if (radio.checked) { - radio.dispatchEvent(new CustomEvent("change")); - } - }); - }; - - render(): TemplateResult { - return html`
- ${this.mappingTypes.map((type) => { - const requiresEnteprise = type.requiresEnterprise && !this.hasEnterpriseLicense; - return html`
- { - this.host.steps = [ - "initial", - `type-${type.component}-${type.modelName}`, - ]; - this.host.isValid = true; - }} - ?disabled=${type.requiresEnterprise ? this.hasEnterpriseLicense : false} - /> - - ${type.description} - ${requiresEnteprise - ? html`` - : nothing} -
`; - })} -
`; - } -} - @customElement("ak-property-mapping-wizard") export class PropertyMappingWizard extends AKElement { static get styles() { - return [PFBase, PFButton, PFRadio]; + return [PFBase, PFButton]; } @property({ attribute: false }) mappingTypes: TypeCreate[] = []; + @query("ak-wizard") + wizard?: Wizard; + async firstUpdated(): Promise { this.mappingTypes = await new PropertymappingsApi( DEFAULT_CONFIG, @@ -103,11 +47,19 @@ export class PropertyMappingWizard extends AKElement { header=${msg("New property mapping")} description=${msg("Create a new property mapping.")} > - ) => { + if (!this.wizard) return; + this.wizard.steps = [ + "initial", + `type-${ev.detail.component}-${ev.detail.modelName}`, + ]; + this.wizard.isValid = true; + }} > - + ${this.mappingTypes.map((type) => { return html` msg("Select type"); - - activeCallback: () => Promise = async () => { - this.host.isValid = false; - this.shadowRoot - ?.querySelectorAll("input[type=radio]") - .forEach((radio) => { - if (radio.checked) { - radio.dispatchEvent(new CustomEvent("change")); - } - }); - }; - - renderHint(): TemplateResult { - return html`
-
${msg("Try the new application wizard")}
-
- ${msg( - "The new application wizard greatly simplifies the steps required to create applications and providers.", - )} -
- -
-
`; - } - - render(): TemplateResult { - return html`
- ${this.providerTypes.map((type) => { - const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense; - return html`
- { - this.host.steps = ["initial", `type-${type.component}`]; - this.host.isValid = true; - }} - ?disabled=${requiresEnterprise} - /> - - ${type.description} - ${requiresEnterprise - ? html`` - : nothing} - -
`; - })} -
`; - } -} - @customElement("ak-provider-wizard") export class ProviderWizard extends AKElement { static get styles(): CSSResult[] { - return [PFBase, PFButton, PFRadio]; + return [PFBase, PFButton]; } @property() @@ -115,6 +40,9 @@ export class ProviderWizard extends AKElement { return Promise.resolve(); }; + @query("ak-wizard") + wizard?: Wizard; + async firstUpdated(): Promise { this.providerTypes = await new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList(); } @@ -129,8 +57,17 @@ export class ProviderWizard extends AKElement { return this.finalHandler(); }} > - - + ) => { + if (!this.wizard) return; + this.wizard.steps = ["initial", `type-${ev.detail.component}`]; + this.wizard.isValid = true; + }} + > + ${this.providerTypes.map((type) => { return html` msg("Select type"); - - activeCallback: () => Promise = async () => { - this.host.isValid = false; - this.shadowRoot - ?.querySelectorAll("input[type=radio]") - .forEach((radio) => { - if (radio.checked) { - radio.dispatchEvent(new CustomEvent("change")); - } - }); - }; - - render(): TemplateResult { - return html`
- ${this.sourceTypes.map((type) => { - return html`
- { - this.host.steps = [ - "initial", - `type-${type.component}-${type.modelName}`, - ]; - this.host.isValid = true; - }} - /> - - ${type.description} -
`; - })} -
`; - } -} - @customElement("ak-source-wizard") export class SourceWizard extends AKElement { static get styles(): CSSResult[] { - return [PFBase, PFButton, PFRadio]; + return [PFBase, PFButton]; } @property({ attribute: false }) sourceTypes: TypeCreate[] = []; + @query("ak-wizard") + wizard?: Wizard; + firstUpdated(): void { new SourcesApi(DEFAULT_CONFIG).sourcesAllTypesList().then((types) => { this.sourceTypes = types; @@ -92,8 +46,20 @@ export class SourceWizard extends AKElement { header=${msg("New source")} description=${msg("Create a new source.")} > - - + ) => { + if (!this.wizard) return; + this.wizard.steps = [ + "initial", + `type-${ev.detail.component}-${ev.detail.modelName}`, + ]; + this.wizard.isValid = true; + }} + > + ${this.sourceTypes.map((type) => { return html` msg("Select type"); - - static get styles(): CSSResult[] { - return [PFBase, PFForm, PFButton, PFRadio]; - } - - activeCallback: () => Promise = async () => { - this.host.isValid = false; - this.shadowRoot - ?.querySelectorAll("input[type=radio]") - .forEach((radio) => { - if (radio.checked) { - radio.dispatchEvent(new CustomEvent("change")); - } - }); - }; - - render(): TemplateResult { - return html`
- ${this.stageTypes.map((type) => { - const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense; - return html`
- { - const idx = this.host.steps.indexOf("initial") + 1; - // Exclude all current steps starting with type-, - // this happens when the user selects a type and then goes back - this.host.steps = this.host.steps.filter( - (step) => !step.startsWith("type-"), - ); - this.host.steps.splice( - idx, - 0, - `type-${type.component}-${type.modelName}`, - ); - this.host.isValid = true; - }} - ?disabled=${requiresEnterprise} - /> - - ${type.description}${ - requiresEnterprise ? html`` : nothing - } - -
`; - })} -
`; - } -} - @customElement("ak-stage-wizard") export class StageWizard extends AKElement { static get styles(): CSSResult[] { - return [PFBase, PFButton, PFRadio]; + return [PFBase, PFButton]; } @property() @@ -119,6 +57,9 @@ export class StageWizard extends AKElement { @property({ attribute: false }) stageTypes: TypeCreate[] = []; + @query("ak-wizard") + wizard?: Wizard; + firstUpdated(): void { new StagesApi(DEFAULT_CONFIG).stagesAllTypesList().then((types) => { this.stageTypes = types; @@ -132,8 +73,26 @@ export class StageWizard extends AKElement { header=${msg("New stage")} description=${msg("Create a new stage.")} > - - + ) => { + if (!this.wizard) return; + const idx = this.wizard.steps.indexOf("initial") + 1; + // Exclude all current steps starting with type-, + // this happens when the user selects a type and then goes back + this.wizard.steps = this.wizard.steps.filter( + (step) => !step.startsWith("type-"), + ); + this.wizard.steps.splice( + idx, + 0, + `type-${ev.detail.component}-${ev.detail.modelName}`, + ); + this.wizard.isValid = true; + }} + > + ${this.stageTypes.map((type) => { return html`
- ${this.renderNavigation()} + ${this.renderNavigation()} ${this.renderMainSection()}
${this.renderFooter()} diff --git a/web/src/elements/Alert.ts b/web/src/elements/Alert.ts index e75d2c3936..139c504203 100644 --- a/web/src/elements/Alert.ts +++ b/web/src/elements/Alert.ts @@ -17,6 +17,8 @@ export enum Level { export class Alert extends AKElement { @property({ type: Boolean }) inline = false; + @property({ type: Boolean }) + plain = false; @property() level: Level = Level.Warning; @@ -26,7 +28,11 @@ export class Alert extends AKElement { } render(): TemplateResult { - return html`
+ return html`
diff --git a/web/src/elements/Interface/BrandContextController.ts b/web/src/elements/Interface/BrandContextController.ts index ce47875443..ac3106ed58 100644 --- a/web/src/elements/Interface/BrandContextController.ts +++ b/web/src/elements/Interface/BrandContextController.ts @@ -1,5 +1,5 @@ -import { EVENT_REFRESH } from "@goauthentik/authentik/common/constants"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; diff --git a/web/src/elements/Interface/ConfigContextController.ts b/web/src/elements/Interface/ConfigContextController.ts index 35e38d54ea..c626a7a9c9 100644 --- a/web/src/elements/Interface/ConfigContextController.ts +++ b/web/src/elements/Interface/ConfigContextController.ts @@ -1,5 +1,5 @@ -import { EVENT_REFRESH } from "@goauthentik/authentik/common/constants"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; diff --git a/web/src/elements/Interface/EnterpriseContextController.ts b/web/src/elements/Interface/EnterpriseContextController.ts index faa3b4823b..f0cc1ac2a8 100644 --- a/web/src/elements/Interface/EnterpriseContextController.ts +++ b/web/src/elements/Interface/EnterpriseContextController.ts @@ -1,5 +1,5 @@ -import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/authentik/common/constants"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/common/constants"; import { authentikEnterpriseContext } from "@goauthentik/elements/AuthentikContexts"; import type { ReactiveElementHost } from "@goauthentik/elements/types.js"; diff --git a/web/src/elements/SyncStatusCard.ts b/web/src/elements/SyncStatusCard.ts index db8a4f17d7..9009322027 100644 --- a/web/src/elements/SyncStatusCard.ts +++ b/web/src/elements/SyncStatusCard.ts @@ -1,7 +1,7 @@ -import { EVENT_REFRESH } from "@goauthentik/authentik/common/constants"; -import { getRelativeTime } from "@goauthentik/authentik/common/utils"; -import { AKElement } from "@goauthentik/authentik/elements/Base"; +import { EVENT_REFRESH } from "@goauthentik/common/constants"; +import { getRelativeTime } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-status-label"; +import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/events/LogViewer"; diff --git a/web/src/elements/ak-locale-context/configureLocale.ts b/web/src/elements/ak-locale-context/configureLocale.ts index 9d007111d3..ddf47c85b1 100644 --- a/web/src/elements/ak-locale-context/configureLocale.ts +++ b/web/src/elements/ak-locale-context/configureLocale.ts @@ -1,7 +1,6 @@ -import { sourceLocale, targetLocales } from "@goauthentik/authentik/locale-codes"; - import { configureLocalization } from "@lit/localize"; +import { sourceLocale, targetLocales } from "../../locale-codes"; import { getBestMatchLocale } from "./helpers"; type LocaleGetter = ReturnType["getLocale"]; diff --git a/web/src/elements/wizard/TypeCreateWizardPage.ts b/web/src/elements/wizard/TypeCreateWizardPage.ts new file mode 100644 index 0000000000..567edd579d --- /dev/null +++ b/web/src/elements/wizard/TypeCreateWizardPage.ts @@ -0,0 +1,146 @@ +import "@goauthentik/admin/common/ak-license-notice"; +import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider"; +import { WizardPage } from "@goauthentik/elements/wizard/WizardPage"; + +import { msg, str } from "@lit/localize"; +import { CSSResult, TemplateResult, css, html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; +import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { TypeCreate } from "@goauthentik/api"; + +export enum TypeCreateWizardPageLayouts { + list = "list", + grid = "grid", +} + +@customElement("ak-wizard-page-type-create") +export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) { + @property({ attribute: false }) + types: TypeCreate[] = []; + + @property({ attribute: false }) + selectedType?: TypeCreate; + + @property({ type: String }) + layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list; + + static get styles(): CSSResult[] { + return [ + PFBase, + PFForm, + PFGrid, + PFRadio, + PFCard, + css` + .pf-c-card__header-main img { + max-height: 2em; + min-height: 2em; + } + :host([theme="dark"]) .pf-c-card__header-main img { + filter: invert(1); + } + `, + ]; + } + + sidebarLabel = () => msg("Select type"); + + activeCallback: () => Promise = async () => { + this.host.isValid = false; + if (this.selectedType) { + this.selectDispatch(this.selectedType); + } + }; + + private selectDispatch(type: TypeCreate) { + this.dispatchEvent( + new CustomEvent("select", { + detail: type, + bubbles: true, + composed: true, + }), + ); + } + + renderGrid(): TemplateResult { + return html`
+ ${this.types.map((type, idx) => { + const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense; + return html`
{ + if (requiresEnterprise) { + return; + } + this.selectDispatch(type); + this.selectedType = type; + }} + > + ${type.iconUrl + ? html`
+
+ ${msg(str`${type.name} +
+
` + : nothing} +
${type.name}
+
${type.description}
+ ${requiresEnterprise + ? html` ` + : nothing} +
`; + })} +
`; + } + + renderList(): TemplateResult { + return html`
+ ${this.types.map((type) => { + const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense; + return html`
+ { + this.selectDispatch(type); + }} + ?disabled=${requiresEnterprise} + /> + + ${type.description} + ${requiresEnterprise + ? html`` + : nothing} + +
`; + })} +
`; + } + + render(): TemplateResult { + switch (this.layout) { + case TypeCreateWizardPageLayouts.grid: + return this.renderGrid(); + case TypeCreateWizardPageLayouts.list: + return this.renderList(); + } + } +} diff --git a/web/src/user/user-settings/tokens/UserTokenForm.ts b/web/src/user/user-settings/tokens/UserTokenForm.ts index eb94821616..d0fce872f2 100644 --- a/web/src/user/user-settings/tokens/UserTokenForm.ts +++ b/web/src/user/user-settings/tokens/UserTokenForm.ts @@ -1,5 +1,5 @@ -import { dateTimeLocal } from "@goauthentik/authentik/common/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { dateTimeLocal } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; diff --git a/web/tsconfig.json b/web/tsconfig.json index 887178d6d3..c7fd0f6358 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -2,7 +2,6 @@ "extends": "./tsconfig.base.json", "compilerOptions": { "paths": { - "@goauthentik/authentik/*": ["src/*"], "@goauthentik/admin/*": ["src/admin/*"], "@goauthentik/common/*": ["src/common/*"], "@goauthentik/components/*": ["src/components/*"],