stages/prompt: Add initial_data prompt field and ability to select a default choice for choice fields (#5095)

* Added initial_value to model

* Added initial_value to admin panel

* Added initial_value support to flows; updated tests

* Updated default blueprints

* update docs

* Fix test

* Fix another test

* Fix yet another test

* Add placeholder migration

* Remove unused import
This commit is contained in:
sdimovv 2023-04-19 11:27:51 +01:00 committed by GitHub
parent 04cc7817ee
commit ee6edec1d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 418 additions and 138 deletions

View File

@ -59,6 +59,7 @@ class TestPasswordPolicyFlow(FlowTestCase):
"label": "PASSWORD_LABEL", "label": "PASSWORD_LABEL",
"order": 0, "order": 0,
"placeholder": "PASSWORD_PLACEHOLDER", "placeholder": "PASSWORD_PLACEHOLDER",
"initial_value": "",
"required": True, "required": True,
"type": "password", "type": "password",
"sub_text": "", "sub_text": "",

View File

@ -57,10 +57,12 @@ class PromptSerializer(ModelSerializer):
"type", "type",
"required", "required",
"placeholder", "placeholder",
"initial_value",
"order", "order",
"promptstage_set", "promptstage_set",
"sub_text", "sub_text",
"placeholder_expression", "placeholder_expression",
"initial_value_expression",
] ]

View File

@ -0,0 +1,53 @@
# Generated by Django 4.1.7 on 2023-03-24 17:32
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_placeholder_expressions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.stages.prompt.models import CHOICE_FIELDS
db_alias = schema_editor.connection.alias
Prompt = apps.get_model("authentik_stages_prompt", "prompt")
for prompt in Prompt.objects.using(db_alias).all():
if not prompt.placeholder_expression or prompt.type in CHOICE_FIELDS:
continue
prompt.initial_value = prompt.placeholder
prompt.initial_value_expression = True
prompt.placeholder = ""
prompt.placeholder_expression = False
prompt.save()
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_prompt", "0010_alter_prompt_placeholder_alter_prompt_type"),
]
operations = [
migrations.AddField(
model_name="prompt",
name="initial_value",
field=models.TextField(
blank=True,
help_text="Optionally pre-fill the input with an initial value. When creating a fixed choice field, enable interpreting as expression and return a list to return multiple default choices.",
),
),
migrations.AddField(
model_name="prompt",
name="initial_value_expression",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="prompt",
name="placeholder",
field=models.TextField(
blank=True,
help_text="Optionally provide a short hint that describes the expected input value. When creating a fixed choice field, enable interpreting as expression and return a list to return multiple choices.",
),
),
migrations.RunPython(code=migrate_placeholder_expressions),
]

View File

