stages/prompt: Add Radio Button Group, Dropdown and Text Area prompt fields (#4822)
* Added radio-button prompt type in model * Add radio-button prompt * Refactored radio-button prompt; Added dropdown prompt * Added tests * Fixed unrelated to choice fields bug causing validation errors; Added more tests * Added description for new prompts * Added docs * Fix lint * Add forgotten file changes * Fix lint * Small fix * Add text-area prompts * Update authentik/stages/prompt/models.py Co-authored-by: Jens L. <jens@beryju.org> Signed-off-by: sdimovv <36302090+sdimovv@users.noreply.github.com> * Update authentik/stages/prompt/models.py Co-authored-by: Jens L. <jens@beryju.org> Signed-off-by: sdimovv <36302090+sdimovv@users.noreply.github.com> * Fix inline css * remove AKGlobal, update schema Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: sdimovv <36302090+sdimovv@users.noreply.github.com> Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Jens L. <jens@beryju.org> Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
parent
4da18b5f0c
commit
8b52d711e8
|
@ -54,6 +54,7 @@ class TestPasswordPolicyFlow(FlowTestCase):
|
|||
component="ak-stage-prompt",
|
||||
fields=[
|
||||
{
|
||||
"choices": None,
|
||||
"field_key": "password",
|
||||
"label": "PASSWORD_LABEL",
|
||||
"order": 0,
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
# Generated by Django 4.1.7 on 2023-03-03 17:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_stages_prompt", "0009_prompt_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="prompt",
|
||||
name="placeholder",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="When creating a Radio Button Group or Dropdown, enable interpreting as expression and return a list to return multiple choices.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="prompt",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("text", "Text: Simple Text input"),
|
||||
("text_area", "Text area: Multiline Text Input."),
|
||||
(
|
||||
"text_read_only",
|
||||
"Text (read-only): Simple Text input, but cannot be edited.",
|
||||
),
|
||||
(
|
||||
"text_area_read_only",
|
||||
"Text area (read-only): Multiline 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"),
|
||||
(
|
||||
"radio-button-group",
|
||||
"Fixed choice field rendered as a group of radio buttons.",
|
||||
),
|
||||
("dropdown", "Fixed choice field rendered as a dropdown."),
|
||||
("date", "Date"),
|
||||
("date-time", "Date Time"),
|
||||
(
|
||||
"file",
|
||||
"File: File upload for arbitrary files. File content will be available in flow context as data-URI",
|
||||
),
|
||||
("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,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -11,6 +11,7 @@ from rest_framework.exceptions import ValidationError
|
|||
from rest_framework.fields import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
ChoiceField,
|
||||
DateField,
|
||||
DateTimeField,
|
||||
EmailField,
|
||||
|
@ -38,10 +39,17 @@ class FieldTypes(models.TextChoices):
|
|||
|
||||
# Simple text field
|
||||
TEXT = "text", _("Text: Simple Text input")
|
||||
# Long text field
|
||||
TEXT_AREA = "text_area", _("Text area: Multiline Text Input.")
|
||||
# Simple text field
|
||||
TEXT_READ_ONLY = "text_read_only", _(
|
||||
"Text (read-only): Simple Text input, but cannot be edited."
|
||||
)
|
||||
# Long text field
|
||||
TEXT_AREA_READ_ONLY = "text_area_read_only", _(
|
||||
"Text area (read-only): Multiline Text input, but cannot be edited."
|
||||
)
|
||||
|
||||
# Same as text, but has autocomplete for password managers
|
||||
USERNAME = (
|
||||
"username",
|
||||
|
@ -58,6 +66,10 @@ class FieldTypes(models.TextChoices):
|
|||
)
|
||||
NUMBER = "number"
|
||||
CHECKBOX = "checkbox"
|
||||
RADIO_BUTTON_GROUP = "radio-button-group", _(
|
||||
"Fixed choice field rendered as a group of radio buttons."
|
||||
)
|
||||
DROPDOWN = "dropdown", _("Fixed choice field rendered as a dropdown.")
|
||||
DATE = "date"
|
||||
DATE_TIME = "date-time"
|
||||
|
||||
|
@ -76,6 +88,9 @@ class FieldTypes(models.TextChoices):
|
|||
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
|
||||
|
||||
|
||||
CHOICE_FIELDS = (FieldTypes.RADIO_BUTTON_GROUP, FieldTypes.DROPDOWN)
|
||||
|
||||
|
||||
class InlineFileField(CharField):
|
||||
"""Field for inline data-URI base64 encoded files"""
|
||||
|
||||
|
@ -102,7 +117,13 @@ class Prompt(SerializerModel):
|
|||
label = models.TextField()
|
||||
type = models.CharField(max_length=100, choices=FieldTypes.choices)
|
||||
required = models.BooleanField(default=True)
|
||||
placeholder = models.TextField(blank=True)
|
||||
placeholder = models.TextField(
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"When creating a Radio Button Group or Dropdown, enable interpreting as "
|
||||
"expression and return a list to return multiple choices."
|
||||
),
|
||||
)
|
||||
sub_text = models.TextField(blank=True, default="")
|
||||
|
||||
order = models.IntegerField(default=0)
|
||||
|
@ -115,8 +136,46 @@ class Prompt(SerializerModel):
|
|||
|
||||
return PromptSerializer
|
||||
|
||||
def get_choices(
|
||||
self, prompt_context: dict, user: User, request: HttpRequest
|
||||
) -> Optional[tuple[dict[str, Any]]]:
|
||||
"""Get fully interpolated list of choices"""
|
||||
if self.type not in CHOICE_FIELDS:
|
||||
return None
|
||||
|
||||
raw_choices = self.placeholder
|
||||
|
||||
if self.field_key in prompt_context:
|
||||
raw_choices = prompt_context[self.field_key]
|
||||
elif self.placeholder_expression:
|
||||
evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context)
|
||||
try:
|
||||
raw_choices = evaluator.evaluate(self.placeholder)
|
||||
except Exception as exc: # pylint:disable=broad-except
|
||||
LOGGER.warning(
|
||||
"failed to evaluate prompt choices",
|
||||
exc=PropertyMappingExpressionException(str(exc)),
|
||||
)
|
||||
|
||||
if isinstance(raw_choices, (list, tuple, set)):
|
||||
choices = raw_choices
|
||||
else:
|
||||
choices = [raw_choices]
|
||||
|
||||
if len(choices) == 0:
|
||||
LOGGER.warning("failed to get prompt choices", choices=choices, input=raw_choices)
|
||||
|
||||
return tuple(choices)
|
||||
|
||||
def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest) -> str:
|
||||
"""Get fully interpolated placeholder"""
|
||||
if self.type in CHOICE_FIELDS:
|
||||
# Make sure to return a valid choice as placeholder
|
||||
choices = self.get_choices(prompt_context, user, request)
|
||||
if not choices:
|
||||
return ""
|
||||
return choices[0]
|
||||
|
||||
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
|
||||
|
@ -133,16 +192,16 @@ class Prompt(SerializerModel):
|
|||
)
|
||||
return self.placeholder
|
||||
|
||||
def field(self, default: Optional[Any]) -> CharField:
|
||||
"""Get field type for Challenge and response"""
|
||||
def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField:
|
||||
"""Get field type for Challenge and response. Choices are only valid for CHOICE_FIELDS."""
|
||||
field_class = CharField
|
||||
kwargs = {
|
||||
"required": self.required,
|
||||
}
|
||||
if self.type == FieldTypes.TEXT:
|
||||
if self.type in (FieldTypes.TEXT, FieldTypes.TEXT_AREA):
|
||||
kwargs["trim_whitespace"] = False
|
||||
kwargs["allow_blank"] = not self.required
|
||||
if self.type == FieldTypes.TEXT_READ_ONLY:
|
||||
if self.type in (FieldTypes.TEXT_READ_ONLY, FieldTypes.TEXT_AREA_READ_ONLY):
|
||||
field_class = ReadOnlyField
|
||||
# required can't be set for ReadOnlyField
|
||||
kwargs["required"] = False
|
||||
|
@ -154,13 +213,15 @@ class Prompt(SerializerModel):
|
|||
if self.type == FieldTypes.CHECKBOX:
|
||||
field_class = BooleanField
|
||||
kwargs["required"] = False
|
||||
if self.type in CHOICE_FIELDS:
|
||||
field_class = ChoiceField
|
||||
kwargs["choices"] = choices or []
|
||||
if self.type == FieldTypes.DATE:
|
||||
field_class = DateField
|
||||
if self.type == FieldTypes.DATE_TIME:
|
||||
field_class = DateTimeField
|
||||
if self.type == FieldTypes.FILE:
|
||||
field_class = InlineFileField
|
||||
|
||||
if self.type == FieldTypes.SEPARATOR:
|
||||
kwargs["required"] = False
|
||||
kwargs["label"] = ""
|
||||
|
|
|
@ -7,7 +7,14 @@ from django.db.models.query import QuerySet
|
|||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.fields import BooleanField, CharField, ChoiceField, IntegerField, empty
|
||||
from rest_framework.fields import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
ChoiceField,
|
||||
IntegerField,
|
||||
ListField,
|
||||
empty,
|
||||
)
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
|
@ -33,6 +40,7 @@ class StagePromptSerializer(PassiveSerializer):
|
|||
placeholder = CharField(allow_blank=True)
|
||||
order = IntegerField()
|
||||
sub_text = CharField(allow_blank=True)
|
||||
choices = ListField(child=CharField(allow_blank=True), allow_empty=True, allow_null=True)
|
||||
|
||||
|
||||
class PromptChallenge(Challenge):
|
||||
|
@ -65,10 +73,13 @@ class PromptChallengeResponse(ChallengeResponse):
|
|||
fields = list(self.stage_instance.fields.all())
|
||||
for field in fields:
|
||||
field: Prompt
|
||||
choices = field.get_choices(
|
||||
plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
|
||||
)
|
||||
current = field.get_placeholder(
|
||||
plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
|
||||
)
|
||||
self.fields[field.field_key] = field.field(current)
|
||||
self.fields[field.field_key] = field.field(current, choices)
|
||||
# Special handling for fields with username type
|
||||
# these check for existing users with the same username
|
||||
if field.type == FieldTypes.USERNAME:
|
||||
|
@ -99,7 +110,12 @@ class PromptChallengeResponse(ChallengeResponse):
|
|||
# Check if we have any static or hidden fields, and ensure they
|
||||
# still have the same value
|
||||
static_hidden_fields: QuerySet[Prompt] = self.stage_instance.fields.filter(
|
||||
type__in=[FieldTypes.HIDDEN, FieldTypes.STATIC, FieldTypes.TEXT_READ_ONLY]
|
||||
type__in=[
|
||||
FieldTypes.HIDDEN,
|
||||
FieldTypes.STATIC,
|
||||
FieldTypes.TEXT_READ_ONLY,
|
||||
FieldTypes.TEXT_AREA_READ_ONLY,
|
||||
]
|
||||
)
|
||||
for static_hidden in static_hidden_fields:
|
||||
field = self.fields[static_hidden.field_key]
|
||||
|
@ -180,8 +196,15 @@ class PromptStageView(ChallengeStageView):
|
|||
context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
|
||||
for field in fields:
|
||||
data = StagePromptSerializer(field).data
|
||||
data["placeholder"] = field.get_placeholder(
|
||||
context_prompt, self.get_pending_user(), self.request
|
||||
# Ensure all choices and placeholders are str, as otherwise further in
|
||||
# we can fail serializer validation if we return some types such as bool
|
||||
choices = field.get_choices(context_prompt, self.get_pending_user(), self.request)
|
||||
if choices:
|
||||
data["choices"] = [str(choice) for choice in choices]
|
||||
else:
|
||||
data["choices"] = None
|
||||
data["placeholder"] = str(
|
||||
field.get_placeholder(context_prompt, self.get_pending_user(), self.request)
|
||||
)
|
||||
serializers.append(data)
|
||||
challenge = PromptChallenge(
|
||||
|
|
|
@ -45,6 +45,14 @@ class TestPromptStage(FlowTestCase):
|
|||
required=True,
|
||||
placeholder="TEXT_PLACEHOLDER",
|
||||
)
|
||||
text_area_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
field_key="text_area_prompt",
|
||||
label="TEXT_AREA_LABEL",
|
||||
type=FieldTypes.TEXT_AREA,
|
||||
required=True,
|
||||
placeholder="TEXT_AREA_PLACEHOLDER",
|
||||
)
|
||||
email_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
field_key="email_prompt",
|
||||
|
@ -91,6 +99,19 @@ class TestPromptStage(FlowTestCase):
|
|||
required=True,
|
||||
placeholder="static",
|
||||
)
|
||||
radio_button_group = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
field_key="radio_button_group",
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
required=True,
|
||||
placeholder="test",
|
||||
)
|
||||
dropdown = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
field_key="dropdown",
|
||||
type=FieldTypes.DROPDOWN,
|
||||
required=True,
|
||||
)
|
||||
self.stage = PromptStage.objects.create(name="prompt-stage")
|
||||
self.stage.fields.set(
|
||||
[
|
||||
|
@ -102,18 +123,23 @@ class TestPromptStage(FlowTestCase):
|
|||
number_prompt,
|
||||
hidden_prompt,
|
||||
static_prompt,
|
||||
radio_button_group,
|
||||
dropdown,
|
||||
]
|
||||
)
|
||||
|
||||
self.prompt_data = {
|
||||
username_prompt.field_key: "test-username",
|
||||
text_prompt.field_key: "test-input",
|
||||
text_area_prompt.field_key: "test-area-input",
|
||||
email_prompt.field_key: "test@test.test",
|
||||
password_prompt.field_key: "test",
|
||||
password2_prompt.field_key: "test",
|
||||
number_prompt.field_key: 3,
|
||||
hidden_prompt.field_key: hidden_prompt.placeholder,
|
||||
static_prompt.field_key: static_prompt.placeholder,
|
||||
radio_button_group.field_key: radio_button_group.placeholder,
|
||||
dropdown.field_key: "",
|
||||
}
|
||||
|
||||
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
|
||||
|
@ -251,6 +277,34 @@ class TestPromptStage(FlowTestCase):
|
|||
{"username_prompt": [ErrorDetail(string="Username is already taken.", code="invalid")]},
|
||||
)
|
||||
|
||||
def test_invalid_choice_field(self):
|
||||
"""Test invalid choice field value"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
self.prompt_data["radio_button_group"] = "some invalid choice"
|
||||
self.prompt_data["dropdown"] = "another invalid choice"
|
||||
challenge_response = PromptChallengeResponse(
|
||||
None, stage_instance=self.stage, plan=plan, data=self.prompt_data, stage=self.stage_view
|
||||
)
|
||||
self.assertEqual(challenge_response.is_valid(), False)
|
||||
self.assertEqual(
|
||||
challenge_response.errors,
|
||||
{
|
||||
"radio_button_group": [
|
||||
ErrorDetail(
|
||||
string=f"\"{self.prompt_data['radio_button_group']}\" "
|
||||
"is not a valid choice.",
|
||||
code="invalid_choice",
|
||||
)
|
||||
],
|
||||
"dropdown": [
|
||||
ErrorDetail(
|
||||
string=f"\"{self.prompt_data['dropdown']}\" is not a valid choice.",
|
||||
code="invalid_choice",
|
||||
)
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def test_static_hidden_overwrite(self):
|
||||
"""Test that static and hidden fields ignore any value sent to them"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
|
@ -289,6 +343,113 @@ class TestPromptStage(FlowTestCase):
|
|||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
|
||||
def test_choice_prompts_placeholders(self):
|
||||
"""Test placeholders and expression of choice fields"""
|
||||
context = {"foo": generate_id()}
|
||||
|
||||
# No choices - unusable (in the sense it creates an unsubmittable form)
|
||||
# but valid behaviour
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
placeholder="return []",
|
||||
placeholder_expression=True,
|
||||
)
|
||||
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple())
|
||||
context["fixed_choice_prompt_expression"] = generate_id()
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")),
|
||||
context["fixed_choice_prompt_expression"],
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
(context["fixed_choice_prompt_expression"],),
|
||||
)
|
||||
self.assertNotEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
|
||||
self.assertNotEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple())
|
||||
|
||||
del context["fixed_choice_prompt_expression"]
|
||||
|
||||
# Single choice
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.RADIO_BUTTON_GROUP,
|
||||
placeholder="return prompt_context['foo']",
|
||||
placeholder_expression=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
|
||||
)
|
||||
context["fixed_choice_prompt_expression"] = generate_id()
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")),
|
||||
context["fixed_choice_prompt_expression"],
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
(context["fixed_choice_prompt_expression"],),
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
|
||||
)
|
||||
|
||||
del context["fixed_choice_prompt_expression"]
|
||||
|
||||
# Multi choice
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="fixed_choice_prompt_expression",
|
||||
label="LABEL",
|
||||
type=FieldTypes.DROPDOWN,
|
||||
placeholder="return [prompt_context['foo'], True, 'text']",
|
||||
placeholder_expression=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
(context["foo"], True, "text"),
|
||||
)
|
||||
context["fixed_choice_prompt_expression"] = tuple(["text", generate_id(), 2])
|
||||
self.assertEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")),
|
||||
"text",
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
context["fixed_choice_prompt_expression"],
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
|
||||
)
|
||||
self.assertNotEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
(context["foo"], True, "text"),
|
||||
)
|
||||
|
||||
def test_choices_are_none_for_non_choice_fields(self):
|
||||
"""Test choices are None for non choice fields"""
|
||||
context = {}
|
||||
prompt: Prompt = Prompt(
|
||||
field_key="text_prompt_expression",
|
||||
label="TEXT_LABEL",
|
||||
type=FieldTypes.TEXT,
|
||||
placeholder="choice",
|
||||
)
|
||||
self.assertEqual(
|
||||
prompt.get_choices(context, self.user, self.factory.get("/")),
|
||||
None,
|
||||
)
|
||||
|
||||
def test_prompt_placeholder_error(self):
|
||||
"""Test placeholder and expression"""
|
||||
context = {}
|
||||
|
|
32
schema.yml
32
schema.yml
|
@ -24125,24 +24125,32 @@ paths:
|
|||
- checkbox
|
||||
- date
|
||||
- date-time
|
||||
- dropdown
|
||||
- email
|
||||
- file
|
||||
- hidden
|
||||
- number
|
||||
- password
|
||||
- radio-button-group
|
||||
- separator
|
||||
- static
|
||||
- text
|
||||
- text_area
|
||||
- text_area_read_only
|
||||
- text_read_only
|
||||
- username
|
||||
description: |-
|
||||
* `text` - Text: Simple Text input
|
||||
* `text_area` - Text area: Multiline Text Input.
|
||||
* `text_read_only` - Text (read-only): Simple Text input, but cannot be edited.
|
||||
* `text_area_read_only` - Text area (read-only): Multiline 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
|
||||
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
|
||||
* `dropdown` - Fixed choice field rendered as a dropdown.
|
||||
* `date` - Date
|
||||
* `date-time` - Date Time
|
||||
* `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI
|
||||
|
@ -24152,12 +24160,16 @@ paths:
|
|||
* `ak-locale` - authentik: Selection of locales authentik supports
|
||||
|
||||
* `text` - Text: Simple Text input
|
||||
* `text_area` - Text area: Multiline Text Input.
|
||||
* `text_read_only` - Text (read-only): Simple Text input, but cannot be edited.
|
||||
* `text_area_read_only` - Text area (read-only): Multiline 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
|
||||
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
|
||||
* `dropdown` - Fixed choice field rendered as a dropdown.
|
||||
* `date` - Date
|
||||
* `date-time` - Date Time
|
||||
* `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI
|
||||
|
@ -36262,6 +36274,8 @@ components:
|
|||
type: boolean
|
||||
placeholder:
|
||||
type: string
|
||||
description: When creating a Radio Button Group or Dropdown, enable interpreting
|
||||
as expression and return a list to return multiple choices.
|
||||
order:
|
||||
type: integer
|
||||
maximum: 2147483647
|
||||
|
@ -37388,6 +37402,8 @@ components:
|
|||
type: boolean
|
||||
placeholder:
|
||||
type: string
|
||||
description: When creating a Radio Button Group or Dropdown, enable interpreting
|
||||
as expression and return a list to return multiple choices.
|
||||
order:
|
||||
type: integer
|
||||
maximum: 2147483647
|
||||
|
@ -37461,6 +37477,8 @@ components:
|
|||
type: boolean
|
||||
placeholder:
|
||||
type: string
|
||||
description: When creating a Radio Button Group or Dropdown, enable interpreting
|
||||
as expression and return a list to return multiple choices.
|
||||
order:
|
||||
type: integer
|
||||
maximum: 2147483647
|
||||
|
@ -37554,12 +37572,16 @@ components:
|
|||
PromptTypeEnum:
|
||||
enum:
|
||||
- text
|
||||
- text_area
|
||||
- text_read_only
|
||||
- text_area_read_only
|
||||
- username
|
||||
- email
|
||||
- password
|
||||
- number
|
||||
- checkbox
|
||||
- radio-button-group
|
||||
- dropdown
|
||||
- date
|
||||
- date-time
|
||||
- file
|
||||
|
@ -37570,12 +37592,16 @@ components:
|
|||
type: string
|
||||
description: |-
|
||||
* `text` - Text: Simple Text input
|
||||
* `text_area` - Text area: Multiline Text Input.
|
||||
* `text_read_only` - Text (read-only): Simple Text input, but cannot be edited.
|
||||
* `text_area_read_only` - Text area (read-only): Multiline 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
|
||||
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
|
||||
* `dropdown` - Fixed choice field rendered as a dropdown.
|
||||
* `date` - Date
|
||||
* `date-time` - Date Time
|
||||
* `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI
|
||||
|
@ -39448,7 +39474,13 @@ components:
|
|||
type: integer
|
||||
sub_text:
|
||||
type: string
|
||||
choices:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- choices
|
||||
- field_key
|
||||
- label
|
||||
- order
|
||||
|
|
|
@ -49,12 +49,24 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
>
|
||||
${t`Text: Simple Text input`}
|
||||
</option>
|
||||
<option
|
||||
value=${PromptTypeEnum.TextArea}
|
||||
?selected=${this.instance?.type === PromptTypeEnum.TextArea}
|
||||
>
|
||||
${t`Text Area: Multiline text input`}
|
||||
</option>
|
||||
<option
|
||||
value=${PromptTypeEnum.TextReadOnly}
|
||||
?selected=${this.instance?.type === PromptTypeEnum.TextReadOnly}
|
||||
>
|
||||
${t`Text (read-only): Simple Text input, but cannot be edited.`}
|
||||
</option>
|
||||
<option
|
||||
value=${PromptTypeEnum.TextAreaReadOnly}
|
||||
?selected=${this.instance?.type === PromptTypeEnum.TextAreaReadOnly}
|
||||
>
|
||||
${t`Text Area (read-only): Multiline text input, but cannot be edited.`}
|
||||
</option>
|
||||
<option
|
||||
value=${PromptTypeEnum.Username}
|
||||
?selected=${this.instance?.type === PromptTypeEnum.Username}
|
||||
|
@ -85,6 +97,18 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
>
|
||||
${t`Checkbox`}
|
||||
</option>
|
||||
<option
|
||||
value=${PromptTypeEnum.RadioButtonGroup}
|
||||
?selected=${this.instance?.type === PromptTypeEnum.RadioButtonGroup}
|
||||
>
|
||||
${t`Radio Button Group (fixed choice)`}
|
||||
</option>
|
||||
<option
|
||||
value=${PromptTypeEnum.Dropdown}
|
||||
?selected=${this.instance?.type === PromptTypeEnum.Dropdown}
|
||||
>
|
||||
${t`Dropdown (fixed choice)`}
|
||||
</option>
|
||||
<option
|
||||
value=${PromptTypeEnum.Date}
|
||||
?selected=${this.instance?.type === PromptTypeEnum.Date}
|
||||
|
@ -210,7 +234,11 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
<ak-form-element-horizontal label=${t`Placeholder`} name="placeholder">
|
||||
<ak-codemirror mode="python" value="${ifDefined(this.instance?.placeholder)}">
|
||||
</ak-codemirror>
|
||||
<p class="pf-c-form__helper-text">${t`Optionally pre-fill the input value`}</p>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Optionally pre-fill the input value.
|
||||
When creating a "Radio Button Group" or "Dropdown", enable interpreting as
|
||||
expression and return a list to return multiple choices.`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`Help text`} name="subText">
|
||||
<ak-codemirror mode="htmlmixed" value="${ifDefined(this.instance?.subText)}">
|
||||
|
|
|
@ -6,7 +6,7 @@ import { BaseStage } from "@goauthentik/flow/stages/base";
|
|||
|
||||
import { t } from "@lingui/macro";
|
||||
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
|
@ -28,7 +28,22 @@ import {
|
|||
@customElement("ak-stage-prompt")
|
||||
export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFLogin, PFAlert, PFForm, PFFormControl, PFTitle, PFButton];
|
||||
return [
|
||||
PFBase,
|
||||
PFLogin,
|
||||
PFAlert,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
css`
|
||||
textarea {
|
||||
min-height: 4em;
|
||||
max-height: 15em;
|
||||
resize: vertical;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
renderPromptInner(prompt: StagePrompt, placeholderAsValue: boolean): string {
|
||||
|
@ -42,6 +57,15 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
|
|||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${placeholderAsValue ? prompt.placeholder : ""}">`;
|
||||
case PromptTypeEnum.TextArea:
|
||||
return `<textarea
|
||||
type="text"
|
||||
name="${prompt.fieldKey}"
|
||||
placeholder="${prompt.placeholder}"
|
||||
autocomplete="off"
|
||||
class="pf-c-form-control"
|
||||
?required=${prompt.required}
|
||||
value="${placeholderAsValue ? prompt.placeholder : ""}">`;
|
||||
case PromptTypeEnum.TextReadOnly:
|
||||
return `<input
|
||||
type="text"
|
||||
|
@ -49,6 +73,13 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
|
|||
class="pf-c-form-control"
|
||||
readonly
|
||||
value="${prompt.placeholder}">`;
|
||||
case PromptTypeEnum.TextAreaReadOnly:
|
||||
return `<textarea
|
||||
type="text"
|
||||
name="${prompt.fieldKey}"
|
||||
class="pf-c-form-control"
|
||||
readonly
|
||||
value="${prompt.placeholder}">`;
|
||||
case PromptTypeEnum.Username:
|
||||
return `<input
|
||||
type="text"
|
||||
|
@ -113,6 +144,38 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
|
|||
?required=${prompt.required}>`;
|
||||
case PromptTypeEnum.Static:
|
||||
return `<p>${prompt.placeholder}</p>`;
|
||||
case PromptTypeEnum.Dropdown:
|
||||
return `<select class="pf-c-form-control" name="${prompt.fieldKey}">
|
||||
${prompt.choices
|
||||
?.map((choice) => {
|
||||
return `<option
|
||||
value=${choice}
|
||||
${prompt.placeholder === choice ? "selected" : ""}
|
||||
>
|
||||
${choice}
|
||||
</option>`;
|
||||
})
|
||||
.join("")}
|
||||
</select>`;
|
||||
case PromptTypeEnum.RadioButtonGroup:
|
||||
return (
|
||||
prompt.choices
|
||||
?.map((choice) => {
|
||||
return ` <div class="pf-c-check">
|
||||
<input
|
||||
type="radio"
|
||||
class="pf-c-check__input"
|
||||
name="${prompt.fieldKey}"
|
||||
checked="${prompt.placeholder === choice}"
|
||||
required="${prompt.required}"
|
||||
value="${choice}"
|
||||
/>
|
||||
<label class="pf-c-check__label">${choice}</label>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("") || ""
|
||||
);
|
||||
case PromptTypeEnum.AkLocale:
|
||||
return `<select class="pf-c-form-control" name="${prompt.fieldKey}">
|
||||
<option value="" ${prompt.placeholder === "" ? "selected" : ""}>
|
||||
|
|
|
@ -9,14 +9,18 @@ This stage is used to show the user arbitrary prompts.
|
|||
The prompt can be any of the following types:
|
||||
|
||||
| Type | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------ |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Text | Arbitrary text. No client-side validation is done. |
|
||||
| Text (Read only) | Same as above, but cannot be edited. |
|
||||
| Text Area | Arbitrary multiline text. No client-side validation is done. |
|
||||
| Text Area (Read only) | Same as above, but cannot be edited. |
|
||||
| Username | Same as text, except the username is validated to be unique. |
|
||||
| Email | Text input, ensures the value is an email address (validation is only done client-side). |
|
||||
| Password | Same as text, shown as a password field client-side, and custom validation (see below). |
|
||||
| Number | Numerical textbox. |
|
||||
| Checkbox | Simple checkbox. |
|
||||
| Radio Button Group | Similar to checkboxes, but allows selecting a value from a set of predefined values. |
|
||||
| Dropdwon | A simple dropdown menu filled with predefined values. |
|
||||
| Date | Same as text, except the client renders a date-picker |
|
||||
| Date-time | Same as text, except the client renders a date-time-picker |
|
||||
| File | Allow users to upload a file, which will be available as base64-encoded data in the flow . |
|
||||
|
@ -25,11 +29,16 @@ The prompt can be any of the following types:
|
|||
| Static | Display arbitrary value as is |
|
||||
| authentik: Locale | Display a list of all locales authentik supports. |
|
||||
|
||||
:::note
|
||||
`Radio Button Group` and `Dropdown` options require authentik 2023.3+
|
||||
:::
|
||||
|
||||
Some types have special behaviors:
|
||||
|
||||
- _Username_: Input is validated against other usernames to ensure a unique value is provided.
|
||||
- _Password_: All prompts with the type password within the same stage are compared and must be equal. If they are not equal, an error is shown
|
||||
- _Hidden_ and _Static_: Their placeholder values are defaults and are not user-changeable.
|
||||
- _Radio Button Group_ and _Dropdown_: Only allow the user to select one of a set of predefined values.
|
||||
|
||||
A prompt has the following attributes:
|
||||
|
||||
|
@ -56,6 +65,8 @@ A field placeholder, shown within the input field. This field is also used by th
|
|||
By default, the placeholder is interpreted as-is. If you enable _Interpret placeholder as expression_, the placeholder
|
||||
will be evaluated as a python expression. This happens in the same environment as [_Property mappings_](../../../property-mappings/expression).
|
||||
|
||||
In the case of `Radio Button Group` and `Dropdown` prompts, this field defines all possible values. When interpreted as-is, only one value will be allowed (the placeholder string). When interpreted as expression, a list of values can be returned to define multiple choices. For example, `return ["first option", 42, "another option"]` defines 3 possible values.
|
||||
|
||||
You can access both the HTTP request and the user as with a mapping. Additionally, you can access `prompt_context`, which is a dictionary of the current state of the prompt stage's data.
|
||||
|
||||
### `order`
|
||||
|
|
Reference in New Issue