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