From 4f4f9546933c1f3c7d8faa8b008c4dc89c5eae66 Mon Sep 17 00:00:00 2001
From: Jens L
Date: Thu, 3 Mar 2022 00:13:06 +0100
Subject: [PATCH] core: customisable user settings (#2397)
* tenants: add user_settings flow, add basic flow and basic new executor
Signed-off-by: Jens Langhammer
* web/user: use flow PromptStage instead of custom stage
Signed-off-by: Jens Langhammer
* web/flows: add tenant to StageHost interface
Signed-off-by: Jens Langhammer
* web/user: fix form missing component
Signed-off-by: Jens Langhammer
* web/user: re-add success message
Signed-off-by: Jens Langhammer
* web/user: improve support for multiple error messages
Signed-off-by: Jens Langhammer
* stages/prompt: allow expressions in prompt placeholders
Signed-off-by: Jens Langhammer
* stages/prompt: add tests
Signed-off-by: Jens Langhammer
* flows: always set pending user
Signed-off-by: Jens Langhammer
* flows: never cache stage configuration flow plans
Signed-off-by: Jens Langhammer
* stages/user_write: fix error when pending user is anonymous user
Signed-off-by: Jens Langhammer
* web/admin: add checkbox for prompt placeholder expression
Signed-off-by: Jens Langhammer
* website/docs: add prompt expression docs
Signed-off-by: Jens Langhammer
* stages/prompt: add ak-locale field type
Signed-off-by: Jens Langhammer
* tenants: fix default policy
Signed-off-by: Jens Langhammer
* web/user: add function to do global refresh
Signed-off-by: Jens Langhammer
* web/flows: fix rendering of ak-locale
Signed-off-by: Jens Langhammer
* tenants: fix default policy, add error handling to placeholder, fix locale attribute
Signed-off-by: Jens Langhammer
* add tests
Signed-off-by: Jens Langhammer
---
Makefile | 5 +
authentik/core/api/users.py | 62 -
authentik/core/tests/test_users_api.py | 52 +-
authentik/core/types.py | 2 +-
authentik/flows/planner.py | 33 +-
authentik/stages/prompt/api.py | 1 +
.../0007_prompt_placeholder_expression.py | 49 +
authentik/stages/prompt/models.py | 51 +-
authentik/stages/prompt/stage.py | 7 +-
authentik/stages/prompt/tests.py | 99 +-
authentik/stages/user_write/stage.py | 8 +-
authentik/tenants/api.py | 2 +
.../0002_tenant_flow_user_settings.py | 181 +++
authentik/tenants/models.py | 3 +
schema.yml | 86 +-
web/src/api/Users.ts | 8 +-
web/src/elements/forms/Form.ts | 9 +-
.../elements/forms/HorizontalFormElement.ts | 14 +-
web/src/flows/FlowExecutor.ts | 10 +-
web/src/flows/stages/base.ts | 6 +-
web/src/flows/stages/prompt/PromptStage.ts | 115 +-
web/src/locales/de.po | 150 +-
web/src/locales/en.po | 152 +-
web/src/locales/es.po | 150 +-
web/src/locales/fr_FR.po | 150 +-
web/src/locales/pl.po | 150 +-
web/src/locales/pseudo-LOCALE.po | 150 +-
web/src/locales/tr.po | 150 +-
web/src/locales/zh-Hans.po | 1413 ++++++++---------
web/src/locales/zh-Hant.po | 1413 ++++++++---------
web/src/locales/zh_TW.po | 1413 ++++++++---------
web/src/pages/stages/prompt/PromptForm.ts | 30 +-
web/src/pages/tenants/TenantForm.ts | 37 +
.../user/user-settings/UserSettingsPage.ts | 11 +-
.../user-settings/details/UserDetailsForm.ts | 143 --
.../details/UserSettingsFlowExecutor.ts | 231 +++
.../details/stages/prompt/PromptStage.ts | 49 +
.../user-settings/sources/SourceSettings.ts | 3 +-
.../docs/writing-documentation.md | 2 +-
website/docs/flow/stages/prompt/index.md | 32 +-
website/docs/user-group/user.md | 12 -
41 files changed, 3791 insertions(+), 2853 deletions(-)
create mode 100644 authentik/stages/prompt/migrations/0007_prompt_placeholder_expression.py
create mode 100644 authentik/tenants/migrations/0002_tenant_flow_user_settings.py
delete mode 100644 web/src/user/user-settings/details/UserDetailsForm.ts
create mode 100644 web/src/user/user-settings/details/UserSettingsFlowExecutor.ts
create mode 100644 web/src/user/user-settings/details/stages/prompt/PromptStage.ts
diff --git a/Makefile b/Makefile
index f4d06f02e..74b990445 100644
--- a/Makefile
+++ b/Makefile
@@ -130,3 +130,8 @@ ci-pyright: ci--meta-debug
ci-pending-migrations: ci--meta-debug
./manage.py makemigrations --check
+
+install:
+ poetry install
+ cd web && npm i
+ cd website && npm i
diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py
index 69eab3d25..767007960 100644
--- a/authentik/core/api/users.py
+++ b/authentik/core/api/users.py
@@ -24,7 +24,6 @@ from drf_spectacular.utils import (
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, DictField, JSONField, SerializerMethodField
-from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
@@ -46,9 +45,6 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
from authentik.core.models import (
- USER_ATTRIBUTE_CHANGE_EMAIL,
- USER_ATTRIBUTE_CHANGE_NAME,
- USER_ATTRIBUTE_CHANGE_USERNAME,
USER_ATTRIBUTE_SA,
USER_ATTRIBUTE_TOKEN_EXPIRING,
Group,
@@ -57,7 +53,6 @@ from authentik.core.models import (
User,
)
from authentik.events.models import EventAction
-from authentik.lib.config import CONFIG
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
@@ -126,43 +121,6 @@ class UserSelfSerializer(ModelSerializer):
"pk": group.pk,
}
- def validate_email(self, email: str):
- """Check if the user is allowed to change their email"""
- if self.instance.group_attributes().get(
- USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True)
- ):
- return email
- if email != self.instance.email:
- raise ValidationError("Not allowed to change email.")
- return email
-
- def validate_name(self, name: str):
- """Check if the user is allowed to change their name"""
- if self.instance.group_attributes().get(
- USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True)
- ):
- return name
- if name != self.instance.name:
- raise ValidationError("Not allowed to change name.")
- return name
-
- def validate_username(self, username: str):
- """Check if the user is allowed to change their username"""
- if self.instance.group_attributes().get(
- USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True)
- ):
- return username
- if username != self.instance.username:
- raise ValidationError("Not allowed to change username.")
- return username
-
- def save(self, **kwargs):
- if self.instance:
- attributes: dict = self.instance.attributes
- attributes.update(self.validated_data.get("attributes", {}))
- self.validated_data["attributes"] = attributes
- return super().save(**kwargs)
-
class Meta:
model = User
@@ -407,26 +365,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
update_session_auth_hash(self.request, user)
return Response(status=204)
- @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)})
- @action(
- methods=["PUT"],
- detail=False,
- pagination_class=None,
- filter_backends=[],
- permission_classes=[IsAuthenticated],
- )
- def update_self(self, request: Request) -> Response:
- """Allow users to change information on their own profile"""
- data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data)
- if not data.is_valid():
- return Response(data.errors, status=400)
- new_user = data.save()
- # If we're impersonating, we need to update that user object
- # since it caches the full object
- if SESSION_IMPERSONATE_USER in request.session:
- request.session[SESSION_IMPERSONATE_USER] = new_user
- return Response({"user": data.data})
-
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
@action(detail=True, pagination_class=None, filter_backends=[])
diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py
index 4134c86fa..6cddd3043 100644
--- a/authentik/core/tests/test_users_api.py
+++ b/authentik/core/tests/test_users_api.py
@@ -2,12 +2,7 @@
from django.urls.base import reverse
from rest_framework.test import APITestCase
-from authentik.core.models import (
- USER_ATTRIBUTE_CHANGE_EMAIL,
- USER_ATTRIBUTE_CHANGE_NAME,
- USER_ATTRIBUTE_CHANGE_USERNAME,
- User,
-)
+from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_key
@@ -22,51 +17,6 @@ class TestUsersAPI(APITestCase):
self.admin = create_test_admin_user()
self.user = User.objects.create(username="test-user")
- def test_update_self(self):
- """Test update_self"""
- self.admin.attributes["foo"] = "bar"
- self.admin.save()
- self.admin.refresh_from_db()
- self.client.force_login(self.admin)
- response = self.client.put(
- reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
- )
- self.admin.refresh_from_db()
- self.assertEqual(response.status_code, 200)
- self.assertEqual(self.admin.attributes["foo"], "bar")
- self.assertEqual(self.admin.username, "foo")
- self.assertEqual(self.admin.name, "foo")
-
- def test_update_self_name_denied(self):
- """Test update_self"""
- self.admin.attributes[USER_ATTRIBUTE_CHANGE_NAME] = False
- self.admin.save()
- self.client.force_login(self.admin)
- response = self.client.put(
- reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
- )
- self.assertEqual(response.status_code, 400)
-
- def test_update_self_username_denied(self):
- """Test update_self"""
- self.admin.attributes[USER_ATTRIBUTE_CHANGE_USERNAME] = False
- self.admin.save()
- self.client.force_login(self.admin)
- response = self.client.put(
- reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
- )
- self.assertEqual(response.status_code, 400)
-
- def test_update_self_email_denied(self):
- """Test update_self"""
- self.admin.attributes[USER_ATTRIBUTE_CHANGE_EMAIL] = False
- self.admin.save()
- self.client.force_login(self.admin)
- response = self.client.put(
- reverse("authentik_api:user-update-self"), data={"email": "foo", "name": "foo"}
- )
- self.assertEqual(response.status_code, 400)
-
def test_metrics(self):
"""Test user's metrics"""
self.client.force_login(self.admin)
diff --git a/authentik/core/types.py b/authentik/core/types.py
index 892f58e7e..2bcab71ac 100644
--- a/authentik/core/types.py
+++ b/authentik/core/types.py
@@ -29,4 +29,4 @@ class UserSettingSerializer(PassiveSerializer):
component = CharField()
title = CharField()
configure_url = CharField(required=False)
- icon_url = CharField()
+ icon_url = CharField(required=False)
diff --git a/authentik/flows/planner.py b/authentik/flows/planner.py
index 2e6dcb8bc..d1417363f 100644
--- a/authentik/flows/planner.py
+++ b/authentik/flows/planner.py
@@ -13,7 +13,7 @@ from authentik.core.models import User
from authentik.events.models import cleanse_dict
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.markers import ReevaluateMarker, StageMarker
-from authentik.flows.models import Flow, FlowStageBinding, Stage
+from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
from authentik.lib.config import CONFIG
from authentik.policies.engine import PolicyEngine
@@ -124,6 +124,8 @@ class FlowPlanner:
) -> FlowPlan:
"""Check each of the flows' policies, check policies for each stage with PolicyBinding
and return ordered list"""
+ if not default_context:
+ default_context = {}
with Hub.current.start_span(
op="authentik.flow.planner.plan", description=self.flow.slug
) as span:
@@ -137,16 +139,14 @@ class FlowPlanner:
# Bit of a workaround here, if there is a pending user set in the default context
# we use that user for our cache key
# to make sure they don't get the generic response
- if default_context and PLAN_CONTEXT_PENDING_USER in default_context:
- user = default_context[PLAN_CONTEXT_PENDING_USER]
- else:
- user = request.user
+ if PLAN_CONTEXT_PENDING_USER not in default_context:
+ default_context[PLAN_CONTEXT_PENDING_USER] = request.user
+ user = default_context[PLAN_CONTEXT_PENDING_USER]
# First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow
engine = PolicyEngine(self.flow, user, request)
- if default_context:
- span.set_data("default_context", cleanse_dict(default_context))
- engine.request.context = default_context
+ span.set_data("default_context", cleanse_dict(default_context))
+ engine.request.context = default_context
engine.build()
result = engine.result
if not result.passing:
@@ -156,14 +156,15 @@ class FlowPlanner:
# User is passing so far, check if we have a cached plan
cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None)
- if cached_plan and self.use_cache:
- self._logger.debug(
- "f(plan): taking plan from cache",
- key=cached_plan_key,
- )
- # Reset the context as this isn't factored into caching
- cached_plan.context = default_context or {}
- return cached_plan
+ if self.flow.designation not in [FlowDesignation.STAGE_CONFIGURATION]:
+ if cached_plan and self.use_cache:
+ self._logger.debug(
+ "f(plan): taking plan from cache",
+ key=cached_plan_key,
+ )
+ # Reset the context as this isn't factored into caching
+ cached_plan.context = default_context or {}
+ return cached_plan
self._logger.debug(
"f(plan): building plan",
)
diff --git a/authentik/stages/prompt/api.py b/authentik/stages/prompt/api.py
index 4a7fedc37..51b8daf3e 100644
--- a/authentik/stages/prompt/api.py
+++ b/authentik/stages/prompt/api.py
@@ -49,6 +49,7 @@ class PromptSerializer(ModelSerializer):
"order",
"promptstage_set",
"sub_text",
+ "placeholder_expression",
]
diff --git a/authentik/stages/prompt/migrations/0007_prompt_placeholder_expression.py b/authentik/stages/prompt/migrations/0007_prompt_placeholder_expression.py
new file mode 100644
index 000000000..581968456
--- /dev/null
+++ b/authentik/stages/prompt/migrations/0007_prompt_placeholder_expression.py
@@ -0,0 +1,49 @@
+# Generated by Django 4.0.2 on 2022-02-27 19:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_stages_prompt", "0006_alter_prompt_type"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="prompt",
+ name="placeholder_expression",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AlterField(
+ model_name="prompt",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("text", "Text: Simple Text input"),
+ (
+ "text_read_only",
+ "Text (read-only): Simple Text input, but cannot be edited.",
+ ),
+ (
+ "username",
+ "Username: Same as Text input, but checks for and prevents duplicate usernames.",
+ ),
+ ("email", "Email: Text field with Email type."),
+ (
+ "password",
+ "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.",
+ ),
+ ("number", "Number"),
+ ("checkbox", "Checkbox"),
+ ("date", "Date"),
+ ("date-time", "Date Time"),
+ ("separator", "Separator: Static Separator Line"),
+ ("hidden", "Hidden: Hidden field, can be used to insert data into form."),
+ ("static", "Static: Static value, displayed as-is."),
+ ("ak-locale", "authentik: Selection of locales authentik supports"),
+ ],
+ max_length=100,
+ ),
+ ),
+ ]
diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py
index 0c04aacc9..264f3b06d 100644
--- a/authentik/stages/prompt/models.py
+++ b/authentik/stages/prompt/models.py
@@ -3,6 +3,7 @@ from typing import Any, Optional
from uuid import uuid4
from django.db import models
+from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from django.views import View
from rest_framework.fields import (
@@ -16,15 +17,23 @@ from rest_framework.fields import (
ReadOnlyField,
)
from rest_framework.serializers import BaseSerializer
+from structlog.stdlib import get_logger
+from authentik.core.exceptions import PropertyMappingExpressionException
+from authentik.core.expression import PropertyMappingEvaluator
+from authentik.core.models import User
from authentik.flows.models import Stage
from authentik.lib.models import SerializerModel
from authentik.policies.models import Policy
+LOGGER = get_logger()
+
class FieldTypes(models.TextChoices):
"""Field types an Prompt can be"""
+ # update website/docs/flow/stages/prompt.index.md
+
# Simple text field
TEXT = "text", _("Text: Simple Text input")
# Simple text field
@@ -56,6 +65,8 @@ class FieldTypes(models.TextChoices):
HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
STATIC = "static", _("Static: Static value, displayed as-is.")
+ AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
+
class Prompt(SerializerModel):
"""Single Prompt, part of a prompt stage."""
@@ -73,12 +84,33 @@ class Prompt(SerializerModel):
order = models.IntegerField(default=0)
+ placeholder_expression = models.BooleanField(default=False)
+
@property
def serializer(self) -> BaseSerializer:
from authentik.stages.prompt.api import PromptSerializer
return PromptSerializer
+ def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest) -> str:
+ """Get fully interpolated placeholder"""
+ if self.field_key in prompt_context:
+ # We don't want to parse this as an expression since a user will
+ # be able to control the input
+ return prompt_context[self.field_key]
+
+ if self.placeholder_expression:
+ evaluator = PropertyMappingEvaluator()
+ evaluator.set_context(user, request, self, prompt_context=prompt_context)
+ try:
+ return evaluator.evaluate(self.placeholder)
+ except Exception as exc: # pylint:disable=broad-except
+ LOGGER.warning(
+ "failed to evaluate prompt placeholder",
+ exc=PropertyMappingExpressionException(str(exc)),
+ )
+ return self.placeholder
+
def field(self, default: Optional[Any]) -> CharField:
"""Get field type for Challenge and response"""
field_class = CharField
@@ -93,10 +125,6 @@ class Prompt(SerializerModel):
field_class = EmailField
if self.type == FieldTypes.NUMBER:
field_class = IntegerField
- if self.type == FieldTypes.HIDDEN:
- field_class = HiddenField
- kwargs["required"] = False
- kwargs["default"] = self.placeholder
if self.type == FieldTypes.CHECKBOX:
field_class = BooleanField
kwargs["required"] = False
@@ -104,13 +132,22 @@ class Prompt(SerializerModel):
field_class = DateField
if self.type == FieldTypes.DATE_TIME:
field_class = DateTimeField
+
+ if self.type == FieldTypes.SEPARATOR:
+ kwargs["required"] = False
+ kwargs["label"] = ""
+ if self.type == FieldTypes.HIDDEN:
+ field_class = HiddenField
+ kwargs["required"] = False
+ kwargs["default"] = self.placeholder
if self.type == FieldTypes.STATIC:
kwargs["default"] = self.placeholder
kwargs["required"] = False
kwargs["label"] = ""
- if self.type == FieldTypes.SEPARATOR:
- kwargs["required"] = False
- kwargs["label"] = ""
+
+ if self.type == FieldTypes.AK_LOCALE:
+ kwargs["allow_blank"] = True
+
if default:
kwargs["default"] = default
# May not set both `required` and `default`
diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py
index 437d5cf01..349d6254d 100644
--- a/authentik/stages/prompt/stage.py
+++ b/authentik/stages/prompt/stage.py
@@ -165,13 +165,14 @@ class PromptStageView(ChallengeStageView):
response_class = PromptChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
- fields = list(self.executor.current_stage.fields.all().order_by("order"))
+ fields: list[Prompt] = list(self.executor.current_stage.fields.all().order_by("order"))
serializers = []
context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
for field in fields:
data = StagePromptSerializer(field).data
- if field.field_key in context_prompt:
- data["placeholder"] = context_prompt.get(field.field_key)
+ data["placeholder"] = field.get_placeholder(
+ context_prompt, self.get_pending_user(), self.request
+ )
serializers.append(data)
challenge = PromptChallenge(
data={
diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py
index 94084a69a..cabb6e6a2 100644
--- a/authentik/stages/prompt/tests.py
+++ b/authentik/stages/prompt/tests.py
@@ -1,16 +1,17 @@
"""Prompt tests"""
from unittest.mock import MagicMock, patch
+from django.test import RequestFactory
from django.urls import reverse
from rest_framework.exceptions import ErrorDetail
-from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN
+from authentik.lib.generators import generate_id
from authentik.policies.expression.models import ExpressionPolicy
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT, PromptChallengeResponse
@@ -21,8 +22,8 @@ class TestPromptStage(FlowTestCase):
def setUp(self):
super().setUp()
- self.user = User.objects.create(username="unittest", email="test@beryju.org")
-
+ self.user = create_test_admin_user()
+ self.factory = RequestFactory()
self.flow = Flow.objects.create(
name="test-prompt",
slug="test-prompt",
@@ -219,3 +220,95 @@ class TestPromptStage(FlowTestCase):
self.assertNotEqual(challenge_response.validated_data["hidden_prompt"], "foo")
self.assertEqual(challenge_response.validated_data["hidden_prompt"], "hidden")
self.assertNotEqual(challenge_response.validated_data["static_prompt"], "foo")
+
+ def test_prompt_placeholder(self):
+ """Test placeholder and expression"""
+ context = {
+ "foo": generate_id(),
+ }
+ prompt: Prompt = Prompt(
+ field_key="text_prompt_expression",
+ label="TEXT_LABEL",
+ type=FieldTypes.TEXT,
+ placeholder="return prompt_context['foo']",
+ placeholder_expression=True,
+ )
+ self.assertEqual(
+ prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
+ )
+ context["text_prompt_expression"] = generate_id()
+ self.assertEqual(
+ prompt.get_placeholder(context, self.user, self.factory.get("/")),
+ context["text_prompt_expression"],
+ )
+ self.assertNotEqual(
+ prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
+ )
+
+ def test_prompt_placeholder_error(self):
+ """Test placeholder and expression"""
+ context = {}
+ prompt: Prompt = Prompt(
+ field_key="text_prompt_expression",
+ label="TEXT_LABEL",
+ type=FieldTypes.TEXT,
+ placeholder="something invalid dunno",
+ placeholder_expression=True,
+ )
+ self.assertEqual(
+ prompt.get_placeholder(context, self.user, self.factory.get("/")),
+ "something invalid dunno",
+ )
+
+ def test_prompt_placeholder_disabled(self):
+ """Test placeholder and expression"""
+ context = {}
+ prompt: Prompt = Prompt(
+ field_key="text_prompt_expression",
+ label="TEXT_LABEL",
+ type=FieldTypes.TEXT,
+ placeholder="return prompt_context['foo']",
+ placeholder_expression=False,
+ )
+ self.assertEqual(
+ prompt.get_placeholder(context, self.user, self.factory.get("/")), prompt.placeholder
+ )
+
+ def test_field_types(self):
+ """Ensure all field types can successfully be created"""
+
+ def test_invalid_save(self):
+ """Ensure field can't be saved with invalid type"""
+ prompt: Prompt = Prompt(
+ field_key="text_prompt_expression",
+ label="TEXT_LABEL",
+ type="foo",
+ placeholder="foo",
+ placeholder_expression=False,
+ sub_text="test",
+ order=123,
+ )
+ with self.assertRaises(ValueError):
+ prompt.save()
+
+
+def field_type_tester_factory(field_type: FieldTypes):
+ """Test field for field_type"""
+
+ def tester(self: TestPromptStage):
+ prompt: Prompt = Prompt(
+ field_key="text_prompt_expression",
+ label="TEXT_LABEL",
+ type=field_type,
+ placeholder="foo",
+ placeholder_expression=False,
+ sub_text="test",
+ order=123,
+ )
+ self.assertIsNotNone(prompt.field("foo"))
+
+ return tester
+
+
+for _type in FieldTypes:
+ setattr(TestPromptStage, f"test_field_type_{_type}", field_type_tester_factory(_type))
diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py
index e5fde9b67..51d774085 100644
--- a/authentik/stages/user_write/stage.py
+++ b/authentik/stages/user_write/stage.py
@@ -3,6 +3,7 @@ from typing import Any
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash
+from django.contrib.auth.models import AnonymousUser
from django.db import transaction
from django.db.utils import IntegrityError
from django.http import HttpRequest, HttpResponse
@@ -56,7 +57,12 @@ class UserWriteStageView(StageView):
return self.executor.stage_invalid()
data = self.executor.plan.context[PLAN_CONTEXT_PROMPT]
user_created = False
- if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
+ # check if pending user is set (default to anonymous user), if
+ # it's an anonymous user then we need to create a new user.
+ if isinstance(
+ self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER, AnonymousUser()),
+ AnonymousUser,
+ ):
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User(
is_active=not self.executor.current_stage.create_users_as_inactive
)
diff --git a/authentik/tenants/api.py b/authentik/tenants/api.py
index a18893674..631ceb7ee 100644
--- a/authentik/tenants/api.py
+++ b/authentik/tenants/api.py
@@ -50,6 +50,7 @@ class TenantSerializer(ModelSerializer):
"flow_invalidation",
"flow_recovery",
"flow_unenrollment",
+ "flow_user_settings",
"event_retention",
"web_certificate",
]
@@ -72,6 +73,7 @@ class CurrentTenantSerializer(PassiveSerializer):
flow_invalidation = CharField(source="flow_invalidation.slug", required=False)
flow_recovery = CharField(source="flow_recovery.slug", required=False)
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
+ flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
class TenantViewSet(UsedByMixin, ModelViewSet):
diff --git a/authentik/tenants/migrations/0002_tenant_flow_user_settings.py b/authentik/tenants/migrations/0002_tenant_flow_user_settings.py
new file mode 100644
index 000000000..8d628b925
--- /dev/null
+++ b/authentik/tenants/migrations/0002_tenant_flow_user_settings.py
@@ -0,0 +1,181 @@
+# Generated by Django 4.0.2 on 2022-02-26 21:14
+
+import django.db.models.deletion
+from django.apps.registry import Apps
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+from authentik.flows.models import FlowDesignation
+from authentik.stages.identification.models import UserFields
+from authentik.stages.password import BACKEND_APP_PASSWORD, BACKEND_INBUILT, BACKEND_LDAP
+
+AUTHORIZATION_POLICY = """from authentik.lib.config import CONFIG
+from authentik.core.models import (
+ USER_ATTRIBUTE_CHANGE_EMAIL,
+ USER_ATTRIBUTE_CHANGE_NAME,
+ USER_ATTRIBUTE_CHANGE_USERNAME
+)
+prompt_data = request.context.get("prompt_data")
+
+if not request.user.group_attributes().get(
+ USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True)
+):
+ if prompt_data.get("email") != request.user.email:
+ ak_message("Not allowed to change email address.")
+ return False
+
+if not request.user.group_attributes().get(
+ USER_ATTRIBUTE_CHANGE_NAME, CONFIG.y_bool("default_user_change_name", True)
+):
+ if prompt_data.get("name") != request.user.name:
+ ak_message("Not allowed to change name.")
+ return False
+
+if not request.user.group_attributes().get(
+ USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True)
+):
+ if prompt_data.get("username") != request.user.username:
+ ak_message("Not allowed to change username.")
+ return False
+
+return True
+"""
+
+
+def create_default_user_settings_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
+ from authentik.stages.prompt.models import FieldTypes
+
+ db_alias = schema_editor.connection.alias
+
+ Tenant = apps.get_model("authentik_tenants", "Tenant")
+
+ Flow = apps.get_model("authentik_flows", "Flow")
+ FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding")
+
+ ExpressionPolicy = apps.get_model("authentik_policies_expression", "ExpressionPolicy")
+
+ UserWriteStage = apps.get_model("authentik_stages_user_write", "UserWriteStage")
+ PromptStage = apps.get_model("authentik_stages_prompt", "PromptStage")
+ Prompt = apps.get_model("authentik_stages_prompt", "Prompt")
+
+ prompt_username, _ = Prompt.objects.using(db_alias).update_or_create(
+ field_key="username",
+ order=200,
+ defaults={
+ "label": "Username",
+ "type": FieldTypes.TEXT,
+ "placeholder": """try:
+ return user.username
+except:
+ return """,
+ "placeholder_expression": True,
+ },
+ )
+ prompt_name, _ = Prompt.objects.using(db_alias).update_or_create(
+ field_key="name",
+ order=201,
+ defaults={
+ "label": "Name",
+ "type": FieldTypes.TEXT,
+ "placeholder": """try:
+ return user.name
+except:
+ return """,
+ "placeholder_expression": True,
+ },
+ )
+ prompt_email, _ = Prompt.objects.using(db_alias).update_or_create(
+ field_key="email",
+ order=202,
+ defaults={
+ "label": "Email",
+ "type": FieldTypes.EMAIL,
+ "placeholder": """try:
+ return user.email
+except:
+ return """,
+ "placeholder_expression": True,
+ },
+ )
+ prompt_locale, _ = Prompt.objects.using(db_alias).update_or_create(
+ field_key="attributes.settings.locale",
+ order=203,
+ defaults={
+ "label": "Locale",
+ "type": FieldTypes.AK_LOCALE,
+ "placeholder": """try:
+ return return user.attributes.get("settings", {}).get("locale", "")
+except:
+ return """,
+ "placeholder_expression": True,
+ "required": True,
+ },
+ )
+
+ validation_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create(
+ name="default-user-settings-authorization",
+ defaults={
+ "expression": AUTHORIZATION_POLICY,
+ },
+ )
+ prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create(
+ name="default-user-settings",
+ )
+ prompt_stage.validation_policies.set([validation_policy])
+ prompt_stage.fields.set([prompt_username, prompt_name, prompt_email, prompt_locale])
+ prompt_stage.save()
+ user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create(
+ name="default-user-settings-write"
+ )
+
+ flow, _ = Flow.objects.using(db_alias).update_or_create(
+ slug="default-user-settings-flow",
+ designation=FlowDesignation.STAGE_CONFIGURATION,
+ defaults={
+ "name": "Update your info",
+ },
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow,
+ stage=prompt_stage,
+ defaults={
+ "order": 20,
+ },
+ )
+ FlowStageBinding.objects.using(db_alias).update_or_create(
+ target=flow,
+ stage=user_write,
+ defaults={
+ "order": 100,
+ },
+ )
+
+ tenant = Tenant.objects.using(db_alias).filter(default=True).first()
+ if not tenant:
+ return
+ tenant.flow_user_settings = flow
+ tenant.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_policies_expression", "__latest__"),
+ ("authentik_stages_prompt", "0007_prompt_placeholder_expression"),
+ ("authentik_flows", "0021_auto_20211227_2103"),
+ ("authentik_tenants", "0001_squashed_0005_tenant_web_certificate"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="tenant",
+ name="flow_user_settings",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="tenant_user_settings",
+ to="authentik_flows.flow",
+ ),
+ ),
+ migrations.RunPython(create_default_user_settings_flow),
+ ]
diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py
index 57830003e..58b6de674 100644
--- a/authentik/tenants/models.py
+++ b/authentik/tenants/models.py
@@ -40,6 +40,9 @@ class Tenant(models.Model):
flow_unenrollment = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_unenrollment"
)
+ flow_user_settings = models.ForeignKey(
+ Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_user_settings"
+ )
event_retention = models.TextField(
default="days=365",
diff --git a/schema.yml b/schema.yml
index 2285fc88f..bf1a9f160 100644
--- a/schema.yml
+++ b/schema.yml
@@ -2342,6 +2342,11 @@ paths:
schema:
type: string
format: uuid
+ - in: query
+ name: flow_user_settings
+ schema:
+ type: string
+ format: uuid
- name: ordering
required: false
in: query
@@ -3369,31 +3374,6 @@ paths:
$ref: '#/components/schemas/ValidationError'
'403':
$ref: '#/components/schemas/GenericError'
- /core/users/update_self/:
- put:
- operationId: core_users_update_self_update
- description: Allow users to change information on their own profile
- tags:
- - core
- requestBody:
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserSelfRequest'
- required: true
- security:
- - authentik: []
- responses:
- '200':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/SessionUser'
- description: ''
- '400':
- $ref: '#/components/schemas/ValidationError'
- '403':
- $ref: '#/components/schemas/GenericError'
/crypto/certificatekeypairs/:
get:
operationId: crypto_certificatekeypairs_list
@@ -17636,6 +17616,7 @@ paths:
schema:
type: string
enum:
+ - ak-locale
- checkbox
- date
- date-time
@@ -20471,11 +20452,7 @@ components:
items:
$ref: '#/components/schemas/FooterLink'
readOnly: true
- default:
- - href: https://goauthentik.io/docs/?utm_source=authentik
- name: Documentation
- - href: https://goauthentik.io/?utm_source=authentik
- name: authentik Website
+ default: []
flow_authentication:
type: string
flow_invalidation:
@@ -20484,6 +20461,8 @@ components:
type: string
flow_unenrollment:
type: string
+ flow_user_settings:
+ type: string
required:
- branding_favicon
- branding_logo
@@ -27772,6 +27751,8 @@ components:
$ref: '#/components/schemas/StageRequest'
sub_text:
type: string
+ placeholder_expression:
+ type: boolean
PatchedPromptStageRequest:
type: object
description: PromptStage Serializer
@@ -28144,6 +28125,10 @@ components:
type: string
format: uuid
nullable: true
+ flow_user_settings:
+ type: string
+ format: uuid
+ nullable: true
event_retention:
type: string
minLength: 1
@@ -28723,6 +28708,8 @@ components:
$ref: '#/components/schemas/Stage'
sub_text:
type: string
+ placeholder_expression:
+ type: boolean
required:
- field_key
- label
@@ -28790,6 +28777,8 @@ components:
$ref: '#/components/schemas/StageRequest'
sub_text:
type: string
+ placeholder_expression:
+ type: boolean
required:
- field_key
- label
@@ -28877,6 +28866,7 @@ components:
- separator
- hidden
- static
+ - ak-locale
type: string
PropertyMapping:
type: object
@@ -30567,6 +30557,10 @@ components:
type: string
format: uuid
nullable: true
+ flow_user_settings:
+ type: string
+ format: uuid
+ nullable: true
event_retention:
type: string
description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).'
@@ -30614,6 +30608,10 @@ components:
type: string
format: uuid
nullable: true
+ flow_user_settings:
+ type: string
+ format: uuid
+ nullable: true
event_retention:
type: string
minLength: 1
@@ -31174,33 +31172,6 @@ components:
required:
- name
- pk
- UserSelfRequest:
- type: object
- description: |-
- User Serializer for information a user can retrieve about themselves and
- update about themselves
- properties:
- username:
- type: string
- minLength: 1
- description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
- only.
- pattern: ^[\w.@+-]+$
- maxLength: 150
- name:
- type: string
- description: User's display name.
- email:
- type: string
- format: email
- title: Email address
- maxLength: 254
- settings:
- type: object
- additionalProperties: {}
- required:
- - name
- - username
UserServiceAccountRequest:
type: object
properties:
@@ -31238,7 +31209,6 @@ components:
type: string
required:
- component
- - icon_url
- object_uid
- title
UserSourceConnection:
diff --git a/web/src/api/Users.ts b/web/src/api/Users.ts
index 201322a24..ee29746bb 100644
--- a/web/src/api/Users.ts
+++ b/web/src/api/Users.ts
@@ -2,7 +2,13 @@ import { CoreApi, SessionUser } from "@goauthentik/api";
import { activateLocale } from "../interfaces/locale";
import { DEFAULT_CONFIG } from "./Config";
-let globalMePromise: Promise;
+let globalMePromise: Promise | undefined;
+
+export function refreshMe(): Promise {
+ globalMePromise = undefined;
+ return me();
+}
+
export function me(): Promise {
if (!globalMePromise) {
globalMePromise = new CoreApi(DEFAULT_CONFIG).coreUsersMeRetrieve().then((user) => {
diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts
index ca2ddd6cb..1053c050e 100644
--- a/web/src/elements/forms/Form.ts
+++ b/web/src/elements/forms/Form.ts
@@ -1,7 +1,6 @@
import "@polymer/iron-form/iron-form";
import { IronFormElement } from "@polymer/iron-form/iron-form";
import "@polymer/paper-input/paper-input";
-import { PaperInputElement } from "@polymer/paper-input/paper-input";
import { CSSResult, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -22,6 +21,7 @@ import { showMessage } from "../../elements/messages/MessageContainer";
import { camelToSnake, convertToSlug } from "../../utils";
import { SearchSelect } from "../SearchSelect";
import { MessageLevel } from "../messages/Message";
+import { HorizontalFormElement } from "./HorizontalFormElement";
export class APIError extends Error {
constructor(public response: ValidationError) {
@@ -217,16 +217,15 @@ export class Form extends LitElement {
throw errorMessage;
}
// assign all input-related errors to their elements
- const elements: PaperInputElement[] = ironForm._getSubmittableElements();
+ const elements: HorizontalFormElement[] = ironForm._getSubmittableElements();
elements.forEach((element) => {
const elementName = element.name;
if (!elementName) return;
if (camelToSnake(elementName) in errorMessage) {
- element.errorMessage =
- errorMessage[camelToSnake(elementName)].join(", ");
+ element.errorMessages = errorMessage[camelToSnake(elementName)];
element.invalid = true;
} else {
- element.errorMessage = "";
+ element.errorMessages = [];
element.invalid = false;
}
});
diff --git a/web/src/elements/forms/HorizontalFormElement.ts b/web/src/elements/forms/HorizontalFormElement.ts
index fb719309e..4bda70044 100644
--- a/web/src/elements/forms/HorizontalFormElement.ts
+++ b/web/src/elements/forms/HorizontalFormElement.ts
@@ -45,8 +45,8 @@ export class HorizontalFormElement extends LitElement {
@property({ type: Boolean })
writeOnlyActivated = false;
- @property()
- errorMessage = "";
+ @property({ attribute: false })
+ errorMessages: string[] = [];
_invalid = false;
@@ -124,11 +124,11 @@ export class HorizontalFormElement extends LitElement {
${t`Click to change value`}
`
: html``}
- ${this.invalid
- ? html`
- ${this.errorMessage}
-
`
- : html``}
+ ${this.errorMessages.map((message) => {
+ return html`
+ ${message}
+
`;
+ })}
`;
diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts
index 713c62006..377ad6e6c 100644
--- a/web/src/flows/FlowExecutor.ts
+++ b/web/src/flows/FlowExecutor.ts
@@ -53,7 +53,7 @@ import "./stages/prompt/PromptStage";
@customElement("ak-flow-executor")
export class FlowExecutor extends LitElement implements StageHost {
- flowSlug: string;
+ flowSlug?: string;
private _challenge?: ChallengeTypes;
@@ -90,7 +90,7 @@ export class FlowExecutor extends LitElement implements StageHost {
loading = false;
@property({ attribute: false })
- tenant?: CurrentTenant;
+ tenant!: CurrentTenant;
@property({ attribute: false })
inspectorOpen: boolean;
@@ -124,6 +124,7 @@ export class FlowExecutor extends LitElement implements StageHost {
this.ws = new WebsocketClient();
this.flowSlug = window.location.pathname.split("/")[3];
this.inspectorOpen = window.location.search.includes("inspector");
+ tenant().then((tenant) => (this.tenant = tenant));
}
setBackground(url: string): void {
@@ -142,7 +143,7 @@ export class FlowExecutor extends LitElement implements StageHost {
this.loading = true;
return new FlowsApi(DEFAULT_CONFIG)
.flowsExecutorSolve({
- flowSlug: this.flowSlug,
+ flowSlug: this.flowSlug || "",
query: window.location.search.substring(1),
flowChallengeResponseRequest: payload,
})
@@ -173,11 +174,10 @@ export class FlowExecutor extends LitElement implements StageHost {
firstUpdated(): void {
configureSentry();
- tenant().then((tenant) => (this.tenant = tenant));
this.loading = true;
new FlowsApi(DEFAULT_CONFIG)
.flowsExecutorGet({
- flowSlug: this.flowSlug,
+ flowSlug: this.flowSlug || "",
query: window.location.search.substring(1),
})
.then((challenge) => {
diff --git a/web/src/flows/stages/base.ts b/web/src/flows/stages/base.ts
index d6c97de67..20f1a3359 100644
--- a/web/src/flows/stages/base.ts
+++ b/web/src/flows/stages/base.ts
@@ -1,13 +1,15 @@
import { LitElement, TemplateResult, html } from "lit";
import { property } from "lit/decorators.js";
-import { ErrorDetail } from "@goauthentik/api";
+import { CurrentTenant, ErrorDetail } from "@goauthentik/api";
export interface StageHost {
challenge?: unknown;
- flowSlug: string;
+ flowSlug?: string;
loading: boolean;
submit(payload: unknown): Promise;
+
+ readonly tenant: CurrentTenant;
}
export class BaseStage extends LitElement {
diff --git a/web/src/flows/stages/prompt/PromptStage.ts b/web/src/flows/stages/prompt/PromptStage.ts
index 3151c2aef..f1d285eb4 100644
--- a/web/src/flows/stages/prompt/PromptStage.ts
+++ b/web/src/flows/stages/prompt/PromptStage.ts
@@ -23,6 +23,7 @@ import {
import "../../../elements/Divider";
import "../../../elements/EmptyState";
import "../../../elements/forms/FormElement";
+import { LOCALES } from "../../../interfaces/locale";
import { BaseStage } from "../base";
@customElement("ak-stage-prompt")
@@ -31,7 +32,7 @@ export class PromptStage extends BaseStage`;
+ value="${placeholderAsValue ? prompt.placeholder : ""}">`;
case PromptTypeEnum.TextReadOnly:
return ``;
+ value="${placeholderAsValue ? prompt.placeholder : ""}">`;
case PromptTypeEnum.Email:
return ``;
+ value="${placeholderAsValue ? prompt.placeholder : ""}">`;
case PromptTypeEnum.Password:
return ``;
case PromptTypeEnum.Static:
return `${prompt.placeholder}
`;
+ case PromptTypeEnum.AkLocale:
+ return ``;
default:
return `invalid type '${prompt.type}'
`;
}
@@ -118,6 +133,54 @@ export class PromptStage extends BaseStage${unsafeHTML(prompt.subText)}`;
}
+ renderField(prompt: StagePrompt): TemplateResult {
+ // Checkbox is rendered differently
+ if (prompt.type === PromptTypeEnum.Checkbox) {
+ return html`
+
+
+ ${prompt.required
+ ? html`
${t`Required.`}
`
+ : html``}
+
${unsafeHTML(prompt.subText)}
+
`;
+ }
+ // Special types that aren't rendered in a wrapper
+ if (
+ prompt.type === PromptTypeEnum.Static ||
+ prompt.type === PromptTypeEnum.Hidden ||
+ prompt.type === PromptTypeEnum.Separator
+ ) {
+ return html`
+ ${unsafeHTML(this.renderPromptInner(prompt, false))}
+ ${this.renderPromptHelpText(prompt)}
+ `;
+ }
+ return html`
+ ${unsafeHTML(this.renderPromptInner(prompt, false))}
+ ${this.renderPromptHelpText(prompt)}
+ `;
+ }
+
+ renderContinue(): TemplateResult {
+ return html`
+
+
`;
+ }
+
render(): TemplateResult {
if (!this.challenge) {
return html` `;
@@ -133,54 +196,14 @@ export class PromptStage extends BaseStage
${this.challenge.fields.map((prompt) => {
- // Checkbox is rendered differently
- if (prompt.type === PromptTypeEnum.Checkbox) {
- return html`
-
-
- ${prompt.required
- ? html`
${t`Required.`}
`
- : html``}
-
${unsafeHTML(prompt.subText)}
-
`;
- }
- // Special types that aren't rendered in a wrapper
- if (
- prompt.type === PromptTypeEnum.Static ||
- prompt.type === PromptTypeEnum.Hidden ||
- prompt.type === PromptTypeEnum.Separator
- ) {
- return html`
- ${unsafeHTML(this.renderPromptInner(prompt))}
- ${this.renderPromptHelpText(prompt)}
- `;
- }
- return html`
- ${unsafeHTML(this.renderPromptInner(prompt))}
- ${this.renderPromptHelpText(prompt)}
- `;
+ return this.renderField(prompt);
})}
${"non_field_errors" in (this.challenge?.responseErrors || {})
? this.renderNonFieldErrors(
this.challenge?.responseErrors?.non_field_errors || [],
)
: html``}
-
-
-
+ ${this.renderContinue()}