Repository: goauthentik/authentik
Pull request: stages/prompt: Add Radio Button Group, Dropdown and Text Area prompt fields
<!--
👋 Hello there! Welcome.

Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/main/CONTRIBUTING.md#how-can-i-contribute).
-->

# Details
* **Does this resolve an issue?**
Resolves #4352
Resolves #2521
Resolves #4841 

## Changes
### New Features
* Adds radio-button and dropdown and text-area prompts

### Breaking Changes
N/A

## Additional
N/A


Problem statement:
Additional prompt field types
**Is your feature request related to a problem? Please describe.**
Not really.

**Describe the solution you'd like**
Some additional field types would make the user experience nicer and allow for new possibilities, in particular:

- List with options for checkboxes, dropdown, radio buttons, or a static list (`ul` or `ol`) that looks nice.
- Long text (textarea).

**Describe alternatives you've considered**
For the static list, I used a placeholder expression to generate a comma separated list.

**Additional context**
The list options are particularly useful when you really need to limit the user's choices. For example, I would like my users to be able to choose their login shell between `/bin/zsh` and `/bin/bash` but not any arbitrary path. For SSH keys, the single line text field creates some usability challenges.


Add radio prompt/checkbox groups
**Is your feature request related to a problem? Please describe.**
Add an ability to select only one out of multiple options. This will allow to create branches in flows. For example in a recovery flow a user can select in a prompt stage if they want to restore a forgotten password or a lost MFA method. Then later flow stages and logic can take into account the selection.

Currently not possible as checkbox controls are not able to be grouped together and no radio controls exists.

**Describe the solution you'd like**
Add a new radio button prompt or add ability to group checkbox prompts together

**Describe alternatives you've considered**
There isn't one I think

**Additional context**
N/A


Add dynamic dropdowns to Promt Stage
I have written a custom Promt Stage which reminds me if i forgot to enable my time tracking on login. One feature missing is to select the Project i am working on.
It would be awesome if the promt stage would have a additional dropdown type which can be filled dynamicly (for example with the context dict).

Hints from issue discussion:
After recreating my recovery flow, I now have a need for a dropdown, combobox, or similar, so that users can pick what MFA they want to recover with. I currently use strings emitted with a policy as well.
Related: #2521

Commit message: Add text-area prompts

Patch to review:
diff --git a/authentik/policies/password/tests/test_flows.py b/authentik/policies/password/tests/test_flows.py
index 42e1ae5587b2..bdf661b46a2d 100644
--- a/authentik/policies/password/tests/test_flows.py
+++ b/authentik/policies/password/tests/test_flows.py
@@ -54,6 +54,7 @@ def test_prompt_data(self):
             component="ak-stage-prompt",
             fields=[
                 {
+                    "choices": None,
                     "field_key": "password",
                     "label": "PASSWORD_LABEL",
                     "order": 0,
diff --git a/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py b/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py
new file mode 100644
index 000000000000..906369440cb4
--- /dev/null
+++ b/authentik/stages/prompt/migrations/0010_alter_prompt_placeholder_alter_prompt_type.py
@@ -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,
+            ),
+        ),
+    ]
diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py
index 62be9df1673f..d0d8c3fa0c66 100644
--- a/authentik/stages/prompt/models.py
+++ b/authentik/stages/prompt/models.py
@@ -11,6 +11,7 @@
 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 @@ def serializer(self) -> Type[BaseSerializer]:
 
         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)):
+            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 choices:
+                return choices[0]
+            return ""
+
         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 @@ def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest
                 )
         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 @@ def field(self, default: Optional[Any]) -> CharField:
         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"] = ""
diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py
index 915cc695dfa0..d0d723342b16 100644
--- a/authentik/stages/prompt/stage.py
+++ b/authentik/stages/prompt/stage.py
@@ -7,7 +7,14 @@
 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 @@ def __init__(self, *args, **kwargs):
         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 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
         # 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 @@ def get_challenge(self, *args, **kwargs) -> Challenge:
         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(
diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py
index 4f593d1b5b5b..4a7453ea02ea 100644
--- a/authentik/stages/prompt/tests.py
+++ b/authentik/stages/prompt/tests.py
@@ -45,6 +45,14 @@ def setUp(self):
             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 @@ def setUp(self):
             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 @@ def setUp(self):
                 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 @@ def test_invalid_username(self):
             {"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 @@ def test_prompt_placeholder(self):
             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 = {}
diff --git a/schema.yml b/schema.yml
index a5e7e0e0d2d2..07f7d3c4ebd1 100644
--- a/schema.yml
+++ b/schema.yml
@@ -23279,14 +23279,18 @@ 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
       tags:
@@ -34549,6 +34553,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
@@ -35572,6 +35578,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
@@ -35645,6 +35653,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
@@ -35734,12 +35744,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
@@ -37290,7 +37304,13 @@ components:
           type: integer
         sub_text:
           type: string
+        choices:
+          type: array
+          items:
+            type: string
+          nullable: true
       required:
+      - choices
       - field_key
       - label
       - order
diff --git a/web/src/admin/stages/prompt/PromptForm.ts b/web/src/admin/stages/prompt/PromptForm.ts
index efbbf9edb85a..9c4c9de53a0b 100644
--- a/web/src/admin/stages/prompt/PromptForm.ts
+++ b/web/src/admin/stages/prompt/PromptForm.ts
@@ -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)}">
diff --git a/web/src/flow/stages/prompt/PromptStage.ts b/web/src/flow/stages/prompt/PromptStage.ts
index dbfee6a51a4e..53c46180ce5e 100644
--- a/web/src/flow/stages/prompt/PromptStage.ts
+++ b/web/src/flow/stages/prompt/PromptStage.ts
@@ -43,6 +43,16 @@ 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"
+                    style="min-height: 4em; max-height: 15em; resize: vertical;"
+                    ?required=${prompt.required}
+                    value="${placeholderAsValue ? prompt.placeholder : ""}">`;
             case PromptTypeEnum.TextReadOnly:
                 return `<input
                     type="text"
@@ -50,6 +60,14 @@ 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"
+                    style="min-height: 4em; max-height: 15em; resize: vertical;"
+                    readonly
+                    value="${prompt.placeholder}">`;
             case PromptTypeEnum.Username:
                 return `<input
                     type="text"
@@ -114,6 +132,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" : ""}>
diff --git a/website/docs/flow/stages/prompt/index.md b/website/docs/flow/stages/prompt/index.md
index e1c829ccf082..c6aca61cc5d0 100644
--- a/website/docs/flow/stages/prompt/index.md
+++ b/website/docs/flow/stages/prompt/index.md
@@ -8,28 +8,37 @@ 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.                                                       |
-| 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.                                                                           |
-| 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 . |
-| Separator         | Passive element to group surrounding elements                                              |
-| Hidden            | Hidden input field. Allows for the pre-setting of default values.                          |
-| Static            | Display arbitrary value as is                                                              |
-| authentik: Locale | Display a list of all locales authentik supports.                                          |
+| 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 . |
+| Separator             | Passive element to group surrounding elements                                              |
+| Hidden                | Hidden input field. Allows for the pre-setting of default values.                          |
+| 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`


Code review comments:
diff_hunk: @@ -115,8 +136,46 @@ def serializer(self) -> Type[BaseSerializer]:
 
         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)):
+            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 choices:
+                return choices[0]
+            return ""
path: authentik/stages/prompt/models.py
text: @user1:
```suggestion
            if not choices:
                return ""
            return choices[0]
```

imo reads a bit better like this, but fine with either one

diff_hunk: @@ -43,13 +43,31 @@ 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"
+                    style="min-height: 4em; max-height: 15em; resize: vertical;"
path: web/src/flow/stages/prompt/PromptStage.ts
text: @user1:
these styles should be set CSS instead of inline, also a very minor thing

@author:
I am not sure how to do this, frontend has never been my strong suit. Can you please show me?

@user1:
Yeah of course, see https://github.com/goauthentik/authentik/blob/63e3f6545bd0d3cf036e56d93d30add9f57ceaa0/web/src/flow/FormStatic.ts#L22

Add the import `import { css } from "lit";`

and then in the styles at the top use the
```
css``
```
template tag to add css for the `textarea {}` selector

diff_hunk: @@ -115,8 +136,46 @@ def serializer(self) -> Type[BaseSerializer]:
 
         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)):
path: authentik/stages/prompt/models.py
text: @user1:
```suggestion
        if isinstance(raw_choices, (list, tuple, set)):
```