diff --git a/authentik/admin/templates/administration/base.html b/authentik/admin/templates/administration/base.html deleted file mode 100644 index ee466a3fc..000000000 --- a/authentik/admin/templates/administration/base.html +++ /dev/null @@ -1,5 +0,0 @@ -{% load static %} -{% load i18n %} - -{% block content %} -{% endblock %} diff --git a/authentik/admin/templates/generic/form.html b/authentik/admin/templates/generic/form.html index 2ef3e09e9..0dd1c2ad1 100644 --- a/authentik/admin/templates/generic/form.html +++ b/authentik/admin/templates/generic/form.html @@ -1,5 +1,3 @@ -{% extends container_template|default:"administration/base.html" %} - {% load i18n %} {% load authentik_utils %} {% load static %} diff --git a/authentik/admin/tests/test_policy_binding.py b/authentik/admin/tests/test_policy_binding.py deleted file mode 100644 index 034cfd1d1..000000000 --- a/authentik/admin/tests/test_policy_binding.py +++ /dev/null @@ -1,43 +0,0 @@ -"""admin tests""" -from uuid import uuid4 - -from django import forms -from django.test import TestCase -from django.test.client import RequestFactory - -from authentik.admin.views.policies_bindings import PolicyBindingCreateView -from authentik.core.models import Application -from authentik.policies.forms import PolicyBindingForm - - -class TestPolicyBindingView(TestCase): - """Generic admin tests""" - - def setUp(self): - self.factory = RequestFactory() - - def test_without_get_param(self): - """Test PolicyBindingCreateView without get params""" - request = self.factory.get("/") - view = PolicyBindingCreateView(request=request) - self.assertEqual(view.get_initial(), {}) - - def test_with_params_invalid(self): - """Test PolicyBindingCreateView with invalid get params""" - request = self.factory.get("/", {"target": uuid4()}) - view = PolicyBindingCreateView(request=request) - self.assertEqual(view.get_initial(), {}) - - def test_with_params(self): - """Test PolicyBindingCreateView with get params""" - target = Application.objects.create(name="test") - request = self.factory.get("/", {"target": target.pk.hex}) - view = PolicyBindingCreateView(request=request) - self.assertEqual(view.get_initial(), {"target": target, "order": 0}) - - self.assertTrue( - isinstance( - PolicyBindingForm(initial={"target": "foo"}).fields["target"].widget, - forms.HiddenInput, - ) - ) diff --git a/authentik/admin/tests/test_stage_bindings.py b/authentik/admin/tests/test_stage_bindings.py deleted file mode 100644 index 481fcaca4..000000000 --- a/authentik/admin/tests/test_stage_bindings.py +++ /dev/null @@ -1,43 +0,0 @@ -"""admin tests""" -from uuid import uuid4 - -from django import forms -from django.test import TestCase -from django.test.client import RequestFactory - -from authentik.admin.views.stages_bindings import StageBindingCreateView -from authentik.flows.forms import FlowStageBindingForm -from authentik.flows.models import Flow - - -class TestStageBindingView(TestCase): - """Generic admin tests""" - - def setUp(self): - self.factory = RequestFactory() - - def test_without_get_param(self): - """Test StageBindingCreateView without get params""" - request = self.factory.get("/") - view = StageBindingCreateView(request=request) - self.assertEqual(view.get_initial(), {}) - - def test_with_params_invalid(self): - """Test StageBindingCreateView with invalid get params""" - request = self.factory.get("/", {"target": uuid4()}) - view = StageBindingCreateView(request=request) - self.assertEqual(view.get_initial(), {}) - - def test_with_params(self): - """Test StageBindingCreateView with get params""" - target = Flow.objects.create(name="test", slug="test") - request = self.factory.get("/", {"target": target.pk.hex}) - view = StageBindingCreateView(request=request) - self.assertEqual(view.get_initial(), {"target": target, "order": 0}) - - self.assertTrue( - isinstance( - FlowStageBindingForm(initial={"target": "foo"}).fields["target"].widget, - forms.HiddenInput, - ) - ) diff --git a/authentik/admin/urls.py b/authentik/admin/urls.py index 6d4ad1b51..73615509d 100644 --- a/authentik/admin/urls.py +++ b/authentik/admin/urls.py @@ -4,14 +4,10 @@ from django.urls import path from authentik.admin.views import ( outposts_service_connections, policies, - policies_bindings, property_mappings, providers, sources, stages, - stages_bindings, - stages_invitations, - stages_prompts, ) from authentik.providers.saml.views.metadata import MetadataImportView @@ -30,17 +26,6 @@ urlpatterns = [ policies.PolicyUpdateView.as_view(), name="policy-update", ), - # Policy bindings - path( - "policies/bindings/create/", - policies_bindings.PolicyBindingCreateView.as_view(), - name="policy-binding-create", - ), - path( - "policies/bindings//update/", - policies_bindings.PolicyBindingUpdateView.as_view(), - name="policy-binding-update", - ), # Providers path( "providers/create/", @@ -64,34 +49,6 @@ urlpatterns = [ stages.StageUpdateView.as_view(), name="stage-update", ), - # Stage bindings - path( - "stages/bindings/create/", - stages_bindings.StageBindingCreateView.as_view(), - name="stage-binding-create", - ), - path( - "stages/bindings//update/", - stages_bindings.StageBindingUpdateView.as_view(), - name="stage-binding-update", - ), - # Stage Prompts - path( - "stages_prompts/create/", - stages_prompts.PromptCreateView.as_view(), - name="stage-prompt-create", - ), - path( - "stages_prompts//update/", - stages_prompts.PromptUpdateView.as_view(), - name="stage-prompt-update", - ), - # Stage Invitations - path( - "stages/invitations/create/", - stages_invitations.InvitationCreateView.as_view(), - name="stage-invitation-create", - ), # Property Mappings path( "property-mappings/create/", diff --git a/authentik/admin/views/policies_bindings.py b/authentik/admin/views/policies_bindings.py deleted file mode 100644 index fbbd33f29..000000000 --- a/authentik/admin/views/policies_bindings.py +++ /dev/null @@ -1,67 +0,0 @@ -"""authentik PolicyBinding administration""" -from typing import Any - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.db.models import Max -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import UpdateView -from guardian.mixins import PermissionRequiredMixin - -from authentik.lib.views import CreateAssignPermView -from authentik.policies.forms import PolicyBindingForm -from authentik.policies.models import PolicyBinding, PolicyBindingModel - - -class PolicyBindingCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new PolicyBinding""" - - model = PolicyBinding - permission_required = "authentik_policies.add_policybinding" - form_class = PolicyBindingForm - - template_name = "generic/create.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully created PolicyBinding") - - def get_initial(self) -> dict[str, Any]: - if "target" in self.request.GET: - initial_target_pk = self.request.GET["target"] - targets = PolicyBindingModel.objects.filter( - pk=initial_target_pk - ).select_subclasses() - if not targets.exists(): - return {} - max_order = PolicyBinding.objects.filter(target=targets.first()).aggregate( - Max("order") - )["order__max"] - if not isinstance(max_order, int): - max_order = -1 - return {"target": targets.first(), "order": max_order + 1} - return super().get_initial() - - -class PolicyBindingUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update policybinding""" - - model = PolicyBinding - permission_required = "authentik_policies.change_policybinding" - form_class = PolicyBindingForm - - template_name = "generic/update.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully updated PolicyBinding") diff --git a/authentik/admin/views/stages_bindings.py b/authentik/admin/views/stages_bindings.py deleted file mode 100644 index 3bebcb75c..000000000 --- a/authentik/admin/views/stages_bindings.py +++ /dev/null @@ -1,65 +0,0 @@ -"""authentik StageBinding administration""" -from typing import Any - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.db.models import Max -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import UpdateView -from guardian.mixins import PermissionRequiredMixin - -from authentik.flows.forms import FlowStageBindingForm -from authentik.flows.models import Flow, FlowStageBinding -from authentik.lib.views import CreateAssignPermView - - -class StageBindingCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new StageBinding""" - - model = FlowStageBinding - permission_required = "authentik_flows.add_flowstagebinding" - form_class = FlowStageBindingForm - - template_name = "generic/create.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully created StageBinding") - - def get_initial(self) -> dict[str, Any]: - if "target" in self.request.GET: - initial_target_pk = self.request.GET["target"] - targets = Flow.objects.filter(pk=initial_target_pk).select_subclasses() - if not targets.exists(): - return {} - max_order = FlowStageBinding.objects.filter( - target=targets.first() - ).aggregate(Max("order"))["order__max"] - if not isinstance(max_order, int): - max_order = -1 - return {"target": targets.first(), "order": max_order + 1} - return super().get_initial() - - -class StageBindingUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update FlowStageBinding""" - - model = FlowStageBinding - permission_required = "authentik_flows.change_flowstagebinding" - form_class = FlowStageBindingForm - - template_name = "generic/update.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully updated StageBinding") diff --git a/authentik/admin/views/stages_invitations.py b/authentik/admin/views/stages_invitations.py deleted file mode 100644 index ca2552c58..000000000 --- a/authentik/admin/views/stages_invitations.py +++ /dev/null @@ -1,36 +0,0 @@ -"""authentik Invitation administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.http import HttpResponseRedirect -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ - -from authentik.lib.views import CreateAssignPermView -from authentik.stages.invitation.forms import InvitationForm -from authentik.stages.invitation.models import Invitation - - -class InvitationCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Invitation""" - - model = Invitation - form_class = InvitationForm - permission_required = "authentik_stages_invitation.add_invitation" - - template_name = "generic/create.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully created Invitation") - - def form_valid(self, form): - obj = form.save(commit=False) - obj.created_by = self.request.user - obj.save() - return HttpResponseRedirect(self.success_url) diff --git a/authentik/admin/views/stages_prompts.py b/authentik/admin/views/stages_prompts.py deleted file mode 100644 index f47e6dba1..000000000 --- a/authentik/admin/views/stages_prompts.py +++ /dev/null @@ -1,48 +0,0 @@ -"""authentik Prompt administration""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.urls import reverse_lazy -from django.utils.translation import gettext as _ -from django.views.generic import UpdateView -from guardian.mixins import PermissionRequiredMixin - -from authentik.lib.views import CreateAssignPermView -from authentik.stages.prompt.forms import PromptAdminForm -from authentik.stages.prompt.models import Prompt - - -class PromptCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Prompt""" - - model = Prompt - form_class = PromptAdminForm - permission_required = "authentik_stages_prompt.add_prompt" - - template_name = "generic/create.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully created Prompt") - - -class PromptUpdateView( - SuccessMessageMixin, - LoginRequiredMixin, - PermissionRequiredMixin, - UpdateView, -): - """Update prompt""" - - model = Prompt - form_class = PromptAdminForm - permission_required = "authentik_stages_prompt.change_prompt" - - template_name = "generic/update.html" - success_url = reverse_lazy("authentik_core:if-admin") - success_message = _("Successfully updated Prompt") diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py index 3a2bb4911..f4ee86451 100644 --- a/authentik/core/api/tokens.py +++ b/authentik/core/api/tokens.py @@ -18,7 +18,7 @@ from authentik.events.models import Event, EventAction class TokenSerializer(ModelSerializer): """Token Serializer""" - user = UserSerializer() + user = UserSerializer(required=False) class Meta: @@ -61,6 +61,9 @@ class TokenViewSet(ModelViewSet): ] ordering = ["expires"] + def perform_create(self, serializer: TokenSerializer): + serializer.save(user=self.request.user) + @permission_required("authentik_core.view_token_key") @swagger_auto_schema(responses={200: TokenViewSerializer(many=False)}) @action(detail=True) diff --git a/authentik/core/forms/__init__.py b/authentik/core/forms/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/authentik/core/forms/token.py b/authentik/core/forms/token.py deleted file mode 100644 index 9bc43aa8f..000000000 --- a/authentik/core/forms/token.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Core user token form""" -from django import forms - -from authentik.core.models import Token - - -class UserTokenForm(forms.ModelForm): - """Token form, for tokens created by endusers""" - - class Meta: - - model = Token - fields = [ - "identifier", - "expires", - "expiring", - "description", - ] - widgets = { - "identifier": forms.TextInput(), - "description": forms.TextInput(), - } diff --git a/authentik/core/tests/test_api.py b/authentik/core/tests/test_property_mapping_api.py similarity index 100% rename from authentik/core/tests/test_api.py rename to authentik/core/tests/test_property_mapping_api.py diff --git a/authentik/core/tests/test_token_api.py b/authentik/core/tests/test_token_api.py new file mode 100644 index 000000000..5588bc527 --- /dev/null +++ b/authentik/core/tests/test_token_api.py @@ -0,0 +1,22 @@ +"""Test token API""" +from django.urls.base import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Token, User + + +class TestTokenAPI(APITestCase): + """Test token API""" + + def setUp(self) -> None: + super().setUp() + self.user = User.objects.get(username="akadmin") + self.client.force_login(self.user) + + def test_token_create(self): + """Test token creation endpoint""" + response = self.client.post( + reverse("authentik_api:token-list"), {"identifier": "test-token"} + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(Token.objects.get(identifier="test-token").user, self.user) diff --git a/authentik/core/urls.py b/authentik/core/urls.py index a8f2d91b6..181f8918f 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -5,7 +5,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic import RedirectView from django.views.generic.base import TemplateView -from authentik.core.views import impersonate, user +from authentik.core.views import impersonate urlpatterns = [ path( @@ -13,17 +13,6 @@ urlpatterns = [ login_required(RedirectView.as_view(pattern_name="authentik_core:if-admin")), name="root-redirect", ), - # User views - path( - "-/user/tokens/create/", - user.TokenCreateView.as_view(), - name="user-tokens-create", - ), - path( - "-/user/tokens//update/", - user.TokenUpdateView.as_view(), - name="user-tokens-update", - ), # Impersonation path( "-/impersonation//", diff --git a/authentik/core/views/user.py b/authentik/core/views/user.py deleted file mode 100644 index 968547434..000000000 --- a/authentik/core/views/user.py +++ /dev/null @@ -1,60 +0,0 @@ -"""authentik core user views""" -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.auth.mixins import ( - PermissionRequiredMixin as DjangoPermissionRequiredMixin, -) -from django.contrib.messages.views import SuccessMessageMixin -from django.http.response import HttpResponse -from django.utils.translation import gettext as _ -from django.views.generic import UpdateView -from guardian.mixins import PermissionRequiredMixin -from guardian.shortcuts import get_objects_for_user - -from authentik.core.forms.token import UserTokenForm -from authentik.core.models import Token, TokenIntents -from authentik.lib.views import CreateAssignPermView - - -class TokenCreateView( - SuccessMessageMixin, - LoginRequiredMixin, - DjangoPermissionRequiredMixin, - CreateAssignPermView, -): - """Create new Token""" - - model = Token - form_class = UserTokenForm - permission_required = "authentik_core.add_token" - - template_name = "generic/create.html" - success_url = "/" - success_message = _("Successfully created Token") - - def form_valid(self, form: UserTokenForm) -> HttpResponse: - form.instance.user = self.request.user - form.instance.intent = TokenIntents.INTENT_API - return super().form_valid(form) - - -class TokenUpdateView( - SuccessMessageMixin, LoginRequiredMixin, PermissionRequiredMixin, UpdateView -): - """Update token""" - - model = Token - form_class = UserTokenForm - permission_required = "authentik_core.change_token" - template_name = "generic/update.html" - success_url = "/" - success_message = _("Successfully updated Token") - - def get_object(self) -> Token: - identifier = self.kwargs.get("identifier") - return ( - get_objects_for_user( - self.request.user, self.permission_required, self.model - ) - .filter(intent=TokenIntents.INTENT_API, identifier=identifier) - .first() - ) diff --git a/authentik/flows/forms.py b/authentik/flows/forms.py deleted file mode 100644 index 79b4a2b31..000000000 --- a/authentik/flows/forms.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Flow and Stage forms""" -from django import forms - -from authentik.flows.models import FlowStageBinding, Stage -from authentik.lib.widgets import GroupedModelChoiceField - - -class FlowStageBindingForm(forms.ModelForm): - """FlowStageBinding Form""" - - stage = GroupedModelChoiceField( - queryset=Stage.objects.all().order_by("name").select_subclasses(), - to_field_name="stage_uuid", - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if "target" in self.initial: - self.fields["target"].widget = forms.HiddenInput() - - class Meta: - - model = FlowStageBinding - fields = [ - "target", - "stage", - "evaluate_on_plan", - "re_evaluate_policies", - "order", - "policy_engine_mode", - ] - widgets = { - "name": forms.TextInput(), - } diff --git a/authentik/flows/transfer/common.py b/authentik/flows/transfer/common.py index 30b3b3bbb..eee22f10e 100644 --- a/authentik/flows/transfer/common.py +++ b/authentik/flows/transfer/common.py @@ -29,6 +29,9 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]: for to_remove_name in to_remove: if to_remove_name in data: data.pop(to_remove_name) + for key in list(data.keys()): + if key.endswith("_obj"): + data.pop(key) return data diff --git a/authentik/policies/api/bindings.py b/authentik/policies/api/bindings.py index 5ed92ccaa..a10f1d968 100644 --- a/authentik/policies/api/bindings.py +++ b/authentik/policies/api/bindings.py @@ -1,10 +1,18 @@ """policy binding API Views""" +from typing import OrderedDict + from django.core.exceptions import ObjectDoesNotExist -from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField +from rest_framework.serializers import ( + ModelSerializer, + PrimaryKeyRelatedField, + ValidationError, +) from rest_framework.viewsets import ModelViewSet from structlog.stdlib import get_logger from authentik.core.api.groups import GroupSerializer +from authentik.core.api.users import UserSerializer +from authentik.policies.api.policies import PolicySerializer from authentik.policies.models import PolicyBinding, PolicyBindingModel LOGGER = get_logger() @@ -26,8 +34,8 @@ class PolicyBindingModelForeignKey(PrimaryKeyRelatedField): # won't return anything. This is because the direct lookup # checks the PK of PolicyBindingModel (for example), # but we get given the Primary Key of the inheriting class - for model in self.get_queryset().select_subclasses().all().select_related(): - if model.pk == data: + for model in self.get_queryset().select_subclasses().all(): + if str(model.pk) == str(data): return model # as a fallback we still try a direct lookup return self.get_queryset().get_subclass(pk=data) @@ -51,7 +59,9 @@ class PolicyBindingSerializer(ModelSerializer): required=True, ) - group = GroupSerializer(required=False) + policy_obj = PolicySerializer(required=False, read_only=True, source="policy") + group_obj = GroupSerializer(required=False, read_only=True, source="group") + user_obj = UserSerializer(required=False, read_only=True, source="user") class Meta: @@ -61,12 +71,31 @@ class PolicyBindingSerializer(ModelSerializer): "policy", "group", "user", + "policy_obj", + "group_obj", + "user_obj", "target", "enabled", "order", "timeout", ] - depth = 2 + + def validate(self, data: OrderedDict) -> OrderedDict: + """Check that either policy, group or user is set.""" + count = sum( + [ + bool(data.get("policy", None)), + bool(data.get("group", None)), + bool(data.get("user", None)), + ] + ) + invalid = count > 1 + empty = count < 1 + if invalid: + raise ValidationError("Only one of 'policy', 'group' or 'user' can be set.") + if empty: + raise ValidationError("One of 'policy', 'group' or 'user' must be set.") + return data class PolicyBindingViewSet(ModelViewSet): diff --git a/authentik/policies/tests/test_bindings_api.py b/authentik/policies/tests/test_bindings_api.py new file mode 100644 index 000000000..fc699391d --- /dev/null +++ b/authentik/policies/tests/test_bindings_api.py @@ -0,0 +1,56 @@ +"""Test bindings API""" +from django.urls import reverse +from rest_framework.test import APITestCase + +from authentik.core.models import Group, User +from authentik.policies.models import PolicyBindingModel + + +class TestBindingsAPI(APITestCase): + """Test bindings API""" + + def setUp(self) -> None: + super().setUp() + self.pbm = PolicyBindingModel.objects.create() + self.group = Group.objects.first() + self.user = User.objects.get(username="akadmin") + self.client.force_login(self.user) + + def test_valid_binding(self): + """Test valid binding""" + response = self.client.post( + reverse("authentik_api:policybinding-list"), + data={"target": self.pbm.pk, "user": self.user.pk, "order": 0}, + ) + self.assertEqual(response.status_code, 201) + + def test_invalid_too_much(self): + """Test invalid binding (too much)""" + response = self.client.post( + reverse("authentik_api:policybinding-list"), + data={ + "target": self.pbm.pk, + "user": self.user.pk, + "group": self.group.pk, + "order": 0, + }, + ) + self.assertJSONEqual( + response.content.decode(), + { + "non_field_errors": [ + "Only one of 'policy', 'group' or 'user' can be set." + ] + }, + ) + + def test_invalid_too_little(self): + """Test invvalid binding (too little)""" + response = self.client.post( + reverse("authentik_api:policybinding-list"), + data={"target": self.pbm.pk, "order": 0}, + ) + self.assertJSONEqual( + response.content.decode(), + {"non_field_errors": ["One of 'policy', 'group' or 'user' must be set."]}, + ) diff --git a/authentik/policies/tests/test_api.py b/authentik/policies/tests/test_policies_api.py similarity index 100% rename from authentik/policies/tests/test_api.py rename to authentik/policies/tests/test_policies_api.py diff --git a/authentik/stages/invitation/api.py b/authentik/stages/invitation/api.py index 8bc103c56..1783bd76e 100644 --- a/authentik/stages/invitation/api.py +++ b/authentik/stages/invitation/api.py @@ -49,5 +49,4 @@ class InvitationViewSet(ModelViewSet): filterset_fields = ["created_by__username", "expires"] def perform_create(self, serializer: InvitationSerializer): - serializer.instance.created_by = self.request.user - return super().perform_create(serializer) + serializer.save(created_by=self.request.user) diff --git a/authentik/stages/invitation/forms.py b/authentik/stages/invitation/forms.py index 30fd2622f..a34bbe740 100644 --- a/authentik/stages/invitation/forms.py +++ b/authentik/stages/invitation/forms.py @@ -1,8 +1,7 @@ """authentik flows invitation forms""" from django import forms -from authentik.admin.fields import CodeMirrorWidget, YAMLField -from authentik.stages.invitation.models import Invitation, InvitationStage +from authentik.stages.invitation.models import InvitationStage class InvitationStageForm(forms.ModelForm): @@ -15,14 +14,3 @@ class InvitationStageForm(forms.ModelForm): widgets = { "name": forms.TextInput(), } - - -class InvitationForm(forms.ModelForm): - """InvitationForm""" - - class Meta: - - model = Invitation - fields = ["expires", "fixed_data"] - widgets = {"fixed_data": CodeMirrorWidget()} - field_classes = {"fixed_data": YAMLField} diff --git a/authentik/stages/invitation/tests.py b/authentik/stages/invitation/tests.py index 85b263c31..c0df4b6f7 100644 --- a/authentik/stages/invitation/tests.py +++ b/authentik/stages/invitation/tests.py @@ -5,6 +5,7 @@ from django.test import Client, TestCase from django.urls import reverse from django.utils.encoding import force_str from guardian.shortcuts import get_anonymous_user +from rest_framework.test import APITestCase from authentik.core.models import User from authentik.flows.challenge import ChallengeTypes @@ -134,3 +135,20 @@ class TestUserLoginStage(TestCase): force_str(response.content), {"to": reverse("authentik_core:root-redirect"), "type": "redirect"}, ) + + +class TestInvitationsAPI(APITestCase): + """Test Invitations API""" + + def setUp(self) -> None: + super().setUp() + self.user = User.objects.get(username="akadmin") + self.client.force_login(self.user) + + def test_invite_create(self): + """Test Invitations creation endpoint""" + response = self.client.post( + reverse("authentik_api:invitation-list"), {"identifier": "test-token"} + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(Invitation.objects.first().created_by, self.user) diff --git a/authentik/stages/prompt/forms.py b/authentik/stages/prompt/forms.py index abdcf0ed8..9f3b22bde 100644 --- a/authentik/stages/prompt/forms.py +++ b/authentik/stages/prompt/forms.py @@ -1,7 +1,7 @@ """Prompt forms""" from django import forms -from authentik.stages.prompt.models import Prompt, PromptStage +from authentik.stages.prompt.models import PromptStage class PromptStageForm(forms.ModelForm): @@ -14,23 +14,3 @@ class PromptStageForm(forms.ModelForm): widgets = { "name": forms.TextInput(), } - - -class PromptAdminForm(forms.ModelForm): - """Form to edit Prompt instances for admins""" - - class Meta: - - model = Prompt - fields = [ - "field_key", - "label", - "type", - "required", - "placeholder", - "order", - ] - widgets = { - "label": forms.TextInput(), - "placeholder": forms.TextInput(), - } diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py index e7450725b..c5a00095f 100644 --- a/authentik/stages/prompt/models.py +++ b/authentik/stages/prompt/models.py @@ -12,6 +12,7 @@ from rest_framework.fields import ( DateField, DateTimeField, EmailField, + HiddenField, IntegerField, ) from rest_framework.serializers import BaseSerializer @@ -89,10 +90,10 @@ class Prompt(SerializerModel): field_class = EmailField if self.type == FieldTypes.NUMBER: field_class = IntegerField - # TODO: Hidden? if self.type == FieldTypes.HIDDEN: + field_class = HiddenField kwargs["required"] = False - kwargs["initial"] = self.placeholder + kwargs["default"] = self.placeholder if self.type == FieldTypes.CHECKBOX: field_class = BooleanField kwargs["required"] = False diff --git a/swagger.yaml b/swagger.yaml index 378ac2615..209392d4a 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -14827,7 +14827,6 @@ definitions: Token: required: - identifier - - user type: object properties: pk: @@ -16007,246 +16006,25 @@ definitions: format: uuid readOnly: true policy: - type: object - properties: - policy_uuid: - title: Policy uuid - type: string - format: uuid - readOnly: true - created: - title: Created - type: string - format: date-time - readOnly: true - last_updated: - title: Last updated - type: string - format: date-time - readOnly: true - name: - title: Name - type: string - x-nullable: true - execution_logging: - title: Execution logging - description: When this option is enabled, all executions of this policy - will be logged. By default, only execution errors are logged. - type: boolean - readOnly: true + title: Policy + type: string + format: uuid + x-nullable: true group: - $ref: '#/definitions/Group' + title: Group + type: string + format: uuid + x-nullable: true user: - required: - - password - - username - - name - type: object - properties: - id: - title: ID - type: integer - readOnly: true - password: - title: Password - type: string - maxLength: 128 - minLength: 1 - last_login: - title: Last login - type: string - format: date-time - x-nullable: true - username: - title: Username - description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_ - only. - type: string - pattern: ^[\w.@+-]+$ - maxLength: 150 - minLength: 1 - first_name: - title: First name - type: string - maxLength: 150 - last_name: - title: Last name - type: string - maxLength: 150 - email: - title: Email address - type: string - format: email - maxLength: 254 - is_active: - title: Active - description: Designates whether this user should be treated as active. - Unselect this instead of deleting accounts. - type: boolean - date_joined: - title: Date joined - type: string - format: date-time - uuid: - title: Uuid - type: string - format: uuid - readOnly: true - name: - title: Name - description: User's display name. - type: string - minLength: 1 - password_change_date: - title: Password change date - type: string - format: date-time - readOnly: true - attributes: - title: Attributes - type: object - groups: - type: array - items: - required: - - name - type: object - properties: - id: - title: ID - type: integer - readOnly: true - name: - title: Name - type: string - maxLength: 150 - minLength: 1 - permissions: - type: array - items: - type: integer - uniqueItems: true - readOnly: true - user_permissions: - type: array - items: - required: - - name - - codename - - content_type - type: object - properties: - id: - title: ID - type: integer - readOnly: true - name: - title: Name - type: string - maxLength: 255 - minLength: 1 - codename: - title: Codename - type: string - maxLength: 100 - minLength: 1 - content_type: - title: Content type - type: integer - readOnly: true - sources: - type: array - items: - required: - - name - - slug - type: object - properties: - pbm_uuid: - title: Pbm uuid - type: string - format: uuid - readOnly: true - policy_engine_mode: - title: Policy engine mode - type: string - enum: - - all - - any - name: - title: Name - description: Source's display Name. - type: string - minLength: 1 - slug: - title: Slug - description: Internal source name, used in URLs. - type: string - format: slug - pattern: ^[-a-zA-Z0-9_]+$ - maxLength: 50 - minLength: 1 - enabled: - title: Enabled - type: boolean - authentication_flow: - title: Authentication flow - description: Flow to use when authenticating existing users. - type: string - format: uuid - x-nullable: true - enrollment_flow: - title: Enrollment flow - description: Flow to use when enrolling new users. - type: string - format: uuid - x-nullable: true - policies: - type: array - items: - type: string - format: uuid - readOnly: true - uniqueItems: true - property_mappings: - type: array - items: - type: string - format: uuid - uniqueItems: true - readOnly: true - ak_groups: - type: array - items: - required: - - name - - parent - type: object - properties: - group_uuid: - title: Group uuid - type: string - format: uuid - readOnly: true - name: - title: Name - type: string - maxLength: 80 - minLength: 1 - is_superuser: - title: Is superuser - description: Users added to this group will be superusers. - type: boolean - attributes: - title: Attributes - type: object - parent: - title: Parent - type: string - format: uuid - x-nullable: true - readOnly: true - readOnly: true + title: User + type: integer + x-nullable: true + policy_obj: + $ref: '#/definitions/Policy' + group_obj: + $ref: '#/definitions/Group' + user_obj: + $ref: '#/definitions/User' target: title: Target type: string diff --git a/web/src/api/legacy.ts b/web/src/api/legacy.ts index 2c1855575..bd6a9dc33 100644 --- a/web/src/api/legacy.ts +++ b/web/src/api/legacy.ts @@ -4,10 +4,6 @@ export class AdminURLManager { return `/administration/policies/${rest}`; } - static policyBindings(rest: string): string { - return `/administration/policies/bindings/${rest}`; - } - static providers(rest: string): string { return `/administration/providers/${rest}`; } @@ -24,38 +20,10 @@ export class AdminURLManager { return `/administration/stages/${rest}`; } - static stagePrompts(rest: string): string { - return `/administration/stages_prompts/${rest}`; - } - - static stageInvitations(rest: string): string { - return `/administration/stages/invitations/${rest}`; - } - - static stageBindings(rest: string): string { - return `/administration/stages/bindings/${rest}`; - } - static sources(rest: string): string { return `/administration/sources/${rest}`; } - static tokens(rest: string): string { - return `/administration/tokens/${rest}`; - } - -} - -export class UserURLManager { - - static tokens(rest: string): string { - return `/-/user/tokens/${rest}`; - } - - static authenticatorWebauthn(rest: string): string { - return `/-/user/authenticator/webauthn/${rest}`; - } - } export class AppURLManager { diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index 9940b0e1f..f7b77ea29 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -9,6 +9,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css"; import AKGlobal from "../../authentik.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import { MessageLevel } from "../messages/Message"; import { IronFormElement } from "@polymer/iron-form/iron-form"; import { camelToSnake } from "../../utils"; @@ -31,8 +32,11 @@ export class Form extends LitElement { @property() send!: (data: T) => Promise; + @property({attribute: false}) + nonFieldErrors?: string[]; + static get styles(): CSSResult[] { - return [PFBase, PFCard, PFButton, PFForm, PFFormControl, AKGlobal, css` + return [PFBase, PFCard, PFButton, PFForm, PFAlert, PFFormControl, AKGlobal, css` select[multiple] { height: 15em; } @@ -84,6 +88,8 @@ export class Form extends LitElement { const values = form._serializeElementValues(element); if (element.tagName.toLowerCase() === "select" && "multiple" in element.attributes) { json[element.name] = values; + } else if (element.tagName.toLowerCase() === "input" && element.type === "date") { + json[element.name] = element.valueAsDate; } else { for (let v = 0; v < values.length; v++) { form._addSerializedElement(json, element.name, values[v]); @@ -114,6 +120,7 @@ export class Form extends LitElement { if (errorMessage instanceof Error) { throw errorMessage; } + // assign all input-related errors to their elements const elements: PaperInputElement[] = ironForm._getSubmittableElements(); elements.forEach((element) => { const elementName = element.name; @@ -123,6 +130,9 @@ export class Form extends LitElement { element.invalid = true; } }); + if ("non_field_errors" in errorMessage) { + this.nonFieldErrors = errorMessage["non_field_errors"]; + } throw new APIError(errorMessage); }); } @@ -134,6 +144,24 @@ export class Form extends LitElement { return html``; } + renderNonFieldErrors(): TemplateResult { + if (!this.nonFieldErrors) { + return html``; + } + return html`
+ ${this.nonFieldErrors.map(err => { + return html`
+
+ +
+

+ ${err} +

+
`; + })} +
`; + } + render(): TemplateResult { const rect = this.getBoundingClientRect(); if (rect.x + rect.y + rect.width + rect.height === 0) { @@ -141,6 +169,7 @@ export class Form extends LitElement { } return html` { this.submit(ev); }}> + ${this.renderNonFieldErrors()} ${this.renderForm()} `; } diff --git a/web/src/elements/sidebar/Sidebar.ts b/web/src/elements/sidebar/Sidebar.ts index 5a3624591..cec473142 100644 --- a/web/src/elements/sidebar/Sidebar.ts +++ b/web/src/elements/sidebar/Sidebar.ts @@ -100,6 +100,9 @@ export class Sidebar extends LitElement { PFNav, AKGlobal, css` + :host { + z-index: 100; + } .pf-c-nav__link.pf-m-current::after, .pf-c-nav__link.pf-m-current:hover::after, .pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after { diff --git a/web/src/pages/applications/ApplicationViewPage.ts b/web/src/pages/applications/ApplicationViewPage.ts index c17119f02..766cb2ebb 100644 --- a/web/src/pages/applications/ApplicationViewPage.ts +++ b/web/src/pages/applications/ApplicationViewPage.ts @@ -5,7 +5,7 @@ import "../../elements/Tabs"; import "../../elements/charts/ApplicationAuthorizeChart"; import "../../elements/buttons/ModalButton"; import "../../elements/buttons/SpinnerButton"; -import "../../elements/policies/BoundPoliciesList"; +import "../policies/BoundPoliciesList"; import "../../elements/EmptyState"; import "../../elements/events/ObjectChangelog"; import { Application, CoreApi } from "authentik-api"; diff --git a/web/src/pages/events/RuleListPage.ts b/web/src/pages/events/RuleListPage.ts index 15cb27f4b..3eee587d0 100644 --- a/web/src/pages/events/RuleListPage.ts +++ b/web/src/pages/events/RuleListPage.ts @@ -3,7 +3,7 @@ import { customElement, html, property, TemplateResult } from "lit-element"; import { AKResponse } from "../../api/Client"; import { TablePage } from "../../elements/table/TablePage"; -import "../../elements/policies/BoundPoliciesList"; +import "../policies/BoundPoliciesList"; import "../../elements/buttons/SpinnerButton"; import "../../elements/forms/ModalForm"; import { TableColumn } from "../../elements/table/Table"; diff --git a/web/src/pages/flows/BoundStagesList.ts b/web/src/pages/flows/BoundStagesList.ts index b37a6196a..2f7100a58 100644 --- a/web/src/pages/flows/BoundStagesList.ts +++ b/web/src/pages/flows/BoundStagesList.ts @@ -4,16 +4,19 @@ import { AKResponse } from "../../api/Client"; import { Table, TableColumn } from "../../elements/table/Table"; import "../../elements/forms/DeleteForm"; +import "../../elements/forms/ModalForm"; +import "./StageBindingForm"; import "../../elements/Tabs"; import "../../elements/buttons/ModalButton"; import "../../elements/buttons/SpinnerButton"; import "../../elements/buttons/Dropdown"; -import "../../elements/policies/BoundPoliciesList"; +import "../policies/BoundPoliciesList"; import { until } from "lit-html/directives/until"; import { PAGE_SIZE } from "../../constants"; import { FlowsApi, FlowStageBinding, StagesApi } from "authentik-api"; import { DEFAULT_CONFIG } from "../../api/Config"; import { AdminURLManager } from "../../api/legacy"; +import { ifDefined } from "lit-html/directives/if-defined"; @customElement("ak-bound-stages-list") export class BoundStagesList extends Table { @@ -52,12 +55,19 @@ export class BoundStagesList extends Table {
- - + + + ${gettext("Update")} + + + ${gettext("Update Stage binding")} + + + + + { ${gettext("No stages are currently bound to this flow.")}
- - - ${gettext("Bind Stage")} - -
-
+ + + ${gettext("Create")} + + + ${gettext("Create Stage binding")} + + + + +
`); } @@ -127,12 +144,19 @@ export class BoundStagesList extends Table { }), html``)} - - - ${gettext("Bind Stage")} - -
-
+ + + ${gettext("Create")} + + + ${gettext("Create Stage binding")} + + + + + ${super.renderToolbar()} `; } diff --git a/web/src/pages/flows/FlowViewPage.ts b/web/src/pages/flows/FlowViewPage.ts index d67f388f1..fa44c9d09 100644 --- a/web/src/pages/flows/FlowViewPage.ts +++ b/web/src/pages/flows/FlowViewPage.ts @@ -5,12 +5,13 @@ import "../../elements/Tabs"; import "../../elements/events/ObjectChangelog"; import "../../elements/buttons/ModalButton"; import "../../elements/buttons/SpinnerButton"; -import "../../elements/policies/BoundPoliciesList"; +import "../policies/BoundPoliciesList"; import "./BoundStagesList"; import "./FlowDiagram"; import { Flow, FlowsApi } from "authentik-api"; import { DEFAULT_CONFIG } from "../../api/Config"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFContent from "@patternfly/patternfly/components/Content/content.css"; @@ -33,7 +34,7 @@ export class FlowViewPage extends LitElement { flow!: Flow; static get styles(): CSSResult[] { - return [PFBase, PFPage, PFCard, PFContent, PFGallery, AKGlobal].concat( + return [PFBase, PFPage, PFButton, PFCard, PFContent, PFGallery, AKGlobal].concat( css` img.pf-icon { max-height: 24px; diff --git a/web/src/pages/flows/StageBindingForm.ts b/web/src/pages/flows/StageBindingForm.ts new file mode 100644 index 000000000..f834bf4da --- /dev/null +++ b/web/src/pages/flows/StageBindingForm.ts @@ -0,0 +1,145 @@ +import { FlowsApi, FlowStageBinding, FlowStageBindingPolicyEngineModeEnum, Stage, StagesApi } from "authentik-api"; +import { gettext } from "django"; +import { customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { DEFAULT_CONFIG } from "../../api/Config"; +import { Form } from "../../elements/forms/Form"; +import { until } from "lit-html/directives/until"; +import { ifDefined } from "lit-html/directives/if-defined"; +import "../../elements/forms/HorizontalFormElement"; +import { groupBy } from "../../utils"; + +@customElement("ak-stage-binding-form") +export class StageBindingForm extends Form { + + @property({attribute: false}) + fsb?: FlowStageBinding; + + @property() + targetPk?: string; + + getSuccessMessage(): string { + if (this.fsb) { + return gettext("Successfully updated binding."); + } else { + return gettext("Successfully created binding."); + } + } + + send = (data: FlowStageBinding): Promise => { + if (this.fsb) { + return new FlowsApi(DEFAULT_CONFIG).flowsBindingsUpdate({ + fsbUuid: this.fsb.pk || "", + data: data + }); + } else { + return new FlowsApi(DEFAULT_CONFIG).flowsBindingsCreate({ + data: data + }); + } + }; + + groupStages(stages: Stage[]): TemplateResult { + return html` + ${groupBy(stages, (s => s.verboseName || "")).map(([group, stages]) => { + return html` + ${stages.map(stage => { + const selected = (this.fsb?.stage === stage.pk); + return html``; + })} + `; + })} + `; + } + + getOrder(): Promise { + if (this.fsb) { + return Promise.resolve(this.fsb.order); + } + return new FlowsApi(DEFAULT_CONFIG).flowsBindingsList({ + target: this.targetPk || "", + }).then(bindings => { + const orders = bindings.results.map(binding => binding.order); + return Math.max(...orders) + 1; + }); + } + + renderTarget(): TemplateResult { + if (this.fsb?.target || this.targetPk) { + return html` + + `; + } + return html` + + `; + } + + renderForm(): TemplateResult { + return html`
+ ${this.renderTarget()} + + + + +
+ + +
+

${gettext("Evaluate policies during the Flow planning process. Disable this for input-based policies.")}

+
+ +
+ + +
+

${gettext("Evaluate policies when the Stage is present to the user.")}

+
+ + + + + + +
`; + } + +} diff --git a/web/src/elements/policies/BoundPoliciesList.ts b/web/src/pages/policies/BoundPoliciesList.ts similarity index 66% rename from web/src/elements/policies/BoundPoliciesList.ts rename to web/src/pages/policies/BoundPoliciesList.ts index 0b35f2953..e8b461a9f 100644 --- a/web/src/elements/policies/BoundPoliciesList.ts +++ b/web/src/pages/policies/BoundPoliciesList.ts @@ -15,7 +15,10 @@ import { DEFAULT_CONFIG } from "../../api/Config"; import { AdminURLManager } from "../../api/legacy"; import "../../elements/forms/ModalForm"; -import "../../pages/groups/GroupForm"; +import "../groups/GroupForm"; +import "../users/UserForm"; +import "./PolicyBindingForm"; +import { ifDefined } from "lit-html/directives/if-defined"; @customElement("ak-bound-policies-list") export class BoundPoliciesList extends Table { @@ -43,11 +46,11 @@ export class BoundPoliciesList extends Table { getPolicyUserGroupRow(item: PolicyBinding): string { if (item.policy) { - return gettext(`Policy ${item.policy.name}`); + return gettext(`Policy ${item.policyObj?.name}`); } else if (item.group) { - return gettext(`Group ${item.group.name}`); + return gettext(`Group ${item.groupObj?.name}`); } else if (item.user) { - return gettext(`User ${item.user.name}`); + return gettext(`User ${item.userObj?.name}`); } else { return gettext(""); } @@ -55,7 +58,7 @@ export class BoundPoliciesList extends Table { getObjectEditButton(item: PolicyBinding): TemplateResult { if (item.policy) { - return html` + return html` ${gettext("Edit Policy")} @@ -69,19 +72,26 @@ export class BoundPoliciesList extends Table { ${gettext("Update Group")} - + `; } else if (item.user) { - return html` - - ${gettext("Edit User")} - -
-
`; + return html` + + ${gettext("Update")} + + + ${gettext("Update User")} + + + + + `; } else { return html``; } @@ -95,12 +105,19 @@ export class BoundPoliciesList extends Table { html`${item.timeout}`, html` ${this.getObjectEditButton(item)} - - + + + ${gettext("Update")} + + + ${gettext("Update Binding")} + + + + + { ${gettext("No policies are currently bound to this object.")}
- - - ${gettext("Bind Policy")} - -
-
+ + + ${gettext("Create")} + + + ${gettext("Create Binding")} + + + + +
`); } @@ -154,12 +178,19 @@ export class BoundPoliciesList extends Table { }), html``)} - - - ${gettext("Bind Policy")} - -
-
+ + + ${gettext("Create")} + + + ${gettext("Create Binding")} + + + + + ${super.renderToolbar()} `; } diff --git a/web/src/pages/policies/PolicyBindingForm.ts b/web/src/pages/policies/PolicyBindingForm.ts new file mode 100644 index 000000000..eced5877c --- /dev/null +++ b/web/src/pages/policies/PolicyBindingForm.ts @@ -0,0 +1,138 @@ +import { CoreApi, PoliciesApi, Policy, PolicyBinding } from "authentik-api"; +import { gettext } from "django"; +import { customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { DEFAULT_CONFIG } from "../../api/Config"; +import { Form } from "../../elements/forms/Form"; +import { until } from "lit-html/directives/until"; +import { ifDefined } from "lit-html/directives/if-defined"; +import { groupBy } from "../../utils"; +import "../../elements/forms/HorizontalFormElement"; + +@customElement("ak-policy-binding-form") +export class PolicyBindingForm extends Form { + + @property({attribute: false}) + binding?: PolicyBinding; + + @property() + targetPk?: string; + + getSuccessMessage(): string { + if (this.binding) { + return gettext("Successfully updated binding."); + } else { + return gettext("Successfully created binding."); + } + } + + async customValidate(form: PolicyBinding): Promise { + return form; + } + + send = (data: PolicyBinding): Promise => { + if (this.binding) { + return new PoliciesApi(DEFAULT_CONFIG).policiesBindingsUpdate({ + policyBindingUuid: this.binding.pk || "", + data: data + }); + } else { + return new PoliciesApi(DEFAULT_CONFIG).policiesBindingsCreate({ + data: data + }); + } + }; + + groupPolicies(policies: Policy[]): TemplateResult { + return html` + ${groupBy(policies, (p => p.verboseName || "")).map(([group, policies]) => { + return html` + ${policies.map(p => { + const selected = (this.binding?.policy === p.pk); + return html``; + })} + `; + })} + `; + } + + getOrder(): Promise { + if (this.binding) { + return Promise.resolve(this.binding.order); + } + return new PoliciesApi(DEFAULT_CONFIG).policiesBindingsList({ + target: this.targetPk || "", + }).then(bindings => { + const orders = bindings.results.map(binding => binding.order); + return Math.max(...orders) + 1; + }); + } + + + renderForm(): TemplateResult { + return html`
+ + + + + + + + + + + +
+ + +
+
+ + + + + + +
`; + } + +} diff --git a/web/src/pages/property-mappings/PropertyMappingListPage.ts b/web/src/pages/property-mappings/PropertyMappingListPage.ts index 5c3e8738e..2ebc9f37d 100644 --- a/web/src/pages/property-mappings/PropertyMappingListPage.ts +++ b/web/src/pages/property-mappings/PropertyMappingListPage.ts @@ -43,7 +43,7 @@ export class PropertyMappingListPage extends TablePage { page: page, pageSize: PAGE_SIZE, search: this.search || "", - managedIsnull: this.hideManaged.toString(), + managedIsnull: this.hideManaged ? "true" : undefined, }); } @@ -126,6 +126,7 @@ export class PropertyMappingListPage extends TablePage {
{ this.hideManaged = !this.hideManaged; + this.page = 1; this.fetch(); }} /> diff --git a/web/src/pages/stages/InvitationForm.ts b/web/src/pages/stages/InvitationForm.ts new file mode 100644 index 000000000..aaf24e9a4 --- /dev/null +++ b/web/src/pages/stages/InvitationForm.ts @@ -0,0 +1,56 @@ +import { Invitation, StagesApi } from "authentik-api"; +import { gettext } from "django"; +import { customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { DEFAULT_CONFIG } from "../../api/Config"; +import { Form } from "../../elements/forms/Form"; +import "../../elements/forms/HorizontalFormElement"; +import "../../elements/CodeMirror"; +import YAML from "yaml"; + +@customElement("ak-stage-invitation-form") +export class InvitationForm extends Form { + + @property({attribute: false}) + invitation?: Invitation; + + getSuccessMessage(): string { + if (this.invitation) { + return gettext("Successfully updated invitation."); + } else { + return gettext("Successfully created invitation."); + } + } + + send = (data: Invitation): Promise => { + if (this.invitation) { + return new StagesApi(DEFAULT_CONFIG).stagesInvitationInvitationsUpdate({ + inviteUuid: this.invitation.pk || "", + data: data + }); + } else { + return new StagesApi(DEFAULT_CONFIG).stagesInvitationInvitationsCreate({ + data: data + }); + } + }; + + renderForm(): TemplateResult { + return html`
+ + + + + + +

${gettext("Optional data which is loaded into the flow's 'prompt_data' context variable.")}

+
+
`; + } + +} diff --git a/web/src/pages/stages/InvitationListPage.ts b/web/src/pages/stages/InvitationListPage.ts index f762c2ae5..98bba779b 100644 --- a/web/src/pages/stages/InvitationListPage.ts +++ b/web/src/pages/stages/InvitationListPage.ts @@ -6,11 +6,12 @@ import { TablePage } from "../../elements/table/TablePage"; import "../../elements/buttons/ModalButton"; import "../../elements/buttons/SpinnerButton"; import "../../elements/forms/DeleteForm"; +import "../../elements/forms/ModalForm"; +import "./InvitationForm"; import { TableColumn } from "../../elements/table/Table"; import { PAGE_SIZE } from "../../constants"; import { Invitation, StagesApi } from "authentik-api"; import { DEFAULT_CONFIG } from "../../api/Config"; -import { AdminURLManager } from "../../api/legacy"; @customElement("ak-stage-invitation-list") export class InvitationListPage extends TablePage { @@ -71,12 +72,19 @@ export class InvitationListPage extends TablePage { renderToolbar(): TemplateResult { return html` - - + + ${gettext("Create")} - -
-
+ + + ${gettext("Create Invitation")} + + + + + ${super.renderToolbar()} `; } diff --git a/web/src/pages/stages/PromptForm.ts b/web/src/pages/stages/PromptForm.ts new file mode 100644 index 000000000..6caa3b9cf --- /dev/null +++ b/web/src/pages/stages/PromptForm.ts @@ -0,0 +1,122 @@ +import { Prompt, PromptTypeEnum, StagesApi } from "authentik-api"; +import { gettext } from "django"; +import { customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { DEFAULT_CONFIG } from "../../api/Config"; +import { Form } from "../../elements/forms/Form"; +import { ifDefined } from "lit-html/directives/if-defined"; +import "../../elements/forms/HorizontalFormElement"; + +@customElement("ak-stage-prompt-form") +export class PromptForm extends Form { + + @property({attribute: false}) + prompt?: Prompt; + + getSuccessMessage(): string { + if (this.prompt) { + return gettext("Successfully updated prompt."); + } else { + return gettext("Successfully created prompt."); + } + } + + send = (data: Prompt): Promise => { + if (this.prompt) { + return new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsUpdate({ + promptUuid: this.prompt.pk || "", + data: data + }); + } else { + return new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsCreate({ + data: data + }); + } + }; + + renderTypes(): TemplateResult { + return html` + + + + + + + + + + + + `; + } + + renderForm(): TemplateResult { + return html`
+ + +

${gettext("Name of the form field, also used to store the value.")}

+
+ + +

${gettext("Label shown next to/above the prompt.")}

+
+ + + + +
+ + +
+
+ + +

${gettext("Optionally pre-fill the input value")}

+
+ + + +
`; + } + +} diff --git a/web/src/pages/stages/PromptListPage.ts b/web/src/pages/stages/PromptListPage.ts index 106f491fc..525719fd3 100644 --- a/web/src/pages/stages/PromptListPage.ts +++ b/web/src/pages/stages/PromptListPage.ts @@ -6,11 +6,12 @@ import { TablePage } from "../../elements/table/TablePage"; import "../../elements/buttons/ModalButton"; import "../../elements/buttons/SpinnerButton"; import "../../elements/forms/DeleteForm"; +import "../../elements/forms/ModalForm"; +import "./PromptForm"; import { TableColumn } from "../../elements/table/Table"; import { PAGE_SIZE } from "../../constants"; import { Prompt, StagesApi } from "authentik-api"; import { DEFAULT_CONFIG } from "../../api/Config"; -import { AdminURLManager } from "../../api/legacy"; @customElement("ak-stage-prompt-list") export class PromptListPage extends TablePage { @@ -60,12 +61,19 @@ export class PromptListPage extends TablePage { return html`
  • ${stage.name}
  • `; })}`, html` - - + + + ${gettext("Update")} + + + ${gettext("Update Prompt")} + + + + + { renderToolbar(): TemplateResult { return html` - - + + ${gettext("Create")} - -
    -
    + + + ${gettext("Create Prompt")} + + + + + ${super.renderToolbar()} `; } diff --git a/web/src/pages/tokens/TokenListPage.ts b/web/src/pages/tokens/TokenListPage.ts index aa91449a7..4d00a2a0a 100644 --- a/web/src/pages/tokens/TokenListPage.ts +++ b/web/src/pages/tokens/TokenListPage.ts @@ -52,7 +52,7 @@ export class TokenListPage extends TablePage { row(item: Token): TemplateResult[] { return [ html`${item.identifier}`, - html`${item.user.username}`, + html`${item.user?.username}`, html`${item.expiring ? "Yes" : "No"}`, html`${item.expiring ? item.expires?.toLocaleString() : "-"}`, html` diff --git a/web/src/pages/users/UserDetailsPage.ts b/web/src/pages/user-settings/UserDetailsPage.ts similarity index 100% rename from web/src/pages/users/UserDetailsPage.ts rename to web/src/pages/user-settings/UserDetailsPage.ts diff --git a/web/src/pages/users/UserSettingsPage.ts b/web/src/pages/user-settings/UserSettingsPage.ts similarity index 99% rename from web/src/pages/users/UserSettingsPage.ts rename to web/src/pages/user-settings/UserSettingsPage.ts index d2fb255b0..426bd4897 100644 --- a/web/src/pages/users/UserSettingsPage.ts +++ b/web/src/pages/user-settings/UserSettingsPage.ts @@ -18,8 +18,8 @@ import { DEFAULT_CONFIG } from "../../api/Config"; import { until } from "lit-html/directives/until"; import { ifDefined } from "lit-html/directives/if-defined"; import "../../elements/Tabs"; +import "./tokens/UserTokenList"; import "./UserDetailsPage"; -import "./UserTokenList"; import "./settings/UserSettingsAuthenticatorTOTP"; import "./settings/UserSettingsAuthenticatorStatic"; import "./settings/UserSettingsAuthenticatorWebAuthn"; diff --git a/web/src/pages/users/settings/BaseUserSettings.ts b/web/src/pages/user-settings/settings/BaseUserSettings.ts similarity index 100% rename from web/src/pages/users/settings/BaseUserSettings.ts rename to web/src/pages/user-settings/settings/BaseUserSettings.ts diff --git a/web/src/pages/users/settings/SourceSettingsOAuth.ts b/web/src/pages/user-settings/settings/SourceSettingsOAuth.ts similarity index 100% rename from web/src/pages/users/settings/SourceSettingsOAuth.ts rename to web/src/pages/user-settings/settings/SourceSettingsOAuth.ts diff --git a/web/src/pages/users/settings/UserSettingsAuthenticatorStatic.ts b/web/src/pages/user-settings/settings/UserSettingsAuthenticatorStatic.ts similarity index 100% rename from web/src/pages/users/settings/UserSettingsAuthenticatorStatic.ts rename to web/src/pages/user-settings/settings/UserSettingsAuthenticatorStatic.ts diff --git a/web/src/pages/users/settings/UserSettingsAuthenticatorTOTP.ts b/web/src/pages/user-settings/settings/UserSettingsAuthenticatorTOTP.ts similarity index 100% rename from web/src/pages/users/settings/UserSettingsAuthenticatorTOTP.ts rename to web/src/pages/user-settings/settings/UserSettingsAuthenticatorTOTP.ts diff --git a/web/src/pages/users/settings/UserSettingsAuthenticatorWebAuthn.ts b/web/src/pages/user-settings/settings/UserSettingsAuthenticatorWebAuthn.ts similarity index 100% rename from web/src/pages/users/settings/UserSettingsAuthenticatorWebAuthn.ts rename to web/src/pages/user-settings/settings/UserSettingsAuthenticatorWebAuthn.ts diff --git a/web/src/pages/users/settings/UserSettingsPassword.ts b/web/src/pages/user-settings/settings/UserSettingsPassword.ts similarity index 100% rename from web/src/pages/users/settings/UserSettingsPassword.ts rename to web/src/pages/user-settings/settings/UserSettingsPassword.ts diff --git a/web/src/pages/user-settings/tokens/UserTokenForm.ts b/web/src/pages/user-settings/tokens/UserTokenForm.ts new file mode 100644 index 000000000..1de5b64dd --- /dev/null +++ b/web/src/pages/user-settings/tokens/UserTokenForm.ts @@ -0,0 +1,54 @@ +import { CoreApi, Token } from "authentik-api"; +import { gettext } from "django"; +import { customElement, property } from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import { Form } from "../../../elements/forms/Form"; +import { ifDefined } from "lit-html/directives/if-defined"; +import "../../../elements/forms/HorizontalFormElement"; + +@customElement("ak-user-token-form") +export class UserTokenForm extends Form { + + @property({attribute: false}) + token?: Token; + + getSuccessMessage(): string { + if (this.token) { + return gettext("Successfully updated token."); + } else { + return gettext("Successfully created token."); + } + } + + send = (data: Token): Promise => { + if (this.token) { + return new CoreApi(DEFAULT_CONFIG).coreTokensUpdate({ + identifier: this.token.identifier, + data: data + }); + } else { + return new CoreApi(DEFAULT_CONFIG).coreTokensCreate({ + data: data + }); + } + }; + + renderForm(): TemplateResult { + return html`
    + + + + + + +
    `; + } + +} diff --git a/web/src/pages/users/UserTokenList.ts b/web/src/pages/user-settings/tokens/UserTokenList.ts similarity index 67% rename from web/src/pages/users/UserTokenList.ts rename to web/src/pages/user-settings/tokens/UserTokenList.ts index 92c61c9f1..4f4202aa6 100644 --- a/web/src/pages/users/UserTokenList.ts +++ b/web/src/pages/user-settings/tokens/UserTokenList.ts @@ -1,16 +1,18 @@ import { gettext } from "django"; -import { customElement, html, property, TemplateResult } from "lit-element"; -import { AKResponse } from "../../api/Client"; +import { CSSResult, customElement, html, property, TemplateResult } from "lit-element"; +import { AKResponse } from "../../../api/Client"; +import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; -import "../../elements/forms/DeleteForm"; -import "../../elements/buttons/ModalButton"; -import "../../elements/buttons/Dropdown"; -import "../../elements/buttons/TokenCopyButton"; -import { Table, TableColumn } from "../../elements/table/Table"; -import { PAGE_SIZE } from "../../constants"; +import "../../../elements/forms/DeleteForm"; +import "../../../elements/forms/ModalForm"; +import "../../../elements/buttons/ModalButton"; +import "../../../elements/buttons/Dropdown"; +import "../../../elements/buttons/TokenCopyButton"; +import { Table, TableColumn } from "../../../elements/table/Table"; +import { PAGE_SIZE } from "../../../constants"; import { CoreApi, Token } from "authentik-api"; -import { DEFAULT_CONFIG } from "../../api/Config"; -import { AdminURLManager } from "../../api/legacy"; +import { DEFAULT_CONFIG } from "../../../api/Config"; +import "./UserTokenForm"; @customElement("ak-user-token-list") export class UserTokenList extends Table { @@ -39,14 +41,25 @@ export class UserTokenList extends Table { ]; } + static get styles(): CSSResult[] { + return super.styles.concat(PFDescriptionList); + } + renderToolbar(): TemplateResult { return html` - - + + ${gettext("Create")} - -
    -
    + + + ${gettext("Create Token")} + + + + + ${super.renderToolbar()} `; } @@ -61,7 +74,7 @@ export class UserTokenList extends Table { ${gettext("User")}
    -
    ${item.user.username}
    +
    ${item.user?.username}
    @@ -90,12 +103,19 @@ export class UserTokenList extends Table { return [ html`${item.identifier}`, html` - - + + + ${gettext("Update")} + + + ${gettext("Update Token")} + + + + + (objects: T[], callback: (obj: T) => string): Array<[string, T[]]> { + const m = new Map(); + 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); +}