stages/prompt: add basic file field (#3156)
add basic file field Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
713337130b
commit
49cce6a968
|
@ -0,0 +1,48 @@
|
||||||
|
# Generated by Django 4.0.5 on 2022-06-26 21:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_stages_prompt", "0007_prompt_placeholder_expression"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="prompt",
|
||||||
|
name="type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("text", "Text: Simple Text input"),
|
||||||
|
(
|
||||||
|
"text_read_only",
|
||||||
|
"Text (read-only): Simple Text input, but cannot be edited.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"username",
|
||||||
|
"Username: Same as Text input, but checks for and prevents duplicate usernames.",
|
||||||
|
),
|
||||||
|
("email", "Email: Text field with Email type."),
|
||||||
|
(
|
||||||
|
"password",
|
||||||
|
"Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.",
|
||||||
|
),
|
||||||
|
("number", "Number"),
|
||||||
|
("checkbox", "Checkbox"),
|
||||||
|
("date", "Date"),
|
||||||
|
("date-time", "Date Time"),
|
||||||
|
(
|
||||||
|
"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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,11 +1,14 @@
|
||||||
"""prompt models"""
|
"""prompt models"""
|
||||||
|
from base64 import b64decode
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
from rest_framework.exceptions import ValidationError
|
||||||
from rest_framework.fields import (
|
from rest_framework.fields import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
CharField,
|
CharField,
|
||||||
|
@ -32,7 +35,7 @@ LOGGER = get_logger()
|
||||||
class FieldTypes(models.TextChoices):
|
class FieldTypes(models.TextChoices):
|
||||||
"""Field types an Prompt can be"""
|
"""Field types an Prompt can be"""
|
||||||
|
|
||||||
# update website/docs/flow/stages/prompt.index.md
|
# update website/docs/flow/stages/prompt/index.md
|
||||||
|
|
||||||
# Simple text field
|
# Simple text field
|
||||||
TEXT = "text", _("Text: Simple Text input")
|
TEXT = "text", _("Text: Simple Text input")
|
||||||
|
@ -61,6 +64,14 @@ class FieldTypes(models.TextChoices):
|
||||||
DATE = "date"
|
DATE = "date"
|
||||||
DATE_TIME = "date-time"
|
DATE_TIME = "date-time"
|
||||||
|
|
||||||
|
FILE = (
|
||||||
|
"file",
|
||||||
|
_(
|
||||||
|
"File: File upload for arbitrary files. File content will be available in flow "
|
||||||
|
"context as data-URI"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
SEPARATOR = "separator", _("Separator: Static Separator Line")
|
SEPARATOR = "separator", _("Separator: Static Separator Line")
|
||||||
HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
|
HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
|
||||||
STATIC = "static", _("Static: Static value, displayed as-is.")
|
STATIC = "static", _("Static: Static value, displayed as-is.")
|
||||||
|
@ -68,6 +79,21 @@ class FieldTypes(models.TextChoices):
|
||||||
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
|
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
|
||||||
|
|
||||||
|
|
||||||
|
class InlineFileField(CharField):
|
||||||
|
"""Field for inline data-URI base64 encoded files"""
|
||||||
|
|
||||||
|
def to_internal_value(self, data: str):
|
||||||
|
uri = urlparse(data)
|
||||||
|
if uri.scheme != "data":
|
||||||
|
raise ValidationError("Invalid scheme")
|
||||||
|
header, encoded = uri.path.split(",", 1)
|
||||||
|
_mime, _, enc = header.partition(";")
|
||||||
|
if enc != "base64":
|
||||||
|
raise ValidationError("Invalid encoding")
|
||||||
|
data = b64decode(encoded.encode()).decode()
|
||||||
|
return super().to_internal_value(data)
|
||||||
|
|
||||||
|
|
||||||
class Prompt(SerializerModel):
|
class Prompt(SerializerModel):
|
||||||
"""Single Prompt, part of a prompt stage."""
|
"""Single Prompt, part of a prompt stage."""
|
||||||
|
|
||||||
|
@ -134,6 +160,8 @@ class Prompt(SerializerModel):
|
||||||
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:
|
||||||
|
field_class = InlineFileField
|
||||||
|
|
||||||
if self.type == FieldTypes.SEPARATOR:
|
if self.type == FieldTypes.SEPARATOR:
|
||||||
kwargs["required"] = False
|
kwargs["required"] = False
|
||||||
|
|
|
@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.test import RequestFactory
|
from django.test import RequestFactory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.exceptions import ErrorDetail
|
from rest_framework.exceptions import ErrorDetail, ValidationError
|
||||||
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user
|
from authentik.core.tests.utils import create_test_admin_user
|
||||||
from authentik.flows.markers import StageMarker
|
from authentik.flows.markers import StageMarker
|
||||||
|
@ -13,7 +13,7 @@ from authentik.flows.tests import FlowTestCase
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.policies.expression.models import ExpressionPolicy
|
from authentik.policies.expression.models import ExpressionPolicy
|
||||||
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
from authentik.stages.prompt.models import FieldTypes, InlineFileField, Prompt, PromptStage
|
||||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT, PromptChallengeResponse
|
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT, PromptChallengeResponse
|
||||||
|
|
||||||
|
|
||||||
|
@ -110,6 +110,17 @@ class TestPromptStage(FlowTestCase):
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
def test_inline_file_field(self):
|
||||||
|
"""test InlineFileField"""
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
InlineFileField().to_internal_value("foo")
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
InlineFileField().to_internal_value("data:foo/bar;foo,qwer")
|
||||||
|
self.assertEqual(
|
||||||
|
InlineFileField().to_internal_value("data:mine/type;base64,Zm9v"),
|
||||||
|
"foo",
|
||||||
|
)
|
||||||
|
|
||||||
def test_render(self):
|
def test_render(self):
|
||||||
"""Test render of form, check if all prompts are rendered correctly"""
|
"""Test render of form, check if all prompts are rendered correctly"""
|
||||||
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()])
|
||||||
|
|
|
@ -17741,6 +17741,7 @@ paths:
|
||||||
- date
|
- date
|
||||||
- date-time
|
- date-time
|
||||||
- email
|
- email
|
||||||
|
- file
|
||||||
- hidden
|
- hidden
|
||||||
- number
|
- number
|
||||||
- password
|
- password
|
||||||
|
@ -29242,6 +29243,7 @@ components:
|
||||||
- checkbox
|
- checkbox
|
||||||
- date
|
- date
|
||||||
- date-time
|
- date-time
|
||||||
|
- file
|
||||||
- separator
|
- separator
|
||||||
- hidden
|
- hidden
|
||||||
- static
|
- static
|
||||||
|
|
|
@ -12,6 +12,17 @@ export interface StageHost {
|
||||||
readonly tenant: CurrentTenant;
|
readonly tenant: CurrentTenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readFileAsync(file: Blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
resolve(reader.result);
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class BaseStage<Tin, Tout> extends LitElement {
|
export class BaseStage<Tin, Tout> extends LitElement {
|
||||||
host!: StageHost;
|
host!: StageHost;
|
||||||
|
|
||||||
|
@ -24,7 +35,14 @@ export class BaseStage<Tin, Tout> extends LitElement {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
} = {};
|
} = {};
|
||||||
const form = new FormData(this.shadowRoot?.querySelector("form") || undefined);
|
const form = new FormData(this.shadowRoot?.querySelector("form") || undefined);
|
||||||
form.forEach((value, key) => (object[key] = value));
|
|
||||||
|
for await (const [key, value] of form.entries()) {
|
||||||
|
if (value instanceof Blob) {
|
||||||
|
object[key] = await readFileAsync(value);
|
||||||
|
} else {
|
||||||
|
object[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.host?.submit(object as unknown as Tout).then((successful) => {
|
return this.host?.submit(object as unknown as Tout).then((successful) => {
|
||||||
if (successful) {
|
if (successful) {
|
||||||
this.cleanup();
|
this.cleanup();
|
||||||
|
|
|
@ -96,6 +96,13 @@ 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}>`;
|
||||||
|
case PromptTypeEnum.File:
|
||||||
|
return `<input
|
||||||
|
type="file"
|
||||||
|
name="${prompt.fieldKey}"
|
||||||
|
placeholder="${prompt.placeholder}"
|
||||||
|
class="pf-c-form-control"
|
||||||
|
?required=${prompt.required}>`;
|
||||||
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:
|
||||||
|
@ -133,7 +140,7 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
|
||||||
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
|
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldRenderInWrapper(prompt: StagePrompt): bool {
|
shouldRenderInWrapper(prompt: StagePrompt): boolean {
|
||||||
// Special types that aren't rendered in a wrapper
|
// Special types that aren't rendered in a wrapper
|
||||||
if (
|
if (
|
||||||
prompt.type === PromptTypeEnum.Static ||
|
prompt.type === PromptTypeEnum.Static ||
|
||||||
|
|
|
@ -97,6 +97,12 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
||||||
>
|
>
|
||||||
${t`Date Time`}
|
${t`Date Time`}
|
||||||
</option>
|
</option>
|
||||||
|
<option
|
||||||
|
value=${PromptTypeEnum.File}
|
||||||
|
?selected=${this.instance?.type === PromptTypeEnum.File}
|
||||||
|
>
|
||||||
|
${t`File`}
|
||||||
|
</option>
|
||||||
<option
|
<option
|
||||||
value=${PromptTypeEnum.Separator}
|
value=${PromptTypeEnum.Separator}
|
||||||
?selected=${this.instance?.type === PromptTypeEnum.Separator}
|
?selected=${this.instance?.type === PromptTypeEnum.Separator}
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"ES2020",
|
"ES2020",
|
||||||
"ESNext",
|
"ESNext",
|
||||||
"DOM",
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
"WebWorker"
|
"WebWorker"
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
|
|
@ -8,21 +8,22 @@ 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. |
|
||||||
| 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. |
|
||||||
| 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 |
|
||||||
| Separator | Passive element to group surrounding elements |
|
| File | Allow users to upload a file, which will be available as base64-encoded data in the flow . |
|
||||||
| Hidden | Hidden input field. Allows for the pre-setting of default values. |
|
| Separator | Passive element to group surrounding elements |
|
||||||
| Static | Display arbitrary value as is |
|
| Hidden | Hidden input field. Allows for the pre-setting of default values. |
|
||||||
| authentik: Locale | Display a list of all locales authentik supports. |
|
| Static | Display arbitrary value as is |
|
||||||
|
| authentik: Locale | Display a list of all locales authentik supports. |
|
||||||
|
|
||||||
Some types have special behaviors:
|
Some types have special behaviors:
|
||||||
|
|
||||||
|
|
Reference in New Issue