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
12 changed files with 406 additions and 55 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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),

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 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.