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:
parent
d6fa19a97f
commit
6437fbc814
|
@ -21,11 +21,14 @@ PROPERTY_MAPPING_TIME = Histogram(
|
|||
class PropertyMappingEvaluator(BaseEvaluator):
|
||||
"""Custom Evaluator that adds some different context variables."""
|
||||
|
||||
dry_run: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Model,
|
||||
user: Optional[User] = None,
|
||||
request: Optional[HttpRequest] = None,
|
||||
dry_run: Optional[bool] = False,
|
||||
**kwargs,
|
||||
):
|
||||
if hasattr(model, "name"):
|
||||
|
@ -42,9 +45,13 @@ class PropertyMappingEvaluator(BaseEvaluator):
|
|||
req.http_request = request
|
||||
self._context["request"] = req
|
||||
self._context.update(**kwargs)
|
||||
self.dry_run = dry_run
|
||||
|
||||
def handle_error(self, exc: Exception, expression_source: str):
|
||||
"""Exception Handler"""
|
||||
# For dry-run requests we don't save exceptions
|
||||
if self.dry_run:
|
||||
return
|
||||
error_string = exception_to_string(exc)
|
||||
event = Event.new(
|
||||
EventAction.PROPERTY_MAPPING_EXCEPTION,
|
||||
|
|
|
@ -1,11 +1,22 @@
|
|||
"""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.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
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.stage import PromptChallenge, PromptStageView
|
||||
|
||||
|
||||
class PromptStageSerializer(StageSerializer):
|
||||
|
@ -60,3 +71,49 @@ class PromptViewSet(UsedByMixin, ModelViewSet):
|
|||
serializer_class = PromptSerializer
|
||||
filterset_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)
|
||||
|
|
|
@ -39,7 +39,7 @@ class Migration(migrations.Migration):
|
|||
("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: Masked input, multiple inputs of this type on the same prompt need to be identical.",
|
||||
),
|
||||
("number", "Number"),
|
||||
("checkbox", "Checkbox"),
|
||||
|
|
|
@ -59,9 +59,8 @@ class FieldTypes(models.TextChoices):
|
|||
PASSWORD = (
|
||||
"password", # noqa # nosec
|
||||
_(
|
||||
"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"
|
||||
|
@ -137,7 +136,11 @@ class Prompt(SerializerModel):
|
|||
return PromptSerializer
|
||||
|
||||
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]]]:
|
||||
"""Get fully interpolated list of choices"""
|
||||
if self.type not in CHOICE_FIELDS:
|
||||
|
@ -148,14 +151,19 @@ class Prompt(SerializerModel):
|
|||
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)
|
||||
evaluator = PropertyMappingEvaluator(
|
||||
self, user, request, prompt_context=prompt_context, dry_run=dry_run
|
||||
)
|
||||
try:
|
||||
raw_choices = evaluator.evaluate(self.placeholder)
|
||||
except Exception as exc: # pylint:disable=broad-except
|
||||
wrapped = PropertyMappingExpressionException(str(exc))
|
||||
LOGGER.warning(
|
||||
"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)):
|
||||
choices = raw_choices
|
||||
|
@ -167,11 +175,17 @@ class Prompt(SerializerModel):
|
|||
|
||||
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"""
|
||||
if self.type in CHOICE_FIELDS:
|
||||
# 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:
|
||||
return ""
|
||||
return choices[0]
|
||||
|
@ -182,14 +196,19 @@ class Prompt(SerializerModel):
|
|||
return prompt_context[self.field_key]
|
||||
|
||||
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:
|
||||
return evaluator.evaluate(self.placeholder)
|
||||
except Exception as exc: # pylint:disable=broad-except
|
||||
wrapped = PropertyMappingExpressionException(str(exc))
|
||||
LOGGER.warning(
|
||||
"failed to evaluate prompt placeholder",
|
||||
exc=PropertyMappingExpressionException(str(exc)),
|
||||
exc=wrapped,
|
||||
)
|
||||
if dry_run:
|
||||
raise wrapped from exc
|
||||
return self.placeholder
|
||||
|
||||
def field(self, default: Optional[Any], choices: Optional[list[Any]] = None) -> CharField:
|
||||
|
|
|
@ -190,23 +190,30 @@ class PromptStageView(ChallengeStageView):
|
|||
|
||||
response_class = PromptChallengeResponse
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
fields: list[Prompt] = list(self.executor.current_stage.fields.all().order_by("order"))
|
||||
def get_prompt_challenge_fields(self, fields: list[Prompt], context: dict, dry_run=False):
|
||||
"""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 = []
|
||||
context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
|
||||
for field in fields:
|
||||
data = StagePromptSerializer(field).data
|
||||
# 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)
|
||||
choices = field.get_choices(context, self.get_pending_user(), self.request, dry_run)
|
||||
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)
|
||||
field.get_placeholder(context, self.get_pending_user(), self.request, dry_run)
|
||||
)
|
||||
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(
|
||||
data={
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.urls import reverse
|
|||
from rest_framework.exceptions import ErrorDetail, ValidationError
|
||||
|
||||
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.models import FlowStageBinding
|
||||
from authentik.flows.planner import FlowPlan
|
||||
|
@ -493,6 +494,60 @@ class TestPromptStage(FlowTestCase):
|
|||
with self.assertRaises(ValueError):
|
||||
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):
|
||||
"""Test field for field_type"""
|
||||
|
|
39
schema.yml
39
schema.yml
|
@ -24517,7 +24517,7 @@ paths:
|
|||
* `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.
|
||||
* `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
|
||||
* `number` - Number
|
||||
* `checkbox` - Checkbox
|
||||
* `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.
|
||||
* `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.
|
||||
* `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
|
||||
* `number` - Number
|
||||
* `checkbox` - Checkbox
|
||||
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
|
||||
|
@ -24784,6 +24784,39 @@ paths:
|
|||
schema:
|
||||
$ref: '#/components/schemas/GenericError'
|
||||
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/:
|
||||
get:
|
||||
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.
|
||||
* `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.
|
||||
* `password` - Password: Masked input, multiple inputs of this type on the same prompt need to be identical.
|
||||
* `number` - Number
|
||||
* `checkbox` - Checkbox
|
||||
* `radio-button-group` - Fixed choice field rendered as a group of radio buttons.
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { AdminInterface } from "@goauthentik/admin/AdminInterface";
|
||||
import "@goauthentik/admin/admin-overview/TopApplicationsTable";
|
||||
import "@goauthentik/admin/admin-overview/cards/AdminStatusCard";
|
||||
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/SyncStatusChart";
|
||||
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/cards/AggregatePromiseCard";
|
||||
import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
|
||||
|
@ -17,13 +17,15 @@ import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
|
|||
import { t } from "@lingui/macro";
|
||||
|
||||
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 PFList from "@patternfly/patternfly/components/List/list.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
||||
|
||||
import { SessionUser } from "@goauthentik/api";
|
||||
|
||||
export function versionFamily(): string {
|
||||
const parts = VERSION.split(".");
|
||||
parts.pop();
|
||||
|
@ -56,11 +58,17 @@ export class AdminOverviewPage extends AKElement {
|
|||
];
|
||||
}
|
||||
|
||||
@state()
|
||||
user?: SessionUser;
|
||||
|
||||
async firstUpdated(): Promise<void> {
|
||||
this.user = await me();
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
const user = rootInterface<AdminInterface>()?.user;
|
||||
let name = user?.user.username;
|
||||
if (user?.user.name) {
|
||||
name = user.user.name;
|
||||
let name = this.user?.user.username;
|
||||
if (this.user?.user.name) {
|
||||
name = this.user.user.name;
|
||||
}
|
||||
return html`<ak-page-header icon="" header="" description=${t`General system status`}>
|
||||
<span slot="header"> ${t`Welcome, ${name}.`} </span>
|
||||
|
|
|
@ -3,32 +3,52 @@ import { first } from "@goauthentik/common/utils";
|
|||
import "@goauthentik/elements/CodeMirror";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
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 { TemplateResult, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.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")
|
||||
export class PromptForm extends ModelForm<Prompt, string> {
|
||||
loadInstance(pk: string): Promise<Prompt> {
|
||||
return new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsRetrieve({
|
||||
promptUuid: pk,
|
||||
});
|
||||
}
|
||||
@state()
|
||||
preview?: PromptChallenge;
|
||||
|
||||
getSuccessMessage(): string {
|
||||
if (this.instance) {
|
||||
return t`Successfully updated prompt.`;
|
||||
} else {
|
||||
return t`Successfully created prompt.`;
|
||||
}
|
||||
}
|
||||
@state()
|
||||
previewError?: string[];
|
||||
|
||||
send = (data: Prompt): Promise<Prompt> => {
|
||||
@state()
|
||||
previewResult: unknown;
|
||||
|
||||
send(data: Prompt): Promise<unknown> {
|
||||
if (this.instance) {
|
||||
return new StagesApi(DEFAULT_CONFIG).stagesPromptPromptsUpdate({
|
||||
promptUuid: this.instance.pk || "",
|
||||
|
@ -39,7 +59,68 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
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 {
|
||||
return html`
|
||||
|
@ -83,7 +164,7 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
value=${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
|
||||
value=${PromptTypeEnum.Number}
|
||||
|
@ -155,6 +236,50 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
}
|
||||
|
||||
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">
|
||||
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
|
||||
<input
|
||||
|
@ -162,6 +287,9 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
value="${ifDefined(this.instance?.name)}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
@input=${() => {
|
||||
this._shouldRefresh = true;
|
||||
}}
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${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)}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
@input=${() => {
|
||||
this._shouldRefresh = true;
|
||||
}}
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${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)}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
@input=${() => {
|
||||
this._shouldRefresh = true;
|
||||
}}
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">${t`Label shown next to/above the prompt.`}</p>
|
||||
</ak-form-element-horizontal>
|
||||
<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()}
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
|
@ -201,6 +340,9 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${first(this.instance?.required, false)}
|
||||
@change=${() => {
|
||||
this._shouldRefresh = true;
|
||||
}}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
|
@ -216,6 +358,9 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${first(this.instance?.placeholderExpression, false)}
|
||||
@change=${() => {
|
||||
this._shouldRefresh = true;
|
||||
}}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
|
@ -232,7 +377,13 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<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>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${t`Optionally pre-fill the input value.
|
||||
|
@ -241,7 +392,13 @@ export class PromptForm extends ModelForm<Prompt, string> {
|
|||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<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>
|
||||
<p class="pf-c-form__helper-text">${t`Any HTML can be used.`}</p>
|
||||
</ak-form-element-horizontal>
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
import * as yamlMode from "@codemirror/legacy-modes/mode/yaml";
|
||||
import { Compartment, EditorState, Extension } from "@codemirror/state";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { ViewUpdate } from "@codemirror/view";
|
||||
import { EditorView, drawSelection, keymap, lineNumbers } from "@codemirror/view";
|
||||
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
|
@ -148,6 +149,16 @@ export class CodeMirrorTextarea<T> extends AKElement {
|
|||
lineNumbers(),
|
||||
drawSelection(),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((v: ViewUpdate) => {
|
||||
if (!v.docChanged) {
|
||||
return;
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("change", {
|
||||
detail: v,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
EditorState.readOnly.of(this.readOnly),
|
||||
EditorState.tabSize.of(2),
|
||||
this.theme.of(this.activeTheme === UiThemeEnum.Dark ? this.themeDark : this.themeLight),
|
||||
|
|
|
@ -18,7 +18,7 @@ import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-gro
|
|||
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.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 {
|
||||
// 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 {
|
||||
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
|
||||
form?.reset();
|
||||
|
@ -235,7 +232,7 @@ export abstract class Form<T> extends AKElement {
|
|||
if (ex instanceof ResponseError) {
|
||||
let msg = ex.response.statusText;
|
||||
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 instanceof Error) {
|
||||
throw errorMessage;
|
||||
|
@ -257,8 +254,8 @@ export abstract class Form<T> extends AKElement {
|
|||
element.invalid = false;
|
||||
}
|
||||
});
|
||||
if ("non_field_errors" in errorMessage) {
|
||||
this.nonFieldErrors = errorMessage["non_field_errors"];
|
||||
if (errorMessage.nonFieldErrors) {
|
||||
this.nonFieldErrors = errorMessage.nonFieldErrors;
|
||||
}
|
||||
// Only change the message when we have `detail`.
|
||||
// Everything else is handled in the form.
|
||||
|
|
Reference in New Issue