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:
sdimovv 2023-03-19 19:56:17 +02:00 committed by GitHub
parent 4da18b5f0c
commit 8b52d711e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 475 additions and 30 deletions

View File

@ -54,6 +54,7 @@ class TestPasswordPolicyFlow(FlowTestCase):
component="ak-stage-prompt", component="ak-stage-prompt",
fields=[ fields=[
{ {
"choices": None,
"field_key": "password", "field_key": "password",
"label": "PASSWORD_LABEL", "label": "PASSWORD_LABEL",
"order": 0, "order": 0,

View File

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

View File

@ -11,6 +11,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import ( from rest_framework.fields import (
BooleanField, BooleanField,
CharField, CharField,
ChoiceField,
DateField, DateField,
DateTimeField, DateTimeField,
EmailField, EmailField,
@ -38,10 +39,17 @@ class FieldTypes(models.TextChoices):
# Simple text field # Simple text field
TEXT = "text", _("Text: Simple Text input") TEXT = "text", _("Text: Simple Text input")
# Long text field
TEXT_AREA = "text_area", _("Text area: Multiline Text Input.")
# Simple text field # Simple text field
TEXT_READ_ONLY = "text_read_only", _( TEXT_READ_ONLY = "text_read_only", _(
"Text (read-only): Simple Text input, but cannot be edited." "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 # Same as text, but has autocomplete for password managers
USERNAME = ( USERNAME = (
"username", "username",
@ -58,6 +66,10 @@ class FieldTypes(models.TextChoices):
) )
NUMBER = "number" NUMBER = "number"
CHECKBOX = "checkbox" 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 = "date"
DATE_TIME = "date-time" DATE_TIME = "date-time"
@ -76,6 +88,9 @@ class FieldTypes(models.TextChoices):
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports") AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
CHOICE_FIELDS = (FieldTypes.RADIO_BUTTON_GROUP, FieldTypes.DROPDOWN)
class InlineFileField(CharField): class InlineFileField(CharField):
"""Field for inline data-URI base64 encoded files""" """Field for inline data-URI base64 encoded files"""
@ -102,7 +117,13 @@ class Prompt(SerializerModel):
label = models.TextField() label = models.TextField()
type = models.CharField(max_length=100, choices=FieldTypes.choices) type = models.CharField(max_length=100, choices=FieldTypes.choices)
required = models.BooleanField(default=True) 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="") sub_text = models.TextField(blank=True, default="")
order = models.IntegerField(default=0) order = models.IntegerField(default=0)
@ -115,8 +136,46 @@ class Prompt(SerializerModel):
return PromptSerializer 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: def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest) -> str:
"""Get fully interpolated placeholder""" """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: if self.field_key in prompt_context:
# We don't want to parse this as an expression since a user will # We don't want to parse this as an expression since a user will
# be able to control the input # be able to control the input
@ -133,16 +192,16 @@ class Prompt(SerializerModel):
) )
return self.placeholder return self.placeholder
def field(self, default: Optional[Any]) -> CharField: def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField:
"""Get field type for Challenge and response""" """Get field type for Challenge and response. Choices are only valid for CHOICE_FIELDS."""
field_class = CharField field_class = CharField
kwargs = { kwargs = {
"required": self.required, "required": self.required,
} }
if self.type == FieldTypes.TEXT: if self.type in (FieldTypes.TEXT, FieldTypes.TEXT_AREA):
kwargs["trim_whitespace"] = False kwargs["trim_whitespace"] = False
kwargs["allow_blank"] = not self.required 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 field_class = ReadOnlyField
# required can't be set for ReadOnlyField # required can't be set for ReadOnlyField
kwargs["required"] = False kwargs["required"] = False
@ -154,13 +213,15 @@ class Prompt(SerializerModel):
if self.type == FieldTypes.CHECKBOX: if self.type == FieldTypes.CHECKBOX:
field_class = BooleanField field_class = BooleanField
kwargs["required"] = False kwargs["required"] = False
if self.type in CHOICE_FIELDS:
field_class = ChoiceField
kwargs["choices"] = choices or []
if self.type == FieldTypes.DATE: if self.type == FieldTypes.DATE:
field_class = DateField field_class = DateField
if self.type == FieldTypes.DATE_TIME: if self.type == FieldTypes.DATE_TIME:
field_class = DateTimeField field_class = DateTimeField
if self.type == FieldTypes.FILE: if self.type == FieldTypes.FILE:
field_class = InlineFileField field_class = InlineFileField
if self.type == FieldTypes.SEPARATOR: if self.type == FieldTypes.SEPARATOR:
kwargs["required"] = False kwargs["required"] = False
kwargs["label"] = "" kwargs["label"] = ""

View File

@ -7,7 +7,14 @@ from django.db.models.query import QuerySet
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict from django.http.request import QueryDict
from django.utils.translation import gettext_lazy as _ 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 rest_framework.serializers import ValidationError
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
@ -33,6 +40,7 @@ class StagePromptSerializer(PassiveSerializer):
placeholder = CharField(allow_blank=True) placeholder = CharField(allow_blank=True)
order = IntegerField() order = IntegerField()
sub_text = CharField(allow_blank=True) sub_text = CharField(allow_blank=True)
choices = ListField(child=CharField(allow_blank=True), allow_empty=True, allow_null=True)
class PromptChallenge(Challenge): class PromptChallenge(Challenge):
@ -65,10 +73,13 @@ class PromptChallengeResponse(ChallengeResponse):
fields = list(self.stage_instance.fields.all()) fields = list(self.stage_instance.fields.all())
for field in fields: for field in fields:
field: Prompt field: Prompt
choices = field.get_choices(
plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
)
current = field.get_placeholder( current = field.get_placeholder(
plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request 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 # Special handling for fields with username type
# these check for existing users with the same username # these check for existing users with the same username
if field.type == FieldTypes.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 # Check if we have any static or hidden fields, and ensure they
# still have the same value # still have the same value
static_hidden_fields: QuerySet[Prompt] = self.stage_instance.fields.filter( 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: for static_hidden in static_hidden_fields:
field = self.fields[static_hidden.field_key] field = self.fields[static_hidden.field_key]
@ -180,8 +196,15 @@ class PromptStageView(ChallengeStageView):
context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}) context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
for field in fields: for field in fields:
data = StagePromptSerializer(field).data data = StagePromptSerializer(field).data
data["placeholder"] = field.get_placeholder( # Ensure all choices and placeholders are str, as otherwise further in
context_prompt, self.get_pending_user(), self.request # 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) serializers.append(data)
challenge = PromptChallenge( challenge = PromptChallenge(

View File

@ -45,6 +45,14 @@ class TestPromptStage(FlowTestCase):
required=True, required=True,
placeholder="TEXT_PLACEHOLDER", 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( email_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
field_key="email_prompt", field_key="email_prompt",
@ -91,6 +99,19 @@ class TestPromptStage(FlowTestCase):
required=True, required=True,
placeholder="static", 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 = PromptStage.objects.create(name="prompt-stage")
self.stage.fields.set( self.stage.fields.set(
[ [
@ -102,18 +123,23 @@ class TestPromptStage(FlowTestCase):
number_prompt, number_prompt,
hidden_prompt, hidden_prompt,
static_prompt, static_prompt,
radio_button_group,
dropdown,
] ]
) )
self.prompt_data = { self.prompt_data = {
username_prompt.field_key: "test-username", username_prompt.field_key: "test-username",
text_prompt.field_key: "test-input", text_prompt.field_key: "test-input",
text_area_prompt.field_key: "test-area-input",
email_prompt.field_key: "test@test.test", email_prompt.field_key: "test@test.test",
password_prompt.field_key: "test", password_prompt.field_key: "test",
password2_prompt.field_key: "test", password2_prompt.field_key: "test",
number_prompt.field_key: 3, number_prompt.field_key: 3,
hidden_prompt.field_key: hidden_prompt.placeholder, hidden_prompt.field_key: hidden_prompt.placeholder,
static_prompt.field_key: static_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) 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")]}, {"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): def test_static_hidden_overwrite(self):
"""Test that static and hidden fields ignore any value sent to them""" """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()]) 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"] 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): def test_prompt_placeholder_error(self):
"""Test placeholder and expression""" """Test placeholder and expression"""
context = {} context = {}

View File

@ -24125,24 +24125,32 @@ paths:
- checkbox - checkbox
- date - date
- date-time - date-time
- dropdown
- email - email
- file - file
- hidden - hidden
- number - number
- password - password
- radio-button-group
- separator - separator
- static - static
- text - text
- text_area
- text_area_read_only
- text_read_only - text_read_only
- username - username
description: |- description: |-
* `text` - Text: Simple Text input * `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_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. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type. * `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. * `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 * `number` - Number
* `checkbox` - Checkbox * `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` - Date
* `date-time` - Date Time * `date-time` - Date Time
* `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI * `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 * `ak-locale` - authentik: Selection of locales authentik supports
* `text` - Text: Simple Text input * `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_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. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type. * `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. * `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 * `number` - Number
* `checkbox` - Checkbox * `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` - Date
* `date-time` - Date Time * `date-time` - Date Time
* `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI * `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 type: boolean
placeholder: placeholder:
type: string type: string
description: When creating a Radio Button Group or Dropdown, enable interpreting
as expression and return a list to return multiple choices.
order: order:
type: integer type: integer
maximum: 2147483647 maximum: 2147483647
@ -37388,6 +37402,8 @@ components:
type: boolean type: boolean
placeholder: placeholder:
type: string type: string
description: When creating a Radio Button Group or Dropdown, enable interpreting
as expression and return a list to return multiple choices.
order: order:
type: integer type: integer
maximum: 2147483647 maximum: 2147483647
@ -37461,6 +37477,8 @@ components:
type: boolean type: boolean
placeholder: placeholder:
type: string type: string
description: When creating a Radio Button Group or Dropdown, enable interpreting
as expression and return a list to return multiple choices.
order: order:
type: integer type: integer
maximum: 2147483647 maximum: 2147483647
@ -37554,12 +37572,16 @@ components:
PromptTypeEnum: PromptTypeEnum:
enum: enum:
- text - text
- text_area
- text_read_only - text_read_only
- text_area_read_only
- username - username
- email - email
- password - password
- number - number
- checkbox - checkbox
- radio-button-group
- dropdown
- date - date
- date-time - date-time
- file - file
@ -37570,12 +37592,16 @@ components:
type: string type: string
description: |- description: |-
* `text` - Text: Simple Text input * `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_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. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type. * `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. * `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 * `number` - Number
* `checkbox` - Checkbox * `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` - Date
* `date-time` - Date Time * `date-time` - Date Time
* `file` - File: File upload for arbitrary files. File content will be available in flow context as data-URI * `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 type: integer
sub_text: sub_text:
type: string type: string
choices:
type: array
items:
type: string
nullable: true
required: required:
- choices
- field_key - field_key
- label - label
- order - order

View File

@ -49,12 +49,24 @@ export class PromptForm extends ModelForm<Prompt, string> {
> >
${t`Text: Simple Text input`} ${t`Text: Simple Text input`}
</option> </option>
<option
value=${PromptTypeEnum.TextArea}
?selected=${this.instance?.type === PromptTypeEnum.TextArea}
>
${t`Text Area: Multiline text input`}
</option>
<option <option
value=${PromptTypeEnum.TextReadOnly} value=${PromptTypeEnum.TextReadOnly}
?selected=${this.instance?.type === PromptTypeEnum.TextReadOnly} ?selected=${this.instance?.type === PromptTypeEnum.TextReadOnly}
> >
${t`Text (read-only): Simple Text input, but cannot be edited.`} ${t`Text (read-only): Simple Text input, but cannot be edited.`}
</option> </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 <option
value=${PromptTypeEnum.Username} value=${PromptTypeEnum.Username}
?selected=${this.instance?.type === PromptTypeEnum.Username} ?selected=${this.instance?.type === PromptTypeEnum.Username}
@ -85,6 +97,18 @@ export class PromptForm extends ModelForm<Prompt, string> {
> >
${t`Checkbox`} ${t`Checkbox`}
</option> </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 <option
value=${PromptTypeEnum.Date} value=${PromptTypeEnum.Date}
?selected=${this.instance?.type === 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-form-element-horizontal label=${t`Placeholder`} name="placeholder">
<ak-codemirror mode="python" value="${ifDefined(this.instance?.placeholder)}"> <ak-codemirror mode="python" value="${ifDefined(this.instance?.placeholder)}">
</ak-codemirror> </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>
<ak-form-element-horizontal label=${t`Help text`} name="subText"> <ak-form-element-horizontal label=${t`Help text`} name="subText">
<ak-codemirror mode="htmlmixed" value="${ifDefined(this.instance?.subText)}"> <ak-codemirror mode="htmlmixed" value="${ifDefined(this.instance?.subText)}">

View File

@ -6,7 +6,7 @@ import { BaseStage } from "@goauthentik/flow/stages/base";
import { t } from "@lingui/macro"; 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 { customElement } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
@ -28,7 +28,22 @@ import {
@customElement("ak-stage-prompt") @customElement("ak-stage-prompt")
export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> { export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> {
static get styles(): CSSResult[] { 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 { renderPromptInner(prompt: StagePrompt, placeholderAsValue: boolean): string {
@ -42,6 +57,15 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required} ?required=${prompt.required}
value="${placeholderAsValue ? prompt.placeholder : ""}">`; 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: case PromptTypeEnum.TextReadOnly:
return `<input return `<input
type="text" type="text"
@ -49,6 +73,13 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
class="pf-c-form-control" class="pf-c-form-control"
readonly readonly
value="${prompt.placeholder}">`; 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: case PromptTypeEnum.Username:
return `<input return `<input
type="text" type="text"
@ -113,6 +144,38 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
?required=${prompt.required}>`; ?required=${prompt.required}>`;
case PromptTypeEnum.Static: case PromptTypeEnum.Static:
return `<p>${prompt.placeholder}</p>`; 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: case PromptTypeEnum.AkLocale:
return `<select class="pf-c-form-control" name="${prompt.fieldKey}"> return `<select class="pf-c-form-control" name="${prompt.fieldKey}">
<option value="" ${prompt.placeholder === "" ? "selected" : ""}> <option value="" ${prompt.placeholder === "" ? "selected" : ""}>

View File

@ -9,14 +9,18 @@ This stage is used to show the user arbitrary prompts.
The prompt can be any of the following types: The prompt can be any of the following types:
| Type | Description | | Type | Description |
| ----------------- | ------------------------------------------------------------------------------------------ | | --------------------- | ------------------------------------------------------------------------------------------ |
| Text | Arbitrary text. No client-side validation is done. | | Text | Arbitrary text. No client-side validation is done. |
| Text (Read only) | Same as above, but cannot be edited. | | 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. | | 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). | | 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). | | Password | Same as text, shown as a password field client-side, and custom validation (see below). |
| Number | Numerical textbox. | | Number | Numerical textbox. |
| Checkbox | Simple checkbox. | | 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 | Same as text, except the client renders a date-picker |
| Date-time | Same as text, except the client renders a date-time-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 . | | 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 | | Static | Display arbitrary value as is |
| authentik: Locale | Display a list of all locales authentik supports. | | 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: Some types have special behaviors:
- _Username_: Input is validated against other usernames to ensure a unique value is provided. - _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 - _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. - _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: 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 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). 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. 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` ### `order`