@ -29,6 +29,8 @@ from authentik.flows.models import Stage
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
from authentik.policies.models import Policy from authentik.policies.models import Policy
CHOICES_CONTEXT_SUFFIX = "__choices"
LOGGER = get_logger() LOGGER = get_logger()
@ -119,15 +121,25 @@ class Prompt(SerializerModel):
placeholder = models.TextField( placeholder = models.TextField(
blank=True, blank=True,
help_text=_( help_text=_(
"When creating a Radio Button Group or Dropdown, enable interpreting as " "Optionally provide a short hint that describes the expected input value. "
"When creating a fixed choice field, enable interpreting as "
"expression and return a list to return multiple choices." "expression and return a list to return multiple choices."
), ),
) )
initial_value = models.TextField(
blank=True,
help_text=_(
"Optionally pre-fill the input with an initial value. "
"When creating a fixed choice field, enable interpreting as "
"expression and return a list to return multiple default 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)
placeholder_expression = models.BooleanField(default=False) placeholder_expression = models.BooleanField(default=False)
initial_value_expression = models.BooleanField(default=False)
@property @property
def serializer(self) -> Type[BaseSerializer]: def serializer(self) -> Type[BaseSerializer]:
@ -148,8 +160,8 @@ class Prompt(SerializerModel):
raw_choices = self.placeholder raw_choices = self.placeholder
if self.field_key in prompt_context: if self.field_key + CHOICES_CONTEXT_SUFFIX in prompt_context:
raw_choices = prompt_context[self.field_key] raw_choices = prompt_context[self.field_key + CHOICES_CONTEXT_SUFFIX]
elif self.placeholder_expression: elif self.placeholder_expression:
evaluator = PropertyMappingEvaluator( evaluator = PropertyMappingEvaluator(
self, user, request, prompt_context=prompt_context, dry_run=dry_run self, user, request, prompt_context=prompt_context, dry_run=dry_run
@ -184,16 +196,9 @@ class Prompt(SerializerModel):
) -> str: ) -> str:
"""Get fully interpolated placeholder""" """Get fully interpolated placeholder"""
if self.type in CHOICE_FIELDS: if self.type in CHOICE_FIELDS:
# Make sure to return a valid choice as placeholder # Choice fields use the placeholder to define all valid choices.
choices = self.get_choices(prompt_context, user, request, dry_run=dry_run) # Therefore their actual placeholder is always blank
if not choices:
return "" 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
return prompt_context[self.field_key]
if self.placeholder_expression: if self.placeholder_expression:
evaluator = PropertyMappingEvaluator( evaluator = PropertyMappingEvaluator(
@ -211,6 +216,47 @@ class Prompt(SerializerModel):
raise wrapped from exc raise wrapped from exc
return self.placeholder return self.placeholder
def get_initial_value(
self,
prompt_context: dict,
user: User,
request: HttpRequest,
dry_run: Optional[bool] = False,
) -> str:
"""Get fully interpolated initial value"""
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
value = prompt_context[self.field_key]
elif self.initial_value_expression:
evaluator = PropertyMappingEvaluator(
self, user, request, prompt_context=prompt_context, dry_run=dry_run
)
try:
value = evaluator.evaluate(self.initial_value)
except Exception as exc: # pylint:disable=broad-except
wrapped = PropertyMappingExpressionException(str(exc))
LOGGER.warning(
"failed to evaluate prompt initial value",
exc=wrapped,
)
if dry_run:
raise wrapped from exc
value = self.initial_value
else:
value = self.initial_value
if self.type in CHOICE_FIELDS:
# Ensure returned value is a valid choice
choices = self.get_choices(prompt_context, user, request)
if not choices:
return ""
if value not in choices:
return choices[0]
return value
def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField: 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.""" """Get field type for Challenge and response. Choices are only valid for CHOICE_FIELDS."""
field_class = CharField field_class = CharField

View File

@ -38,6 +38,7 @@ class StagePromptSerializer(PassiveSerializer):
type = ChoiceField(choices=FieldTypes.choices) type = ChoiceField(choices=FieldTypes.choices)
required = BooleanField() required = BooleanField()
placeholder = CharField(allow_blank=True) placeholder = CharField(allow_blank=True)
initial_value = 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) choices = ListField(child=CharField(allow_blank=True), allow_empty=True, allow_null=True)
@ -76,7 +77,7 @@ class PromptChallengeResponse(ChallengeResponse):
choices = field.get_choices( choices = field.get_choices(
plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request plan.context.get(PLAN_CONTEXT_PROMPT, {}), user, self.request
) )
current = field.get_placeholder( current = field.get_initial_value(
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, choices) self.fields[field.field_key] = field.field(current, choices)
@ -197,8 +198,9 @@ class PromptStageView(ChallengeStageView):
serializers = [] serializers = []
for field in fields: for field in fields:
data = StagePromptSerializer(field).data data = StagePromptSerializer(field).data
# Ensure all choices and placeholders are str, as otherwise further in # Ensure all choices, placeholders and initial values are str, as
# we can fail serializer validation if we return some types such as bool # otherwise further in we can fail serializer validation if we return
# some types such as bool
choices = field.get_choices(context, self.get_pending_user(), self.request, dry_run) choices = field.get_choices(context, self.get_pending_user(), self.request, dry_run)
if choices: if choices:
data["choices"] = [str(choice) for choice in choices] data["choices"] = [str(choice) for choice in choices]
@ -207,6 +209,9 @@ class PromptStageView(ChallengeStageView):
data["placeholder"] = str( data["placeholder"] = str(
field.get_placeholder(context, self.get_pending_user(), self.request, dry_run) field.get_placeholder(context, self.get_pending_user(), self.request, dry_run)
) )
data["initial_value"] = str(
field.get_initial_value(context, self.get_pending_user(), self.request, dry_run)
)
serializers.append(data) serializers.append(data)
return serializers return serializers

View File

@ -22,6 +22,7 @@ from authentik.stages.prompt.stage import (
) )
# pylint: disable=too-many-public-methods
class TestPromptStage(FlowTestCase): class TestPromptStage(FlowTestCase):
"""Prompt tests""" """Prompt tests"""
@ -37,6 +38,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.USERNAME, type=FieldTypes.USERNAME,
required=True, required=True,
placeholder="USERNAME_PLACEHOLDER", placeholder="USERNAME_PLACEHOLDER",
initial_value="akuser",
) )
text_prompt = Prompt.objects.create( text_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -45,6 +47,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.TEXT, type=FieldTypes.TEXT,
required=True, required=True,
placeholder="TEXT_PLACEHOLDER", placeholder="TEXT_PLACEHOLDER",
initial_value="some text",
) )
text_area_prompt = Prompt.objects.create( text_area_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -53,6 +56,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.TEXT_AREA, type=FieldTypes.TEXT_AREA,
required=True, required=True,
placeholder="TEXT_AREA_PLACEHOLDER", placeholder="TEXT_AREA_PLACEHOLDER",
initial_value="some text",
) )
email_prompt = Prompt.objects.create( email_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -61,6 +65,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.EMAIL, type=FieldTypes.EMAIL,
required=True, required=True,
placeholder="EMAIL_PLACEHOLDER", placeholder="EMAIL_PLACEHOLDER",
initial_value="email@example.com",
) )
password_prompt = Prompt.objects.create( password_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -69,6 +74,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.PASSWORD, type=FieldTypes.PASSWORD,
required=True, required=True,
placeholder="PASSWORD_PLACEHOLDER", placeholder="PASSWORD_PLACEHOLDER",
initial_value="supersecurepassword",
) )
password2_prompt = Prompt.objects.create( password2_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -77,6 +83,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.PASSWORD, type=FieldTypes.PASSWORD,
required=True, required=True,
placeholder="PASSWORD_PLACEHOLDER", placeholder="PASSWORD_PLACEHOLDER",
initial_value="supersecurepassword",
) )
number_prompt = Prompt.objects.create( number_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -85,6 +92,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.NUMBER, type=FieldTypes.NUMBER,
required=True, required=True,
placeholder="NUMBER_PLACEHOLDER", placeholder="NUMBER_PLACEHOLDER",
initial_value="42",
) )
hidden_prompt = Prompt.objects.create( hidden_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -92,6 +100,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.HIDDEN, type=FieldTypes.HIDDEN,
required=True, required=True,
placeholder="HIDDEN_PLACEHOLDER", placeholder="HIDDEN_PLACEHOLDER",
initial_value="something idk",
) )
static_prompt = Prompt.objects.create( static_prompt = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -99,6 +108,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.STATIC, type=FieldTypes.STATIC,
required=True, required=True,
placeholder="static", placeholder="static",
initial_value="something idk",
) )
radio_button_group = Prompt.objects.create( radio_button_group = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -106,6 +116,7 @@ class TestPromptStage(FlowTestCase):
type=FieldTypes.RADIO_BUTTON_GROUP, type=FieldTypes.RADIO_BUTTON_GROUP,
required=True, required=True,
placeholder="test", placeholder="test",
initial_value="test",
) )
dropdown = Prompt.objects.create( dropdown = Prompt.objects.create(
name=generate_id(), name=generate_id(),
@ -137,9 +148,9 @@ class TestPromptStage(FlowTestCase):
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.initial_value,
static_prompt.field_key: static_prompt.placeholder, static_prompt.field_key: static_prompt.initial_value,
radio_button_group.field_key: radio_button_group.placeholder, radio_button_group.field_key: radio_button_group.initial_value,
dropdown.field_key: "", dropdown.field_key: "",
} }
@ -335,106 +346,176 @@ class TestPromptStage(FlowTestCase):
self.assertEqual( self.assertEqual(
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"]
) )
context["text_prompt_expression"] = generate_id()
self.assertEqual( def test_prompt_placeholder_does_not_take_value_from_context(self):
prompt.get_placeholder(context, self.user, self.factory.get("/")), """Test placeholder does not automatically take value from context"""
context["text_prompt_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.assertNotEqual( context["text_prompt_expression"] = generate_id()
self.assertEqual(
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): def test_prompt_initial_value(self):
"""Test placeholders and expression of choice fields""" """Test initial_value and expression"""
context = {"foo": generate_id()} context = {
"foo": generate_id(),
}
prompt: Prompt = Prompt(
field_key="text_prompt_expression",
label="TEXT_LABEL",
type=FieldTypes.TEXT,
initial_value="return prompt_context['foo']",
initial_value_expression=True,
)
self.assertEqual(
prompt.get_initial_value(context, self.user, self.factory.get("/")), context["foo"]
)
context["text_prompt_expression"] = generate_id()
self.assertEqual(
prompt.get_initial_value(context, self.user, self.factory.get("/")),
context["text_prompt_expression"],
)
self.assertNotEqual(
prompt.get_initial_value(context, self.user, self.factory.get("/")), context["foo"]
)
def test_choice_prompts_placeholder_and_initial_value_no_choices(self):
"""Test placeholder and initial value of choice fields with 0 choices"""
context = {}
# No choices - unusable (in the sense it creates an unsubmittable form)
# but valid behaviour
prompt: Prompt = Prompt( prompt: Prompt = Prompt(
field_key="fixed_choice_prompt_expression", field_key="fixed_choice_prompt_expression",
label="LABEL", label="LABEL",
type=FieldTypes.RADIO_BUTTON_GROUP, type=FieldTypes.RADIO_BUTTON_GROUP,
placeholder="return []", placeholder="return []",
placeholder_expression=True, placeholder_expression=True,
initial_value="Invalid choice",
initial_value_expression=False,
) )
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "") self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
self.assertEqual(prompt.get_initial_value(context, self.user, self.factory.get("/")), "")
self.assertEqual(prompt.get_choices(context, self.user, self.factory.get("/")), tuple()) 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"] def test_choice_prompts_placeholder_and_initial_value_single_choice(self):
"""Test placeholder and initial value of choice fields with 1 choice"""
context = {"foo": generate_id()}
# 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( prompt: Prompt = Prompt(
field_key="fixed_choice_prompt_expression", field_key="fixed_choice_prompt_expression",
label="LABEL", label="LABEL",
type=FieldTypes.DROPDOWN, type=FieldTypes.DROPDOWN,
placeholder="return [prompt_context['foo'], True, 'text']", placeholder=context["foo"],
placeholder_expression=False,
initial_value=context["foo"],
initial_value_expression=False,
)
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
self.assertEqual(
prompt.get_initial_value(context, self.user, self.factory.get("/")), context["foo"]
)
self.assertEqual(
prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
)
prompt: Prompt = Prompt(
field_key="fixed_choice_prompt_expression",
label="LABEL",
type=FieldTypes.DROPDOWN,
placeholder="return [prompt_context['foo']]",
placeholder_expression=True,
initial_value="return prompt_context['foo']",
initial_value_expression=True,
)
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
self.assertEqual(
prompt.get_initial_value(context, self.user, self.factory.get("/")), context["foo"]
)
self.assertEqual(
prompt.get_choices(context, self.user, self.factory.get("/")), (context["foo"],)
)
def test_choice_prompts_placeholder_and_initial_value_multiple_choices(self):
"""Test placeholder and initial value of choice fields with multiple choices"""
context = {}
prompt: Prompt = Prompt(
field_key="fixed_choice_prompt_expression",
label="LABEL",
type=FieldTypes.RADIO_BUTTON_GROUP,
placeholder="return ['test', True, 42]",
placeholder_expression=True, placeholder_expression=True,
) )
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
self.assertEqual( self.assertEqual(
prompt.get_placeholder(context, self.user, self.factory.get("/")), context["foo"] prompt.get_initial_value(context, self.user, self.factory.get("/")), "test"
)
self.assertEqual(
prompt.get_choices(context, self.user, self.factory.get("/")), ("test", True, 42)
)
prompt: Prompt = Prompt(
field_key="fixed_choice_prompt_expression",
label="LABEL",
type=FieldTypes.RADIO_BUTTON_GROUP,
placeholder="return ['test', True, 42]",
placeholder_expression=True,
initial_value="return True",
initial_value_expression=True,
)
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
self.assertEqual(prompt.get_initial_value(context, self.user, self.factory.get("/")), True)
self.assertEqual(
prompt.get_choices(context, self.user, self.factory.get("/")), ("test", True, 42)
)
def test_choice_prompts_placeholder_and_initial_value_from_context(self):
"""Test placeholder and initial value of choice fields with values from context"""
rand_value = generate_id()
context = {
"fixed_choice_prompt_expression": rand_value,
"fixed_choice_prompt_expression__choices": ["test", 42, rand_value],
}
prompt: Prompt = Prompt(
field_key="fixed_choice_prompt_expression",
label="LABEL",
type=FieldTypes.RADIO_BUTTON_GROUP,
)
self.assertEqual(prompt.get_placeholder(context, self.user, self.factory.get("/")), "")
self.assertEqual(
prompt.get_initial_value(context, self.user, self.factory.get("/")), rand_value
)
self.assertEqual(
prompt.get_choices(context, self.user, self.factory.get("/")), ("test", 42, rand_value)
)
def test_initial_value_not_valid_choice(self):
"""Test initial_value not a valid choice"""
context = {}
prompt: Prompt = Prompt(
field_key="choice_prompt",
label="TEXT_LABEL",
type=FieldTypes.DROPDOWN,
placeholder="choice",
initial_value="another_choice",
) )
self.assertEqual( self.assertEqual(
prompt.get_choices(context, self.user, self.factory.get("/")), prompt.get_choices(context, self.user, self.factory.get("/")),
(context["foo"], True, "text"), ("choice",),
)
context["fixed_choice_prompt_expression"] = tuple(["text", generate_id(), 2])
self.assertEqual(
prompt.get_placeholder(context, self.user, self.factory.get("/")),
"text",
) )
self.assertEqual( self.assertEqual(
prompt.get_choices(context, self.user, self.factory.get("/")), prompt.get_initial_value(context, self.user, self.factory.get("/")),
context["fixed_choice_prompt_expression"], "choice",
)
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): def test_choices_are_none_for_non_choice_fields(self):
@ -505,6 +586,8 @@ class TestPromptStage(FlowTestCase):
"type": FieldTypes.TEXT, "type": FieldTypes.TEXT,
"placeholder": 'return "Hello world"', "placeholder": 'return "Hello world"',
"placeholder_expression": True, "placeholder_expression": True,
"initial_value": 'return "Hello Hello world"',
"initial_value_expression": True,
"sub_text": "test", "sub_text": "test",
"order": 123, "order": 123,
}, },
@ -522,6 +605,7 @@ class TestPromptStage(FlowTestCase):
"type": "text", "type": "text",
"required": True, "required": True,
"placeholder": "Hello world", "placeholder": "Hello world",
"initial_value": "Hello Hello world",
"order": 123, "order": 123,
"sub_text": "test", "sub_text": "test",
"choices": None, "choices": None,

View File

@ -13,12 +13,14 @@ entries:
id: flow id: flow
- attrs: - attrs:
order: 200 order: 200
placeholder: | placeholder: Username
placeholder_expression: false
initial_value: |
try: try:
return user.username return user.username
except: except:
return '' return ''
placeholder_expression: true initial_value_expression: true
required: true required: true
type: text type: text
field_key: username field_key: username
@ -29,12 +31,14 @@ entries:
model: authentik_stages_prompt.prompt model: authentik_stages_prompt.prompt
- attrs: - attrs:
order: 201 order: 201
placeholder: | placeholder: Name
placeholder_expression: false
initial_value: |
try: try:
return user.name return user.name
except: except:
return '' return ''
placeholder_expression: true initial_value_expression: true
required: true required: true
type: text type: text
field_key: name field_key: name
@ -45,12 +49,14 @@ entries:
model: authentik_stages_prompt.prompt model: authentik_stages_prompt.prompt
- attrs: - attrs:
order: 202 order: 202
placeholder: | placeholder: Email
placeholder_expression: false
initial_value: |
try: try:
return user.email return user.email
except: except:
return '' return ''
placeholder_expression: true initial_value_expression: true
required: true required: true
type: email type: email
field_key: email field_key: email
@ -61,12 +67,14 @@ entries:
model: authentik_stages_prompt.prompt model: authentik_stages_prompt.prompt
- attrs: - attrs:
order: 203 order: 203
placeholder: | placeholder: Locale
placeholder_expression: false
initial_value: |
try: try:
return user.attributes.get("settings", {}).get("locale", "") return user.attributes.get("settings", {}).get("locale", "")
except: except:
return '' return ''
placeholder_expression: true initial_value_expression: true
required: true required: true
type: ak-locale type: ak-locale
field_key: attributes.settings.locale field_key: attributes.settings.locale

View File

@ -36862,8 +36862,14 @@ components:
type: boolean type: boolean
placeholder: placeholder:
type: string type: string
description: When creating a Radio Button Group or Dropdown, enable interpreting description: Optionally provide a short hint that describes the expected
as expression and return a list to return multiple choices. input value. When creating a fixed choice field, enable interpreting as
expression and return a list to return multiple choices.
initial_value:
type: string
description: Optionally pre-fill the input with an initial value. When creating
a fixed choice field, enable interpreting as expression and return a list
to return multiple default choices.
order: order:
type: integer type: integer
maximum: 2147483647 maximum: 2147483647
@ -36876,6 +36882,8 @@ components:
type: string type: string
placeholder_expression: placeholder_expression:
type: boolean type: boolean
initial_value_expression:
type: boolean
PatchedPromptStageRequest: PatchedPromptStageRequest:
type: object type: object
description: PromptStage Serializer description: PromptStage Serializer
@ -38034,8 +38042,14 @@ components:
type: boolean type: boolean
placeholder: placeholder:
type: string type: string
description: When creating a Radio Button Group or Dropdown, enable interpreting description: Optionally provide a short hint that describes the expected
as expression and return a list to return multiple choices. input value. When creating a fixed choice field, enable interpreting as
expression and return a list to return multiple choices.
initial_value:
type: string
description: Optionally pre-fill the input with an initial value. When creating
a fixed choice field, enable interpreting as expression and return a list
to return multiple default choices.
order: order:
type: integer type: integer
maximum: 2147483647 maximum: 2147483647
@ -38048,6 +38062,8 @@ components:
type: string type: string
placeholder_expression: placeholder_expression:
type: boolean type: boolean
initial_value_expression:
type: boolean
required: required:
- field_key - field_key
- label - label
@ -38109,8 +38125,14 @@ components:
type: boolean type: boolean
placeholder: placeholder:
type: string type: string
description: When creating a Radio Button Group or Dropdown, enable interpreting description: Optionally provide a short hint that describes the expected
as expression and return a list to return multiple choices. input value. When creating a fixed choice field, enable interpreting as
expression and return a list to return multiple choices.
initial_value:
type: string
description: Optionally pre-fill the input with an initial value. When creating
a fixed choice field, enable interpreting as expression and return a list
to return multiple default choices.
order: order:
type: integer type: integer
maximum: 2147483647 maximum: 2147483647
@ -38123,6 +38145,8 @@ components:
type: string type: string
placeholder_expression: placeholder_expression:
type: boolean type: boolean
initial_value_expression:
type: boolean
required: required:
- field_key - field_key
- label - label
@ -40267,6 +40291,8 @@ components:
type: boolean type: boolean
placeholder: placeholder:
type: string type: string
initial_value:
type: string
order: order:
type: integer type: integer
sub_text: sub_text:
@ -40279,6 +40305,7 @@ components:
required: required:
- choices - choices
- field_key - field_key
- initial_value
- label - label
- order - order
- placeholder - placeholder

View File

@ -372,8 +372,8 @@ export class PromptForm extends ModelForm<Prompt, string> {
> >
</label> </label>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${t`When checked, the placeholder will be evaluated in the same way environment as a property mapping. ${t`When checked, the placeholder will be evaluated in the same way a property mapping is.
If the evaluation failed, the placeholder itself is returned.`} If the evaluation fails, the placeholder itself is returned.`}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Placeholder`} name="placeholder"> <ak-form-element-horizontal label=${t`Placeholder`} name="placeholder">
@ -386,11 +386,41 @@ export class PromptForm extends ModelForm<Prompt, string> {
> >
</ak-codemirror> </ak-codemirror>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${t`Optionally pre-fill the input value. ${t`Optionally provide a short hint that describes the expected input value.
When creating a "Radio Button Group" or "Dropdown", enable interpreting as When creating a fixed choice field, enable interpreting as
expression and return a list to return multiple choices.`} expression and return a list to return multiple choices.`}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal name="initialValueExpression">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.initialValueExpression, false)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${t`Interpret initial value as expression`}</span
>
</label>
<p class="pf-c-form__helper-text">
${t`When checked, the initial value will be evaluated in the same way a property mapping is.
If the evaluation fails, the initial value itself is returned.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Initial value`} name="initialValue">
<ak-codemirror mode="python" value="${ifDefined(this.instance?.initialValue)}">
</ak-codemirror>
<p class="pf-c-form__helper-text">
${t`Optionally pre-fill the input with an initial value.
When creating a fixed choice field, enable interpreting as
expression and return a list to return multiple default choices.`}}
</p>
</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 <ak-codemirror
mode="htmlmixed" mode="htmlmixed"

View File

@ -48,7 +48,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
]; ];
} }
renderPromptInner(prompt: StagePrompt, placeholderAsValue: boolean): string { renderPromptInner(prompt: StagePrompt): string {
switch (prompt.type) { switch (prompt.type) {
case PromptTypeEnum.Text: case PromptTypeEnum.Text:
return `<input return `<input
@ -58,7 +58,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
autocomplete="off" autocomplete="off"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required} ?required=${prompt.required}
value="${placeholderAsValue ? prompt.placeholder : ""}">`; value="${prompt.initialValue}">`;
case PromptTypeEnum.TextArea: case PromptTypeEnum.TextArea:
return `<textarea return `<textarea
type="text" type="text"
@ -67,21 +67,23 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
autocomplete="off" autocomplete="off"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required} ?required=${prompt.required}
value="${placeholderAsValue ? prompt.placeholder : ""}">`; value="${prompt.initialValue}"">`;
case PromptTypeEnum.TextReadOnly: case PromptTypeEnum.TextReadOnly:
return `<input return `<input
type="text" type="text"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control" class="pf-c-form-control"
readonly readonly
value="${prompt.placeholder}">`; value="${prompt.initialValue}">`;
case PromptTypeEnum.TextAreaReadOnly: case PromptTypeEnum.TextAreaReadOnly:
return `<textarea return `<textarea
type="text" type="text"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control" class="pf-c-form-control"
readonly readonly
value="${prompt.placeholder}">`; value="${prompt.initialValue}">`;
case PromptTypeEnum.Username: case PromptTypeEnum.Username:
return `<input return `<input
type="text" type="text"
@ -90,7 +92,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
autocomplete="username" autocomplete="username"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required} ?required=${prompt.required}
value="${placeholderAsValue ? prompt.placeholder : ""}">`; value="${prompt.initialValue}">`;
case PromptTypeEnum.Email: case PromptTypeEnum.Email:
return `<input return `<input
type="email" type="email"
@ -98,7 +100,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
placeholder="${prompt.placeholder}" placeholder="${prompt.placeholder}"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required} ?required=${prompt.required}
value="${placeholderAsValue ? prompt.placeholder : ""}">`; value="${prompt.initialValue}">`;
case PromptTypeEnum.Password: case PromptTypeEnum.Password:
return `<input return `<input
type="password" type="password"
@ -113,46 +115,50 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}" placeholder="${prompt.placeholder}"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required}>`; ?required=${prompt.required}
value="${prompt.initialValue}">`;
case PromptTypeEnum.Date: case PromptTypeEnum.Date:
return `<input return `<input
type="date" type="date"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}" placeholder="${prompt.placeholder}"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required}>`; ?required=${prompt.required}
value="${prompt.initialValue}">`;
case PromptTypeEnum.DateTime: case PromptTypeEnum.DateTime:
return `<input return `<input
type="datetime" type="datetime"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}" placeholder="${prompt.placeholder}"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required}>`; ?required=${prompt.required}
value="${prompt.initialValue}">`;
case PromptTypeEnum.File: case PromptTypeEnum.File:
return `<input return `<input
type="file" type="file"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}" placeholder="${prompt.placeholder}"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required}>`; ?required=${prompt.required}
value="${prompt.initialValue}">`;
case PromptTypeEnum.Separator: case PromptTypeEnum.Separator:
return `<ak-divider>${prompt.placeholder}</ak-divider>`; return `<ak-divider>${prompt.placeholder}</ak-divider>`;
case PromptTypeEnum.Hidden: case PromptTypeEnum.Hidden:
return `<input return `<input
type="hidden" type="hidden"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
value="${prompt.placeholder}" value="${prompt.initialValue}"
class="pf-c-form-control" class="pf-c-form-control"
?required=${prompt.required}>`; ?required=${prompt.required}>`;
case PromptTypeEnum.Static: case PromptTypeEnum.Static:
return `<p>${prompt.placeholder}</p>`; return `<p>${prompt.initialValue}</p>`;
case PromptTypeEnum.Dropdown: case PromptTypeEnum.Dropdown:
return `<select class="pf-c-form-control" name="${prompt.fieldKey}"> return `<select class="pf-c-form-control" name="${prompt.fieldKey}">
${prompt.choices ${prompt.choices
?.map((choice) => { ?.map((choice) => {
return `<option return `<option
value="${choice}" value="${choice}"
?selected=${prompt.placeholder === choice} ?selected=${prompt.initialValue === choice}
> >
${choice} ${choice}
</option>`; </option>`;
@ -168,7 +174,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
type="radio" type="radio"
class="pf-c-check__input" class="pf-c-check__input"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
checked="${prompt.placeholder === choice}" checked="${prompt.initialValue === choice}"
required="${prompt.required}" required="${prompt.required}"
value="${choice}" value="${choice}"
/> />
@ -180,7 +186,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
); );
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.initialValue === "" ? "selected" : ""}>
${t`Auto-detect (based on your browser)`} ${t`Auto-detect (based on your browser)`}
</option> </option>
${LOCALES.filter((locale) => { ${LOCALES.filter((locale) => {
@ -195,7 +201,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
.map((locale) => { .map((locale) => {
return `<option return `<option
value=${locale.code} value=${locale.code}
${prompt.placeholder === locale.code ? "selected" : ""} ${prompt.initialValue === locale.code ? "selected" : ""}
> >
${locale.code.toUpperCase()} - ${locale.label} ${locale.code.toUpperCase()} - ${locale.label}
</option>`; </option>`;
@ -234,7 +240,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
type="checkbox" type="checkbox"
class="pf-c-check__input" class="pf-c-check__input"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
?checked=${prompt.placeholder !== ""} ?checked=${prompt.initialValue !== ""}
?required=${prompt.required} ?required=${prompt.required}
/> />
<label class="pf-c-check__label">${prompt.label}</label> <label class="pf-c-check__label">${prompt.label}</label>
@ -251,11 +257,10 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
class="pf-c-form__group" class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]} .errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
> >
${unsafeHTML(this.renderPromptInner(prompt, false))} ${unsafeHTML(this.renderPromptInner(prompt))} ${this.renderPromptHelpText(prompt)}
${this.renderPromptHelpText(prompt)}
</ak-form-element>`; </ak-form-element>`;
} }
return html` ${unsafeHTML(this.renderPromptInner(prompt, false))} return html` ${unsafeHTML(this.renderPromptInner(prompt))}
${this.renderPromptHelpText(prompt)}`; ${this.renderPromptHelpText(prompt)}`;
} }

View File

@ -17,7 +17,7 @@ export class UserSettingsPromptStage extends PromptStage {
return super.styles.concat(PFCheck); return super.styles.concat(PFCheck);
} }
renderPromptInner(prompt: StagePrompt, placeholderAsValue: boolean): string { renderPromptInner(prompt: StagePrompt): string {
switch (prompt.type) { switch (prompt.type) {
// Checkbox requires slightly different rendering here due to the use of horizontal form elements // Checkbox requires slightly different rendering here due to the use of horizontal form elements
case PromptTypeEnum.Checkbox: case PromptTypeEnum.Checkbox:
@ -25,12 +25,12 @@ export class UserSettingsPromptStage extends PromptStage {
type="checkbox" type="checkbox"
class="pf-c-check__input" class="pf-c-check__input"
name="${prompt.fieldKey}" name="${prompt.fieldKey}"
?checked=${prompt.placeholder !== ""} ?checked=${prompt.initialValue !== ""}
?required=${prompt.required} ?required=${prompt.required}
style="vertical-align: bottom" style="vertical-align: bottom"
/>`; />`;
default: default:
return super.renderPromptInner(prompt, placeholderAsValue); return super.renderPromptInner(prompt);
} }
} }
@ -47,13 +47,13 @@ export class UserSettingsPromptStage extends PromptStage {
return error.string; return error.string;
})} })}
> >
${unsafeHTML(this.renderPromptInner(prompt, true))} ${unsafeHTML(this.renderPromptInner(prompt))}
${this.renderPromptHelpText(prompt)} ${this.renderPromptHelpText(prompt)}
</ak-form-element-horizontal> </ak-form-element-horizontal>
`; `;
} }
return html` return html`
${unsafeHTML(this.renderPromptInner(prompt, true))} ${this.renderPromptHelpText(prompt)} ${unsafeHTML(this.renderPromptInner(prompt))} ${this.renderPromptHelpText(prompt)}
`; `;
} }

View File

@ -37,7 +37,7 @@ 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 initial 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. - _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:
@ -60,15 +60,34 @@ A flag which decides whether or not this field is required.
### `placeholder` ### `placeholder`
A field placeholder, shown within the input field. This field is also used by the `hidden` type as the actual value. A field placeholder, shown within the input field.
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. In the case of `Radio Button Group` and `Dropdown` prompts, this field defines all possible values (choices). 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.
For `Radio Button Group` and `Dropdown` prompts, if a key with the same name as the prompt's `field_key` and a suffix of `__choices` (`<field_key>__choices`) is present in the `prompt_context` dictionary, its value will be returned directly, even if _Interpret placeholder as expression_ is enabled.
### `initial_value`
The prompt's initial value. It can also be left empty, in which case the field will not have a pre-filled value.
With the `hidden` prompt, the initial value will also be the actual value, because the field is hidden to the user.
By default, the initial value is interpreted as-is. If you enable _Interpret initial value as expression_, the initial value
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 the default choice. When interpreted as-is, the default choice will be the initial value string. When interpreted as expression, the default choice will be the returned value. For example, `return 42` defines `42` as the default choice.
:::note
The default choice defined for any fixed choice field **must** be one of the valid choices specified in the prompt's placeholder.
:::
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. If a key with the same name as the prompt's `field_key` is present in the `prompt_context` dictionary, its value will be returned directly, even if _Interpret initial value as expression_ is enabled.
### `order` ### `order`
The numerical index of the prompt. This applies to all stages which this prompt is a part of. The numerical index of the prompt. This applies to all stages which this prompt is a part of.