web/admin: prompt preview (#5078)

* add initial prompt preview

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve error handling

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* don't flood api with requests when fields are changeed

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-03-25 22:31:48 +01:00 committed by GitHub
parent d6fa19a97f
commit 6437fbc814
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 406 additions and 55 deletions

View File

@ -21,11 +21,14 @@ PROPERTY_MAPPING_TIME = Histogram(
class PropertyMappingEvaluator(BaseEvaluator): class PropertyMappingEvaluator(BaseEvaluator):
"""Custom Evaluator that adds some different context variables.""" """Custom Evaluator that adds some different context variables."""
dry_run: bool
def __init__( def __init__(
self, self,
model: Model, model: Model,
user: Optional[User] = None, user: Optional[User] = None,
request: Optional[HttpRequest] = None, request: Optional[HttpRequest] = None,
dry_run: Optional[bool] = False,
**kwargs, **kwargs,
): ):
if hasattr(model, "name"): if hasattr(model, "name"):
@ -42,9 +45,13 @@ class PropertyMappingEvaluator(BaseEvaluator):
req.http_request = request req.http_request = request
self._context["request"] = req self._context["request"] = req
self._context.update(**kwargs) self._context.update(**kwargs)
self.dry_run = dry_run
def handle_error(self, exc: Exception, expression_source: str): def handle_error(self, exc: Exception, expression_source: str):
"""Exception Handler""" """Exception Handler"""
# For dry-run requests we don't save exceptions
if self.dry_run:
return
error_string = exception_to_string(exc) error_string = exception_to_string(exc)
event = Event.new( event = Event.new(
EventAction.PROPERTY_MAPPING_EXCEPTION, EventAction.PROPERTY_MAPPING_EXCEPTION,

View File

@ -1,11 +1,22 @@
"""Prompt Stage API Views""" """Prompt Stage API Views"""
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import CharField, ModelSerializer from rest_framework.serializers import CharField, ModelSerializer
from rest_framework.validators import UniqueValidator from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.flows.api.stages import StageSerializer from authentik.flows.api.stages import StageSerializer
from authentik.flows.challenge import ChallengeTypes, HttpChallengeResponse
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.generators import generate_id
from authentik.lib.utils.errors import exception_to_string
from authentik.stages.prompt.models import Prompt, PromptStage from authentik.stages.prompt.models import Prompt, PromptStage
from authentik.stages.prompt.stage import PromptChallenge, PromptStageView
class PromptStageSerializer(StageSerializer): class PromptStageSerializer(StageSerializer):
@ -60,3 +71,49 @@ class PromptViewSet(UsedByMixin, ModelViewSet):
serializer_class = PromptSerializer serializer_class = PromptSerializer
filterset_fields = ["field_key", "name", "label", "type", "placeholder"] filterset_fields = ["field_key", "name", "label", "type", "placeholder"]
search_fields = ["field_key", "name", "label", "type", "placeholder"] search_fields = ["field_key", "name", "label", "type", "placeholder"]
@extend_schema(
request=PromptSerializer,
responses={
200: PromptChallenge,
},
)
@action(detail=False, methods=["POST"])
def preview(self, request: Request) -> Response:
"""Preview a prompt as a challenge, just like a flow would receive"""
# Remove a couple things from the request, the serializer will fail on these
# when previewing an existing prompt
# and since we don't plan to save from this, set a random name and remove the stage
request.data["name"] = generate_id()
request.data.pop("promptstage_set", None)
# Validate data, same as a normal edit/create request
prompt = PromptSerializer(data=request.data)
prompt.is_valid(raise_exception=True)
# Convert serializer to prompt instance
prompt_model = Prompt(**prompt.validated_data)
# Convert to field challenge
try:
fields = PromptStageView(
FlowExecutorView(
plan=FlowPlan(""),
request=request._request,
),
request=request._request,
).get_prompt_challenge_fields([prompt_model], {}, dry_run=True)
except PropertyMappingExpressionException as exc:
return Response(
{
"non_field_errors": [
exception_to_string(exc),
]
},
status=400,
)
challenge = PromptChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
"fields": fields,
},
)
challenge.is_valid()
return HttpChallengeResponse(challenge)

View File

@ -39,7 +39,7 @@ class Migration(migrations.Migration):
("email", "Email: Text field with Email type."), ("email", "Email: Text field with Email type."),
( (
"password", "password",
"Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical.", "Password: Masked input, multiple inputs of this type on the same prompt need to be identical.",
), ),
("number", "Number"), ("number", "Number"),
("checkbox", "Checkbox"), ("checkbox", "Checkbox"),

View File

@ -59,9 +59,8 @@ class FieldTypes(models.TextChoices):
PASSWORD = ( PASSWORD = (
"password", # noqa # nosec "password", # noqa # nosec
_( _(
"Password: Masked input, password is validated against sources. Policies still " "Password: Masked input, multiple inputs of this type on the same prompt "
"have to be applied to this Stage. If two of these are used in the same stage, " "need to be identical."
"they are ensured to be identical."
), ),
) )
NUMBER = "number" NUMBER = "number"
@ -137,7 +136,11 @@ class Prompt(SerializerModel):
return PromptSerializer return PromptSerializer
def get_choices( def get_choices(
self, prompt_context: dict, user: User, request: HttpRequest self,
prompt_context: dict,
user: User,
request: HttpRequest,
dry_run: Optional[bool] = False,
) -> Optional[tuple[dict[str, Any]]]: ) -> Optional[tuple[dict[str, Any]]]:
"""Get fully interpolated list of choices""" """Get fully interpolated list of choices"""
if self.type not in CHOICE_FIELDS: if self.type not in CHOICE_FIELDS:
@ -148,14 +151,19 @@ class Prompt(SerializerModel):
if self.field_key in prompt_context: if self.field_key in prompt_context:
raw_choices = prompt_context[self.field_key] raw_choices = prompt_context[self.field_key]
elif self.placeholder_expression: elif self.placeholder_expression:
evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context) evaluator = PropertyMappingEvaluator(
self, user, request, prompt_context=prompt_context, dry_run=dry_run
)
try: try:
raw_choices = evaluator.evaluate(self.placeholder) raw_choices = evaluator.evaluate(self.placeholder)
except Exception as exc: # pylint:disable=broad-except except Exception as exc: # pylint:disable=broad-except
wrapped = PropertyMappingExpressionException(str(exc))
LOGGER.warning( LOGGER.warning(
"failed to evaluate prompt choices", "failed to evaluate prompt choices",
exc=PropertyMappingExpressionException(str(exc)), exc=wrapped,
) )
if dry_run:
raise wrapped from exc
if isinstance(raw_choices, (list, tuple, set)): if isinstance(raw_choices, (list, tuple, set)):
choices = raw_choices choices = raw_choices
@ -167,11 +175,17 @@ class Prompt(SerializerModel):
return tuple(choices) return tuple(choices)
def get_placeholder(self, prompt_context: dict, user: User, request: HttpRequest) -> str: def get_placeholder(
self,
prompt_context: dict,
user: User,
request: HttpRequest,
dry_run: Optional[bool] = False,
) -> 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 # Make sure to return a valid choice as placeholder
choices = self.get_choices(prompt_context, user, request) choices = self.get_choices(prompt_context, user, request, dry_run=dry_run)
if not choices: if not choices:
return "" return ""
return choices[0] return choices[0]
@ -182,14 +196,19 @@ class Prompt(SerializerModel):
return prompt_context[self.field_key] return prompt_context[self.field_key]
if self.placeholder_expression: if self.placeholder_expression:
evaluator = PropertyMappingEvaluator(self, user, request, prompt_context=prompt_context) evaluator = PropertyMappingEvaluator(
self, user, request, prompt_context=prompt_context, dry_run=dry_run
)
try: try:
return evaluator.evaluate(self.placeholder) return evaluator.evaluate(self.placeholder)
except Exception as exc: # pylint:disable=broad-except except Exception as exc: # pylint:disable=broad-except
wrapped = PropertyMappingExpressionException(str(exc))
LOGGER.warning( LOGGER.warning(
"failed to evaluate prompt placeholder", "failed to evaluate prompt placeholder",
exc=PropertyMappingExpressionException(str(exc)), exc=wrapped,
) )
if dry_run:
raise wrapped from exc
return self.placeholder return self.placeholder
def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField: def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField:

View File

@ -190,23 +190,30 @@ class PromptStageView(ChallengeStageView):
response_class = PromptChallengeResponse response_class = PromptChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge: def get_prompt_challenge_fields(self, fields: list[Prompt], context: dict, dry_run=False):
fields: list[Prompt] = list(self.executor.current_stage.fields.all().order_by("order")) """Get serializers for all fields in `fields`, using the context `context`.
If `dry_run` is set, property mapping expression errors are raised, otherwise they
are logged and events are created"""
serializers = [] serializers = []
context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
for field in fields: for field in fields:
data = StagePromptSerializer(field).data data = StagePromptSerializer(field).data
# Ensure all choices and placeholders are str, as otherwise further in # Ensure all choices and placeholders are str, as otherwise further in
# we can fail serializer validation if we return some types such as bool # 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) 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]
else: else:
data["choices"] = None data["choices"] = None
data["placeholder"] = str( data["placeholder"] = str(
field.get_placeholder(context_prompt, self.get_pending_user(), self.request) field.get_placeholder(context, self.get_pending_user(), self.request, dry_run)
) )
serializers.append(data) serializers.append(data)
return serializers
def get_challenge(self, *args, **kwargs) -> Challenge:
fields: list[Prompt] = list(self.executor.current_stage.fields.all().order_by("order"))
context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
serializers = self.get_prompt_challenge_fields(fields, context_prompt)
challenge = PromptChallenge( challenge = PromptChallenge(
data={ data={
"type": ChallengeTypes.NATIVE.value, "type": ChallengeTypes.NATIVE.value,

View File

@ -6,6 +6,7 @@ from django.urls import reverse
from rest_framework.exceptions import ErrorDetail, ValidationError from rest_framework.exceptions import ErrorDetail, ValidationError
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.challenge import ChallengeTypes
from authentik.flows.markers import StageMarker from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowStageBinding from authentik.flows.models import FlowStageBinding
from authentik.flows.planner import FlowPlan from authentik.flows.planner import FlowPlan
@ -493,6 +494,60 @@ class TestPromptStage(FlowTestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
prompt.save() prompt.save()
def test_api_preview(self):
"""Test API preview"""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:prompt-preview"),
data={
"field_key": "text_prompt_expression",
"label": "TEXT_LABEL",
"type": FieldTypes.TEXT,
"placeholder": 'return "Hello world"',
"placeholder_expression": True,
"sub_text": "test",
"order": 123,
},
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
response.content.decode(),
{
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-prompt",
"fields": [
{
"field_key": "text_prompt_expression",
"label": "TEXT_LABEL",
"type": "text",
"required": True,
"placeholder": "Hello world",
"order": 123,
"sub_text": "test",
"choices": None,
}
],
},
)
def test_api_preview_invalid_expression(self):
"""Test API preview"""
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:prompt-preview"),
data={
"field_key": "text_prompt_expression",
"label": "TEXT_LABEL",
"type": FieldTypes.TEXT,
"placeholder": "return [",
"placeholder_expression": True,
"sub_text": "test",
"order": 123,
},
)
self.assertEqual(response.status_code, 400)
self.assertIn("non_field_errors", response.content.decode())
def field_type_tester_factory(field_type: FieldTypes, required: bool): def field_type_tester_factory(field_type: FieldTypes, required: bool):
"""Test field for field_type""" """Test field for field_type"""

View File

@ -24517,7 +24517,7 @@ paths:
* `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited. * `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited.
* `username` - Username: Same as Text input, but checks for and prevents duplicate usernames. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type. * `email` - Email: Text field with Email type.
* `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical. * `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
* `number` - Number * `number` - Number
* `checkbox` - Checkbox * `checkbox` - Checkbox
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons. * `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
@ -24536,7 +24536,7 @@ paths:
* `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited. * `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited.
* `username` - Username: Same as Text input, but checks for and prevents duplicate usernames. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type. * `email` - Email: Text field with Email type.
* `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical. * `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
* `number` - Number * `number` - Number
* `checkbox` - Checkbox * `checkbox` - Checkbox
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons. * `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
@ -24784,6 +24784,39 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/stages/prompt/prompts/preview/:
post:
operationId: stages_prompt_prompts_preview_create
description: Preview a prompt as a challenge, just like a flow would receive
tags:
- stages
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PromptRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PromptChallenge'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/stages/prompt/stages/: /stages/prompt/stages/:
get: get:
operationId: stages_prompt_stages_list operationId: stages_prompt_stages_list
@ -38125,7 +38158,7 @@ components:
* `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited. * `text_area_read_only` - Text area (read-only): Multiline Text input, but cannot be edited.
* `username` - Username: Same as Text input, but checks for and prevents duplicate usernames. * `username` - Username: Same as Text input, but checks for and prevents duplicate usernames.
* `email` - Email: Text field with Email type. * `email` - Email: Text field with Email type.
* `password` - Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical. * `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
* `number` - Number * `number` - Number
* `checkbox` - Checkbox * `checkbox` - Checkbox
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons. * `radio-button-group` - Fixed choice field rendered as a group of radio buttons.

View File

@ -1,4 +1,3 @@
import { AdminInterface } from "@goauthentik/admin/AdminInterface";
import "@goauthentik/admin/admin-overview/TopApplicationsTable"; import "@goauthentik/admin/admin-overview/TopApplicationsTable";
import "@goauthentik/admin/admin-overview/cards/AdminStatusCard"; import "@goauthentik/admin/admin-overview/cards/AdminStatusCard";
import "@goauthentik/admin/admin-overview/cards/RecentEventsCard"; import "@goauthentik/admin/admin-overview/cards/RecentEventsCard";
@ -9,7 +8,8 @@ import "@goauthentik/admin/admin-overview/charts/AdminLoginAuthorizeChart";
import "@goauthentik/admin/admin-overview/charts/OutpostStatusChart"; import "@goauthentik/admin/admin-overview/charts/OutpostStatusChart";
import "@goauthentik/admin/admin-overview/charts/SyncStatusChart"; import "@goauthentik/admin/admin-overview/charts/SyncStatusChart";
import { VERSION } from "@goauthentik/common/constants"; import { VERSION } from "@goauthentik/common/constants";
import { AKElement, rootInterface } from "@goauthentik/elements/Base"; import { me } from "@goauthentik/common/users";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/PageHeader";
import "@goauthentik/elements/cards/AggregatePromiseCard"; import "@goauthentik/elements/cards/AggregatePromiseCard";
import { paramURL } from "@goauthentik/elements/router/RouterOutlet"; import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
@ -17,13 +17,15 @@ import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import PFContent from "@patternfly/patternfly/components/Content/content.css"; import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFList from "@patternfly/patternfly/components/List/list.css"; import PFList from "@patternfly/patternfly/components/List/list.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css"; import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import { SessionUser } from "@goauthentik/api";
export function versionFamily(): string { export function versionFamily(): string {
const parts = VERSION.split("."); const parts = VERSION.split(".");
parts.pop(); parts.pop();
@ -56,11 +58,17 @@ export class AdminOverviewPage extends AKElement {
]; ];
} }
@state()
user?: SessionUser;
async firstUpdated(): Promise<void> {
this.user = await me();
}
render(): TemplateResult { render(): TemplateResult {
const user = rootInterface<AdminInterface>()?.user; let name = this.user?.user.username;
let name = user?.user.username; if (this.user?.user.name) {
if (user?.user.name) { name = this.user.user.name;
name = user.user.name;
} }
return html`<ak-page-header icon="" header="" description=${t`General system status`}> return html`<ak-page-header icon="" header="" description=${t`General system status`}>
<span slot="header"> ${t`Welcome, ${name}.`} </span> <span slot="header"> ${t`Welcome, ${name}.`} </span>

View File

@ -3,32 +3,52 @@ import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import { StageHost } from "@goauthentik/flow/stages/base";
import "@goauthentik/flow/stages/prompt/PromptStage";
import { t } from "@lingui/macro"; import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { Prompt, PromptTypeEnum, StagesApi } from "@goauthentik/api"; import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
import {
Prompt,
PromptChallenge,
PromptTypeEnum,
ResponseError,
StagesApi,
ValidationErrorFromJSON,
} from "@goauthentik/api";
class PreviewStageHost implements StageHost {
challenge = undefined;
flowSlug = undefined;
loading = false;
tenant = undefined;
async submit(payload: unknown): Promise<boolean> {
this.promptForm.previewResult = payload;
return false;
}
constructor(private promptForm: PromptForm) {}
}
@customElement("ak-prompt-form") @customElement("ak-prompt-form")
export class PromptForm extends ModelForm<Prompt, string> { export class PromptForm extends ModelForm<Prompt, string> {
loadInstance(pk: string): Promise<Prompt> { @state()
return new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsRetrieve({ preview?: PromptChallenge;
promptUuid: pk,
});
}
getSuccessMessage(): string { @state()
if (this.instance) { previewError?: string[];
return t`Successfully updated prompt.`;
} else {
return t`Successfully created prompt.`;
}
}
send = (data: Prompt): Promise<Prompt> => { @state()
previewResult: unknown;
send(data: Prompt): Promise<unknown> {
if (this.instance) { if (this.instance) {
return new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsUpdate({ return new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsUpdate({
promptUuid: this.instance.pk || "", promptUuid: this.instance.pk || "",
@ -39,7 +59,68 @@ export class PromptForm extends ModelForm<Prompt, string> {
promptRequest: data, promptRequest: data,
}); });
} }
}; }
async loadInstance(pk: string): Promise<Prompt> {
const prompt = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsRetrieve({
promptUuid: pk,
});
this.preview = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsPreviewCreate({
promptRequest: prompt,
});
return prompt;
}
async refreshPreview(): Promise<void> {
const data = this.serializeForm();
if (!data) {
return;
}
try {
this.preview = await new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsPreviewCreate({
promptRequest: data,
});
this.previewError = undefined;
} catch (exc) {
const errorMessage = ValidationErrorFromJSON(
await (exc as ResponseError).response.json(),
);
this.previewError = errorMessage.nonFieldErrors;
}
}
getSuccessMessage(): string {
if (this.instance) {
return t`Successfully updated prompt.`;
} else {
return t`Successfully created prompt.`;
}
}
static get styles(): CSSResult[] {
return super.styles.concat(PFGrid, PFTitle);
}
_shouldRefresh = false;
_timer = 0;
connectedCallback(): void {
super.connectedCallback();
// Only check if we should update once a second, to prevent spamming API requests
// when many fields are edited
const minUpdateDelay = 1000;
this._timer = setInterval(() => {
if (this._shouldRefresh) {
this.refreshPreview();
this._shouldRefresh = false;
}
}, minUpdateDelay) as unknown as number;
}
disconnectedCallback(): void {
super.disconnectedCallback();
clearTimeout(this._timer);
}
renderTypes(): TemplateResult { renderTypes(): TemplateResult {
return html` return html`
@ -83,7 +164,7 @@ export class PromptForm extends ModelForm<Prompt, string> {
value=${PromptTypeEnum.Password} value=${PromptTypeEnum.Password}
?selected=${this.instance?.type === PromptTypeEnum.Password} ?selected=${this.instance?.type === PromptTypeEnum.Password}
> >
${t`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.`} ${t`Password: Masked input, multiple inputs of this type on the same prompt need to be identical.`}
</option> </option>
<option <option
value=${PromptTypeEnum.Number} value=${PromptTypeEnum.Number}
@ -155,6 +236,50 @@ export class PromptForm extends ModelForm<Prompt, string> {
} }
renderForm(): TemplateResult { renderForm(): TemplateResult {
return html`<div class="pf-l-grid pf-m-gutter">
<div class="pf-l-grid__item pf-m-6-col">${this.renderEditForm()}</div>
<div class="pf-l-grid__item pf-m-6-col">${this.renderPreview()}</div>
</div> `;
}
renderPreview(): TemplateResult {
return html`
<h3 class="pf-c-title pf-m-lg">${t`Preview`}</h3>
<div class="pf-l-grid pf-m-gutter">
<div class="pf-c-card pf-m-selectable pf-m-selected pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__body">
<ak-stage-prompt
.host=${new PreviewStageHost(this)}
.challenge=${this.preview}
>
</ak-stage-prompt>
</div>
</div>
${this.previewError
? html`
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__body">${t`Preview errors`}</div>
<div class="pf-c-card__body">
${this.previewError.map((err) => html`<pre>${err}</pre>`)}
</div>
</div>
`
: html``}
${this.previewResult
? html`
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__body">${t`Data preview`}</div>
<div class="pf-c-card__body">
<pre>${JSON.stringify(this.previewResult, undefined, 4)}</pre>
</div>
</div>
`
: html``}
</div>
`;
}
renderEditForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal"> return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name"> <ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
<input <input
@ -162,6 +287,9 @@ export class PromptForm extends ModelForm<Prompt, string> {
value="${ifDefined(this.instance?.name)}" value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control" class="pf-c-form-control"
required required
@input=${() => {
this._shouldRefresh = true;
}}
/> />
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${t`Unique name of this field, used for selecting fields in prompt stages.`} ${t`Unique name of this field, used for selecting fields in prompt stages.`}
@ -173,6 +301,9 @@ export class PromptForm extends ModelForm<Prompt, string> {
value="${ifDefined(this.instance?.fieldKey)}" value="${ifDefined(this.instance?.fieldKey)}"
class="pf-c-form-control" class="pf-c-form-control"
required required
@input=${() => {
this._shouldRefresh = true;
}}
/> />
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${t`Name of the form field, also used to store the value.`} ${t`Name of the form field, also used to store the value.`}
@ -187,11 +318,19 @@ export class PromptForm extends ModelForm<Prompt, string> {
value="${ifDefined(this.instance?.label)}" value="${ifDefined(this.instance?.label)}"
class="pf-c-form-control" class="pf-c-form-control"
required required
@input=${() => {
this._shouldRefresh = true;
}}
/> />
<p class="pf-c-form__helper-text">${t`Label shown next to/above the prompt.`}</p> <p class="pf-c-form__helper-text">${t`Label shown next to/above the prompt.`}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Type`} ?required=${true} name="type"> <ak-form-element-horizontal label=${t`Type`} ?required=${true} name="type">
<select class="pf-c-form-control"> <select
class="pf-c-form-control"
@change=${() => {
this._shouldRefresh = true;
}}
>
${this.renderTypes()} ${this.renderTypes()}
</select> </select>
</ak-form-element-horizontal> </ak-form-element-horizontal>
@ -201,6 +340,9 @@ export class PromptForm extends ModelForm<Prompt, string> {
class="pf-c-switch__input" class="pf-c-switch__input"
type="checkbox" type="checkbox"
?checked=${first(this.instance?.required, false)} ?checked=${first(this.instance?.required, false)}
@change=${() => {
this._shouldRefresh = true;
}}
/> />
<span class="pf-c-switch__toggle"> <span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon"> <span class="pf-c-switch__toggle-icon">
@ -216,6 +358,9 @@ export class PromptForm extends ModelForm<Prompt, string> {
class="pf-c-switch__input" class="pf-c-switch__input"
type="checkbox" type="checkbox"
?checked=${first(this.instance?.placeholderExpression, false)} ?checked=${first(this.instance?.placeholderExpression, false)}
@change=${() => {
this._shouldRefresh = true;
}}
/> />
<span class="pf-c-switch__toggle"> <span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon"> <span class="pf-c-switch__toggle-icon">
@ -232,7 +377,13 @@ export class PromptForm extends ModelForm<Prompt, string> {
</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">
<ak-codemirror mode="python" value="${ifDefined(this.instance?.placeholder)}"> <ak-codemirror
mode="python"
value="${ifDefined(this.instance?.placeholder)}"
@change=${() => {
this._shouldRefresh = true;
}}
>
</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 pre-fill the input value.
@ -241,7 +392,13 @@ export class PromptForm extends ModelForm<Prompt, string> {
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Help text`} name="subText"> <ak-form-element-horizontal label=${t`Help text`} name="subText">
<ak-codemirror mode="htmlmixed" value="${ifDefined(this.instance?.subText)}"> <ak-codemirror
mode="htmlmixed"
value="${ifDefined(this.instance?.subText)}"
@change=${() => {
this._shouldRefresh = true;
}}
>
</ak-codemirror> </ak-codemirror>
<p class="pf-c-form__helper-text">${t`Any HTML can be used.`}</p> <p class="pf-c-form__helper-text">${t`Any HTML can be used.`}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>

View File

@ -87,7 +87,7 @@ export class PromptListPage extends TablePage<Prompt> {
html`${item.promptstageSet?.map((stage) => { html`${item.promptstageSet?.map((stage) => {
return html`<li>${stage.name}</li>`; return html`<li>${stage.name}</li>`;
})}`, })}`,
html` <ak-forms-modal> html`<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span> <span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update Prompt`} </span> <span slot="header"> ${t`Update Prompt`} </span>
<ak-prompt-form slot="form" .instancePk=${item.pk}> </ak-prompt-form> <ak-prompt-form slot="form" .instancePk=${item.pk}> </ak-prompt-form>

View File

@ -12,6 +12,7 @@ import {
import * as yamlMode from "@codemirror/legacy-modes/mode/yaml"; import * as yamlMode from "@codemirror/legacy-modes/mode/yaml";
import { Compartment, EditorState, Extension } from "@codemirror/state"; import { Compartment, EditorState, Extension } from "@codemirror/state";
import { oneDark } from "@codemirror/theme-one-dark"; import { oneDark } from "@codemirror/theme-one-dark";
import { ViewUpdate } from "@codemirror/view";
import { EditorView, drawSelection, keymap, lineNumbers } from "@codemirror/view"; import { EditorView, drawSelection, keymap, lineNumbers } from "@codemirror/view";
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
@ -148,6 +149,16 @@ export class CodeMirrorTextarea<T> extends AKElement {
lineNumbers(), lineNumbers(),
drawSelection(), drawSelection(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.updateListener.of((v: ViewUpdate) => {
if (!v.docChanged) {
return;
}
this.dispatchEvent(
new CustomEvent("change", {
detail: v,
}),
);
}),
EditorState.readOnly.of(this.readOnly), EditorState.readOnly.of(this.readOnly),
EditorState.tabSize.of(2), EditorState.tabSize.of(2),
this.theme.of(this.activeTheme === UiThemeEnum.Dark ? this.themeDark : this.themeLight), this.theme.of(this.activeTheme === UiThemeEnum.Dark ? this.themeDark : this.themeLight),

View File

@ -18,7 +18,7 @@ import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-gro
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css"; import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { ResponseError, ValidationError } from "@goauthentik/api"; import { ResponseError, ValidationError, ValidationErrorFromJSON } from "@goauthentik/api";
export class PreventFormSubmit { export class PreventFormSubmit {
// Stub class which can be returned by form elements to prevent the form from submitting // Stub class which can be returned by form elements to prevent the form from submitting
@ -102,9 +102,6 @@ export abstract class Form<T> extends AKElement {
}); });
} }
/**
* Reset the inner iron-form
*/
resetForm(): void { resetForm(): void {
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form"); const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
form?.reset(); form?.reset();
@ -235,7 +232,7 @@ export abstract class Form<T> extends AKElement {
if (ex instanceof ResponseError) { if (ex instanceof ResponseError) {
let msg = ex.response.statusText; let msg = ex.response.statusText;
if (ex.response.status > 399 && ex.response.status < 500) { if (ex.response.status > 399 && ex.response.status < 500) {
const errorMessage: ValidationError = await ex.response.json(); const errorMessage = ValidationErrorFromJSON(await ex.response.json());
if (!errorMessage) return errorMessage; if (!errorMessage) return errorMessage;
if (errorMessage instanceof Error) { if (errorMessage instanceof Error) {
throw errorMessage; throw errorMessage;
@ -257,8 +254,8 @@ export abstract class Form<T> extends AKElement {
element.invalid = false; element.invalid = false;
} }
}); });
if ("non_field_errors" in errorMessage) { if (errorMessage.nonFieldErrors) {
this.nonFieldErrors = errorMessage["non_field_errors"]; this.nonFieldErrors = errorMessage.nonFieldErrors;
} }
// Only change the message when we have `detail`. // Only change the message when we have `detail`.
// Everything else is handled in the form. // Everything else is handled in the form.