Merge pull request #676 from BeryJu/new-forms-part-2

New forms part 2
This commit is contained in:
Jens L 2021-03-31 21:19:42 +02:00 committed by GitHub
commit f2d5d62c9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 915 additions and 889 deletions

View File

@ -1,5 +0,0 @@
{% load static %}
{% load i18n %}
{% block content %}
{% endblock %}

View File

@ -1,5 +1,3 @@
{% extends container_template|default:"administration/base.html" %}
{% load i18n %}
{% load authentik_utils %}
{% load static %}

View File

@ -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,
)
)

View File

@ -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,
)
)

View File

@ -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/<uuid:pk>/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/<uuid:pk>/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/<uuid:pk>/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/",

View File

@ -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")

View File

@ -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")

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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(),
}

View File

@ -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)

View File

@ -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/<slug:identifier>/update/",
user.TokenUpdateView.as_view(),
name="user-tokens-update",
),
# Impersonation
path(
"-/impersonation/<int:user_id>/",

View File

@ -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()
)

View File

@ -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(),
}

View File

@ -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

View File

@ -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):

View File

@ -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."]},
)

View File

@ -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)

View File

@ -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}

View File

@ -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)

View File

@ -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(),
}

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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<T> extends LitElement {
@property()
send!: (data: T) => Promise<unknown>;
@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<T> 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<T> 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<T> 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<T> extends LitElement {
return html`<slot></slot>`;
}
renderNonFieldErrors(): TemplateResult {
if (!this.nonFieldErrors) {
return html``;
}
return html`<div class="pf-c-form__alert">
${this.nonFieldErrors.map(err => {
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
</div>
<h4 class="pf-c-alert__title">
${err}
</h4>
</div>`;
})}
</div>`;
}
render(): TemplateResult {
const rect = this.getBoundingClientRect();
if (rect.x + rect.y + rect.width + rect.height === 0) {
@ -141,6 +169,7 @@ export class Form<T> extends LitElement {
}
return html`<iron-form
@iron-form-presubmit=${(ev: Event) => { this.submit(ev); }}>
${this.renderNonFieldErrors()}
${this.renderForm()}
</iron-form>`;
}

View File

@ -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 {

View File

@ -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";

View File

@ -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";

View File

@ -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<FlowStageBinding> {
@ -52,12 +55,19 @@ export class BoundStagesList extends Table<FlowStageBinding> {
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-modal-button href="${AdminURLManager.stageBindings(`${item.pk}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
<ak-forms-modal>
<span slot="submit">
${gettext("Update")}
</span>
<span slot="header">
${gettext("Update Stage binding")}
</span>
<ak-stage-binding-form slot="form" .fsb=${item}>
</ak-stage-binding-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${gettext("Edit Binding")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</button>
</ak-forms-modal>
<ak-forms-delete
.obj=${item}
objectLabel=${gettext("Stage binding")}
@ -95,12 +105,19 @@ export class BoundStagesList extends Table<FlowStageBinding> {
${gettext("No stages are currently bound to this flow.")}
</div>
<div slot="primary">
<ak-modal-button href="${AdminURLManager.stageBindings(`create/?target=${this.target}`)}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
${gettext("Bind Stage")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-forms-modal>
<span slot="submit">
${gettext("Create")}
</span>
<span slot="header">
${gettext("Create Stage binding")}
</span>
<ak-stage-binding-form slot="form" targetPk=${ifDefined(this.target)}>
</ak-stage-binding-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${gettext("Bind stage")}
</button>
</ak-forms-modal>
</div>
</ak-empty-state>`);
}
@ -127,12 +144,19 @@ export class BoundStagesList extends Table<FlowStageBinding> {
}), html`<ak-spinner></ak-spinner>`)}
</ul>
</ak-dropdown>
<ak-modal-button href="${AdminURLManager.stageBindings(`create/?target=${this.target}`)}">
<ak-spinner-button slot="trigger" class="pf-m-primary">
${gettext("Bind Stage")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-forms-modal>
<span slot="submit">
${gettext("Create")}
</span>
<span slot="header">
${gettext("Create Stage binding")}
</span>
<ak-stage-binding-form slot="form" targetPk=${ifDefined(this.target)}>
</ak-stage-binding-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${gettext("Bind stage")}
</button>
</ak-forms-modal>
${super.renderToolbar()}
`;
}

View File

@ -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;

View File

@ -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<FlowStageBinding> {
@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<FlowStageBinding> => {
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<Stage>(stages, (s => s.verboseName || "")).map(([group, stages]) => {
return html`<optgroup label=${group}>
${stages.map(stage => {
const selected = (this.fsb?.stage === stage.pk);
return html`<option ?selected=${selected} value=${ifDefined(stage.pk)}>${stage.name}</option>`;
})}
</optgroup>`;
})}
`;
}
getOrder(): Promise<number> {
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`
<input required name="target" type="hidden" value=${ifDefined(this.fsb?.target || this.targetPk)}>
`;
}
return html`<ak-form-element-horizontal
label=${gettext("Target")}
?required=${true}
name="target">
<select class="pf-c-form-control">
${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
ordering: "pk"
}).then(flows => {
return flows.results.map(flow => {
// No ?selected check here, as this input isnt shown on update forms
return html`<option value=${ifDefined(flow.pk)}>${flow.name}</option>`;
});
}), html``)}
</select>
</ak-form-element-horizontal>`;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
${this.renderTarget()}
<ak-form-element-horizontal
label=${gettext("Stage")}
?required=${true}
name="stage">
<select class="pf-c-form-control">
${until(new StagesApi(DEFAULT_CONFIG).stagesAllList({
ordering: "pk"
}).then(stages => {
return this.groupStages(stages.results);
}), html``)}
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="evaluateOnPlan">
<div class="pf-c-check">
<input type="checkbox" class="pf-c-check__input" ?checked=${this.fsb?.evaluateOnPlan || true}>
<label class="pf-c-check__label">
${gettext("Evaluate on plan")}
</label>
</div>
<p class="pf-c-form__helper-text">${gettext("Evaluate policies during the Flow planning process. Disable this for input-based policies.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="reEvaluatePolicies">
<div class="pf-c-check">
<input type="checkbox" class="pf-c-check__input" ?checked=${this.fsb?.reEvaluatePolicies || false}>
<label class="pf-c-check__label">
${gettext("Re-evaluate policies")}
</label>
</div>
<p class="pf-c-form__helper-text">${gettext("Evaluate policies when the Stage is present to the user.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Order")}
?required=${true}
name="order">
<input type="number" value="${until(this.getOrder(), this.fsb?.order)}" class="pf-c-form-control" required>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Policy engine mode")}
?required=${true}
name="policyEngineMode">
<select class="pf-c-form-control">
<option value=${FlowStageBindingPolicyEngineModeEnum.Any} ?selected=${this.fsb?.policyEngineMode === FlowStageBindingPolicyEngineModeEnum.Any}>
${gettext("ANY, any policy must match to grant access.")}
</option>
<option value=${FlowStageBindingPolicyEngineModeEnum.All} ?selected=${this.fsb?.policyEngineMode === FlowStageBindingPolicyEngineModeEnum.All}>
${gettext("ALL, all policies must match to grant access.")}
</option>
</select>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -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<PolicyBinding> {
@ -43,11 +46,11 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
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<PolicyBinding> {
getObjectEditButton(item: PolicyBinding): TemplateResult {
if (item.policy) {
return html`<ak-modal-button href="${AdminURLManager.policies(`${item.policy?.policyUuid}/update/`)}">
return html`<ak-modal-button href="${AdminURLManager.policies(`${item.policy}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
${gettext("Edit Policy")}
</ak-spinner-button>
@ -69,19 +72,26 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
<span slot="header">
${gettext("Update Group")}
</span>
<ak-group-form slot="form" .group=${item.group}>
<ak-group-form slot="form" .group=${item.groupObj}>
</ak-group-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${gettext("Edit Group")}
</button>
</ak-forms-modal>`;
} else if (item.user) {
return html`<ak-modal-button href="${AdminURLManager.policies(`${item.user?.id}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
${gettext("Edit User")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>`;
return html`<ak-forms-modal>
<span slot="submit">
${gettext("Update")}
</span>
<span slot="header">
${gettext("Update User")}
</span>
<ak-user-form slot="form" .user=${item.userObj}>
</ak-user-form>
<button slot="trigger" class="pf-m-secondary pf-c-button">
${gettext("Edit")}
</button>
</ak-forms-modal>`;
} else {
return html``;
}
@ -95,12 +105,19 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
html`${item.timeout}`,
html`
${this.getObjectEditButton(item)}
<ak-modal-button href="${AdminURLManager.policyBindings(`${item.pk}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
<ak-forms-modal>
<span slot="submit">
${gettext("Update")}
</span>
<span slot="header">
${gettext("Update Binding")}
</span>
<ak-policy-binding-form slot="form" .binding=${item} targetPk=${ifDefined(this.target)}>
</ak-policy-binding-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${gettext("Edit Binding")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</button>
</ak-forms-modal>
<ak-forms-delete
.obj=${item}
objectLabel=${gettext("Policy binding")}
@ -122,12 +139,19 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
${gettext("No policies are currently bound to this object.")}
</div>
<div slot="primary">
<ak-modal-button href=${AdminURLManager.policyBindings(`create/?target=${this.target}`)}>
<ak-spinner-button slot="trigger" class="pf-m-primary">
${gettext("Bind Policy")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-forms-modal>
<span slot="submit">
${gettext("Create")}
</span>
<span slot="header">
${gettext("Create Binding")}
</span>
<ak-policy-binding-form slot="form" targetPk=${ifDefined(this.target)}>
</ak-policy-binding-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${gettext("Create Binding")}
</button>
</ak-forms-modal>
</div>
</ak-empty-state>`);
}
@ -154,12 +178,19 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
}), html`<ak-spinner></ak-spinner>`)}
</ul>
</ak-dropdown>
<ak-modal-button href=${AdminURLManager.policyBindings(`create/?target=${this.target}`)}>
<ak-spinner-button slot="trigger" class="pf-m-primary">
${gettext("Bind Policy")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
<ak-forms-modal>
<span slot="submit">
${gettext("Create")}
</span>
<span slot="header">
${gettext("Create Binding")}
</span>
<ak-policy-binding-form slot="form" targetPk=${ifDefined(this.target)}>
</ak-policy-binding-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${gettext("Create Binding")}
</button>
</ak-forms-modal>
${super.renderToolbar()}
`;
}

View File

@ -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<PolicyBinding> {
@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<PolicyBinding> {
return form;
}
send = (data: PolicyBinding): Promise<PolicyBinding> => {
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<Policy>(policies, (p => p.verboseName || "")).map(([group, policies]) => {
return html`<optgroup label=${group}>
${policies.map(p => {
const selected = (this.binding?.policy === p.pk);
return html`<option ?selected=${selected} value=${ifDefined(p.pk)}>${p.name}</option>`;
})}
</optgroup>`;
})}
`;
}
getOrder(): Promise<number> {
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`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${gettext("Policy")}
name="policy">
<select class="pf-c-form-control">
<option value="" ?selected=${this.binding?.policy === undefined}>---------</option>
${until(new PoliciesApi(DEFAULT_CONFIG).policiesAllList({
ordering: "pk"
}).then(policies => {
return this.groupPolicies(policies.results);
}), html``)}
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Group")}
name="group">
<select class="pf-c-form-control">
<option value="" ?selected=${this.binding?.group === undefined}>---------</option>
${until(new CoreApi(DEFAULT_CONFIG).coreGroupsList({
ordering: "pk"
}).then(groups => {
return groups.results.map(group => {
return html`<option value=${ifDefined(group.pk)} ?selected=${group.pk === this.binding?.group}>${group.name}</option>`;
});
}), html``)}
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("User")}
name="user">
<select class="pf-c-form-control">
<option value="" ?selected=${this.binding?.user === undefined}>---------</option>
${until(new CoreApi(DEFAULT_CONFIG).coreUsersList({
ordering: "pk"
}).then(users => {
return users.results.map(user => {
return html`<option value=${ifDefined(user.pk)} ?selected=${user.pk === this.binding?.user}>${user.name}</option>`;
});
}), html``)}
</select>
</ak-form-element-horizontal>
<input required name="target" type="hidden" value=${ifDefined(this.binding?.target || this.targetPk)}>
<ak-form-element-horizontal name="enabled">
<div class="pf-c-check">
<input type="checkbox" class="pf-c-check__input" ?checked=${this.binding?.enabled || true}>
<label class="pf-c-check__label">
${gettext("Enabled")}
</label>
</div>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Order")}
?required=${true}
name="order">
<input type="number" value="${until(this.getOrder(), this.binding?.order)}" class="pf-c-form-control" required>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Timeout")}
?required=${true}
name="timeout">
<input type="number" value="${this.binding?.timeout || 30}" class="pf-c-form-control" required>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -43,7 +43,7 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
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<PropertyMapping> {
<div class="pf-c-check">
<input class="pf-c-check__input" type="checkbox" id="hide-managed" name="hide-managed" ?checked=${this.hideManaged} @change=${() => {
this.hideManaged = !this.hideManaged;
this.page = 1;
this.fetch();
}} />
<label class="pf-c-check__label" for="hide-managed">${gettext("Hide managed mappings")}</label>

View File

@ -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<Invitation> {
@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<Invitation> => {
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`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${gettext("Expires")}
?required=${true}
name="expires">
<input type="date" class="pf-c-form-control" required>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Attributes")}
name="fixedData">
<ak-codemirror mode="yaml" value="${YAML.stringify(this.invitation?.fixedData)}">
</ak-codemirror>
<p class="pf-c-form__helper-text">${gettext("Optional data which is loaded into the flow's 'prompt_data' context variable.")}</p>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -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<Invitation> {
@ -71,12 +72,19 @@ export class InvitationListPage extends TablePage<Invitation> {
renderToolbar(): TemplateResult {
return html`
<ak-modal-button href=${AdminURLManager.stageInvitations("create/")}>
<ak-spinner-button slot="trigger" class="pf-m-primary">
<ak-forms-modal>
<span slot="submit">
${gettext("Create")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</span>
<span slot="header">
${gettext("Create Invitation")}
</span>
<ak-stage-invitation-form slot="form">
</ak-stage-invitation-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${gettext("Create")}
</button>
</ak-forms-modal>
${super.renderToolbar()}
`;
}

View File

@ -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<Prompt> {
@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<Prompt> => {
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`
<option value=${PromptTypeEnum.Text} ?selected=${this.prompt?.type === PromptTypeEnum.Text}>
${gettext("Text: Simple Text input")}
</option>
<option value=${PromptTypeEnum.Username} ?selected=${this.prompt?.type === PromptTypeEnum.Username}>
${gettext("Username: Same as Text input, but checks for and prevents duplicate usernames.")}
</option>
<option value=${PromptTypeEnum.Email} ?selected=${this.prompt?.type === PromptTypeEnum.Email}>
${gettext("Email: Text field with Email type.")}
</option>
<option value=${PromptTypeEnum.Password} ?selected=${this.prompt?.type === PromptTypeEnum.Password}>
${gettext("Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.")}
</option>
<option value=${PromptTypeEnum.Number} ?selected=${this.prompt?.type === PromptTypeEnum.Number}>
${gettext("Number")}
</option>
<option value=${PromptTypeEnum.Checkbox} ?selected=${this.prompt?.type === PromptTypeEnum.Checkbox}>
${gettext("Checkbox")}
</option>
<option value=${PromptTypeEnum.Date} ?selected=${this.prompt?.type === PromptTypeEnum.Date}>
${gettext("Date")}
</option>
<option value=${PromptTypeEnum.DateTime} ?selected=${this.prompt?.type === PromptTypeEnum.DateTime}>
${gettext("Date Time")}
</option>
<option value=${PromptTypeEnum.Separator} ?selected=${this.prompt?.type === PromptTypeEnum.Separator}>
${gettext("Separator: Static Separator Line")}
</option>
<option value=${PromptTypeEnum.Hidden} ?selected=${this.prompt?.type === PromptTypeEnum.Hidden}>
${gettext("Hidden: Hidden field, can be used to insert data into form.")}
</option>
<option value=${PromptTypeEnum.Static} ?selected=${this.prompt?.type === PromptTypeEnum.Static}>
${gettext("Static: Static value, displayed as-is.")}
</option>
`;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${gettext("Field Key")}
?required=${true}
name="fieldKey">
<input type="text" value="${ifDefined(this.prompt?.fieldKey)}" class="pf-c-form-control" required>
<p class="pf-c-form__helper-text">${gettext("Name of the form field, also used to store the value.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Label")}
?required=${true}
name="label">
<input type="text" value="${ifDefined(this.prompt?.label)}" class="pf-c-form-control" required>
<p class="pf-c-form__helper-text">${gettext("Label shown next to/above the prompt.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Type")}
?required=${true}
name="type">
<select class="pf-c-form-control">
${this.renderTypes()}
</select>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="required">
<div class="pf-c-check">
<input type="checkbox" class="pf-c-check__input" ?checked=${this.prompt?.required || false}>
<label class="pf-c-check__label">
${gettext("Required")}
</label>
</div>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Placeholder")}
name="placeholder">
<input type="text" value="${ifDefined(this.prompt?.placeholder)}" class="pf-c-form-control" required>
<p class="pf-c-form__helper-text">${gettext("Optionally pre-fill the input value")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Order")}
?required=${true}
name="order">
<input type="number" value="${ifDefined(this.prompt?.order)}" class="pf-c-form-control" required>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -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<Prompt> {
@ -60,12 +61,19 @@ export class PromptListPage extends TablePage<Prompt> {
return html`<li>${stage.name}</li>`;
})}`,
html`
<ak-modal-button href="${AdminURLManager.stagePrompts(`${item.pk}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
<ak-forms-modal>
<span slot="submit">
${gettext("Update")}
</span>
<span slot="header">
${gettext("Update Prompt")}
</span>
<ak-stage-prompt-form slot="form" .prompt=${item}>
</ak-stage-prompt-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${gettext("Edit")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</button>
</ak-forms-modal>
<ak-forms-delete
.obj=${item}
objectLabel=${gettext("Prompt")}
@ -83,12 +91,19 @@ export class PromptListPage extends TablePage<Prompt> {
renderToolbar(): TemplateResult {
return html`
<ak-modal-button href=${AdminURLManager.stagePrompts("create/")}>
<ak-spinner-button slot="trigger" class="pf-m-primary">
<ak-forms-modal>
<span slot="submit">
${gettext("Create")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</span>
<span slot="header">
${gettext("Create Prompt")}
</span>
<ak-stage-prompt-form slot="form">
</ak-stage-prompt-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${gettext("Create")}
</button>
</ak-forms-modal>
${super.renderToolbar()}
`;
}

View File

@ -52,7 +52,7 @@ export class TokenListPage extends TablePage<Token> {
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`

View File

@ -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";

View File

@ -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<Token> {
@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<Token> => {
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`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${gettext("Identifier")}
?required=${true}
name="identifier">
<input type="text" value="${ifDefined(this.token?.identifier)}" class="pf-c-form-control" required>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Description")}
?required=${true}
name="description">
<input type="text" value="${ifDefined(this.token?.description)}" class="pf-c-form-control" required>
</ak-form-element-horizontal>
</form>`;
}
}

View File

@ -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<Token> {
@ -39,14 +41,25 @@ export class UserTokenList extends Table<Token> {
];
}
static get styles(): CSSResult[] {
return super.styles.concat(PFDescriptionList);
}
renderToolbar(): TemplateResult {
return html`
<ak-modal-button href="/-/user/tokens/create/">
<ak-spinner-button slot="trigger" class="pf-m-primary">
<ak-forms-modal>
<span slot="submit">
${gettext("Create")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</span>
<span slot="header">
${gettext("Create Token")}
</span>
<ak-user-token-form slot="form">
</ak-user-token-form>
<button slot="trigger" class="pf-c-button pf-m-primary">
${gettext("Create")}
</button>
</ak-forms-modal>
${super.renderToolbar()}
`;
}
@ -61,7 +74,7 @@ export class UserTokenList extends Table<Token> {
<span class="pf-c-description-list__text">${gettext("User")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${item.user.username}</div>
<div class="pf-c-description-list__text">${item.user?.username}</div>
</dd>
</div>
<div class="pf-c-description-list__group">
@ -90,12 +103,19 @@ export class UserTokenList extends Table<Token> {
return [
html`${item.identifier}`,
html`
<ak-modal-button href="${AdminURLManager.tokens(`${item.identifier}/update/`)}">
<ak-spinner-button slot="trigger" class="pf-m-secondary">
<ak-forms-modal>
<span slot="submit">
${gettext("Update")}
</span>
<span slot="header">
${gettext("Update Token")}
</span>
<ak-user-token-form slot="form" .token=${item}>
</ak-user-token-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${gettext("Edit")}
</ak-spinner-button>
<div slot="modal"></div>
</ak-modal-button>
</button>
</ak-forms-modal>
<ak-forms-delete
.obj=${item}
objectLabel=${gettext("Token")}

View File

@ -28,7 +28,7 @@ import "./pages/system-tasks/SystemTaskListPage";
import "./pages/tokens/TokenListPage";
import "./pages/users/UserListPage";
import "./pages/users/UserViewPage";
import "./pages/users/UserSettingsPage";
import "./pages/user-settings/UserSettingsPage";
export const ROUTES: Route[] = [
// Prevent infinite Shell loops

View File

@ -56,3 +56,16 @@ export function camelToSnake(key: string): string {
const result = key.replace(/([A-Z])/g, " $1");
return result.split(" ").join("_").toLowerCase();
}
export function groupBy<T>(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> {
const m = new Map<string, T[]>();
objects.forEach(obj => {
const group = callback(obj);
if (!m.has(group)) {
m.set(group, []);
}
const tProviders = m.get(group) || [];
tProviders.push(obj);
});
return Array.from(m);
}