web: improve password experience

This commit disassembles PromptStage and places function that don't
need a reference to the PromptStage object into a collection of
maps between the Stage type and the prompt associated with it.  (In
a better world, this would be a great place to try some post-Midgard
mplementation of itemtype/itemid/itemprop).

This surfaced the nature of the relationship between Password and
Password (Repeat), allowing us to modify both to show password
strength and password matching for the "change password" dialog.
This commit is contained in:
Ken Sternberg 2023-06-08 08:35:23 -07:00
parent d0f0f9b29e
commit a71778651f
12 changed files with 526 additions and 180 deletions

View File

@ -3,6 +3,15 @@
This is the default UI for the authentik server. The documentation is going to be a little sparse
for awhile, but at least let's get started.
# Standards
- Be flexible in what you accept as input, be precise in what you produce as output.
- Mis-use is always a crash. A component that takes the ID of an HTMLInputElement as an argument
should throw an exception if the element is anything but an HTMLInputElement ("anything" includes
non-existent, null, undefined, etc.).
- Single Responsibility is ideal, but not always practical. To the best of your obility, every
object in the system should do one thing and do it well.
# Comments
**NOTE:** The comments in this section are for specific changes to this repository that cannot be
@ -21,3 +30,7 @@ settings in JSON files, which do not support comments.
- `compilerOptions.plugins.ts-lit-plugin.rules.no-incompatible-type-binding: "warn"`: lit-analyzer
does not support generics well when parsing a subtype of `HTMLElement`. As a result, this threw
too many errors to be supportable.
- `package.json`
- `prettier` should always be the last thing run in any pre-commit pass. The `precommit` script
does this, but if you don't use `precommit`, make sure `prettier` is the _last_ thing you do
before a `git commit`.

15
web/package-lock.json generated
View File

@ -35,7 +35,8 @@
"mermaid": "^10.2.2",
"rapidoc": "^9.3.4",
"webcomponent-qr-code": "^1.1.1",
"yaml": "^2.3.1"
"yaml": "^2.3.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@babel/core": "^7.22.1",
@ -64,6 +65,7 @@
"@types/chart.js": "^2.9.37",
"@types/codemirror": "5.60.8",
"@types/grecaptcha": "^3.0.4",
"@types/zxcvbn": "^4.4.1",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"babel-plugin-macros": "^3.1.0",
@ -6507,6 +6509,12 @@
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
"dev": true
},
"node_modules/@types/zxcvbn": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.1.tgz",
"integrity": "sha512-3NoqvZC2W5gAC5DZbTpCeJ251vGQmgcWIHQJGq2J240HY6ErQ9aWKkwfoKJlHLx+A83WPNTZ9+3cd2ILxbvr1w==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "5.59.9",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.9.tgz",
@ -18034,6 +18042,11 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zxcvbn": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz",
"integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ=="
}
}
}

View File

@ -16,6 +16,7 @@
"watch": "run-s build-locales rollup:watch",
"lint": "eslint . --max-warnings 0 --fix",
"lit-analyse": "lit-analyzer src",
"precommit": "run-s build-locales lint tsc prettier",
"prettier-check": "prettier --check .",
"prettier": "prettier --write .",
"tsc:execute": "tsc --noEmit -p .",
@ -51,7 +52,8 @@
"mermaid": "^10.2.2",
"rapidoc": "^9.3.4",
"webcomponent-qr-code": "^1.1.1",
"yaml": "^2.3.1"
"yaml": "^2.3.1",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@babel/core": "^7.22.1",
@ -80,6 +82,7 @@
"@types/chart.js": "^2.9.37",
"@types/codemirror": "5.60.8",
"@types/grecaptcha": "^3.0.4",
"@types/zxcvbn": "^4.4.1",
"@typescript-eslint/eslint-plugin": "^5.59.9",
"@typescript-eslint/parser": "^5.59.9",
"babel-plugin-macros": "^3.1.0",

View File

@ -0,0 +1,5 @@
import PasswordMatchIndicator from "./password-match-indicator.js";
export { PasswordMatchIndicator };
export default PasswordMatchIndicator;

View File

@ -0,0 +1,14 @@
import { html } from "lit";
import ".";
export default {
title: "Elements/Password Match Indicator",
};
export const Primary = () =>
html`<div style="background: #fff; padding: 4em">
<p>Type some text: <input id="primary-example" style="color:#000" /></p>
<p>Type some other text: <input id="primary-example_repeat" style="color:#000" /></p>
<ak-password-match-indicator src="#primary-example_repeat"></ak-password-match-indicator>
</div>`;

View File

@ -0,0 +1,91 @@
import { AKElement } from "@goauthentik/elements/Base";
import { css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import findInput from "../password-strength-indicator/findInput.js";
/**
* A simple display showing if the passwords match. This element is extremely fragile and
* role-specific, depending as it does on the token string '_repeat' inside the selector.
*/
const ELEMENT = "ak-password-match-indicator";
@customElement(ELEMENT)
export class PasswordMatchIndicator extends AKElement {
static styles = [
PFBase,
css`
:host {
display: grid;
place-items: center center;
}
`,
];
/**
* The input element to observe. Attaching this to anything other than an HTMLInputElement will
* throw an exception.
*/
@property({ attribute: true })
src = "";
sourceInput?: HTMLInputElement;
otherInput?: HTMLInputElement;
@state()
match = false;
constructor() {
super();
this.checkPasswordMatch = this.checkPasswordMatch.bind(this);
}
connectedCallback() {
super.connectedCallback();
this.input.addEventListener("keyup", this.checkPasswordMatch);
this.other.addEventListener("keyup", this.checkPasswordMatch);
}
disconnectedCallback() {
this.other.removeEventListener("keyup", this.checkPasswordMatch);
this.input.removeEventListener("keyup", this.checkPasswordMatch);
super.disconnectedCallback();
}
checkPasswordMatch() {
this.match =
this.input.value.length > 0 &&
this.other.value.length > 0 &&
this.input.value === this.other.value;
}
get input() {
if (this.sourceInput) {
return this.sourceInput;
}
return (this.sourceInput = findInput(this.getRootNode() as Element, ELEMENT, this.src));
}
get other() {
if (this.otherInput) {
return this.otherInput;
}
return (this.otherInput = findInput(
this.getRootNode() as Element,
ELEMENT,
this.src.replace(/_repeat/, ""),
));
}
render() {
return this.match
? html`<i class="pf-icon pf-icon-ok pf-m-success"></i>`
: html`<i class="pf-icon pf-icon-warning-triangle pf-m-warning"></i>`;
}
}
export default PasswordMatchIndicator;

View File

@ -0,0 +1,18 @@
export function findInput(root: Element, tag: string, src: string) {
const inputs = Array.from(root.querySelectorAll(src));
if (inputs.length === 0) {
throw new Error(`${tag}: no element found for 'src' ${src}`);
}
if (inputs.length > 1) {
throw new Error(`${tag}: more than one element found for 'src' ${src}`);
}
const input = inputs[0];
if (!(input instanceof HTMLInputElement)) {
throw new Error(
`${tag}: the 'src' element must be an <input> tag, found ${input.localName}`,
);
}
return input;
}
export default findInput;

View File

@ -0,0 +1,5 @@
import PasswordStrengthIndicator from "./password-strength-indicator.js";
export { PasswordStrengthIndicator };
export default PasswordStrengthIndicator;

View File

@ -0,0 +1,13 @@
import { html } from "lit";
import ".";
export default {
title: "Elements/Password Strength Indicator",
};
export const Primary = () =>
html`<div style="background: #fff; padding: 4em">
<p>Type some text: <input id="primary-example" style="color:#000" /></p>
<ak-password-strength-indicator src="#primary-example"></ak-password-strength-indicator>
</div>`;

View File

@ -0,0 +1,91 @@
import { AKElement } from "@goauthentik/elements/Base";
import zxcvbn from "zxcvbn";
import { css, html } from "lit";
import { styleMap } from "lit-html/directives/style-map.js";
import { customElement, property, state } from "lit/decorators.js";
import findInput from "./findInput";
const styles = css`
.password-meter-wrap {
margin-top: 5px;
height: 0.5em;
background-color: #ddd;
border-radius: 0.25em;
overflow: hidden;
}
.password-meter-bar {
width: 0;
height: 100%;
transition: width 400ms ease-in;
}
`;
const LEVELS = [
["20%", "#dd0000"],
["40%", "#ff5500"],
["60%", "#ffff00"],
["80%", "#a1a841"],
["100%", "#339933"],
].map(([width, backgroundColor]) => ({ width, backgroundColor }));
/**
* A simple display of the password strength.
*/
const ELEMENT = "ak-password-strength-indicator";
@customElement(ELEMENT)
export class PasswordStrengthIndicator extends AKElement {
static styles = styles;
/**
* The input element to observe. Attaching this to anything other than an HTMLInputElement will
* throw an exception.
*/
@property({ attribute: true })
src = "";
sourceInput?: HTMLInputElement;
@state()
strength = LEVELS[0];
constructor() {
super();
this.checkPasswordStrength = this.checkPasswordStrength.bind(this);
}
connectedCallback() {
super.connectedCallback();
this.input.addEventListener("keyup", this.checkPasswordStrength);
}
disconnectedCallback() {
this.input.removeEventListener("keyup", this.checkPasswordStrength);
super.disconnectedCallback();
}
checkPasswordStrength() {
const { score } = zxcvbn(this.input.value);
this.strength = LEVELS[score];
}
get input(): HTMLInputElement {
if (this.sourceInput) {
return this.sourceInput;
}
return (this.sourceInput = findInput(this.getRootNode() as Element, ELEMENT, this.src));
}
render() {
return html` <div class="password-meter-wrap">
<div class="password-meter-bar" style=${styleMap(this.strength)}></div>
</div>`;
}
}
export default PasswordStrengthIndicator;

View File

@ -0,0 +1,245 @@
import { rootInterface } from "@goauthentik/elements/Base";
import { LOCALES } from "@goauthentik/common/ui/locale";
import "@goauthentik/elements/password-match-indicator";
import "@goauthentik/elements/password-strength-indicator";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { TemplateResult, html } from "lit";
import { msg } from "@lit/localize";
import { StagePrompt, CapabilitiesEnum, PromptTypeEnum } from "@goauthentik/api";
export function password(prompt: StagePrompt) {
return html`<input
type="password"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="new-password"
class="pf-c-form-control"
?required=${prompt.required}
/><ak-password-strength-indicator
src='input[name="${prompt.fieldKey}"]'
></ak-password-strength-indicator>`;
}
export function repeatPassword(prompt: StagePrompt) {
return html` <div style="display:flex; flex-direction:row; gap: 0.5em; align-content: center">
<input
style="flex:1 0"
type="password"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="new-password"
class="pf-c-form-control"
?required=${prompt.required}
/><ak-password-match-indicator
src='input[name="${prompt.fieldKey}"]'
></ak-password-match-indicator>
</div>`;
}
export function renderPassword(prompt: StagePrompt) {
return /_repeat$/.test(prompt.fieldKey) ? repeatPassword(prompt) : password(prompt);
}
export function renderText(prompt: StagePrompt) {
return html`<input
type="text"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="off"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
}
export function renderTextArea(prompt: StagePrompt) {
return html`<textarea
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="off"
class="pf-c-form-control"
?required=${prompt.required}
>
${prompt.initialValue}</textarea
>`;
}
export function renderTextReadOnly(prompt: StagePrompt) {
return html`<input
type="text"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?readonly=${true}
value="${prompt.initialValue}"
/>`;
}
export function renderTextAreaReadOnly(prompt: StagePrompt) {
return html`<textarea
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
readonly
>
${prompt.initialValue}</textarea
>`;
}
export function renderUsername(prompt: StagePrompt) {
return html`<input
type="text"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="username"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
}
export function renderEmail(prompt: StagePrompt) {
return html`<input
type="email"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
}
export function renderNumber(prompt: StagePrompt) {
return html`<input
type="number"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
}
export function renderDate(prompt: StagePrompt) {
return html`<input
type="date"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
}
export function renderDateTime(prompt: StagePrompt) {
return html`<input
type="datetime"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
}
export function renderFile(prompt: StagePrompt) {
return html`<input
type="file"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
}
export function renderSeparator(prompt: StagePrompt) {
return html`<ak-divider>${prompt.placeholder}</ak-divider>`;
}
export function renderHidden(prompt: StagePrompt) {
return html`<input
type="hidden"
name="${prompt.fieldKey}"
value="${prompt.initialValue}"
class="pf-c-form-control"
?required=${prompt.required}
/>`;
}
export function renderStatic(prompt: StagePrompt) {
return html`<p>${unsafeHTML(prompt.initialValue)}</p>`;
}
export function renderDropdown(prompt: StagePrompt) {
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
${prompt.choices?.map((choice) => {
return html`<option value="${choice}" ?selected=${prompt.initialValue === choice}>
${choice}
</option>`;
})}
</select>`;
}
export function renderRadioButtonGroup(prompt: StagePrompt) {
return html`${(prompt.choices || []).map((choice) => {
const id = `${prompt.fieldKey}-${choice}`;
return html`<div class="pf-c-check">
<input
type="radio"
class="pf-c-check__input"
name="${prompt.fieldKey}"
id="${id}"
?checked="${prompt.initialValue === choice}"
?required="${prompt.required}"
value="${choice}"
/>
<label class="pf-c-check__label" for=${id}>${choice}</label>
</div> `;
})}`;
}
export function renderAkLocale(prompt: StagePrompt) {
// TODO: External reference.
const inDebug = rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanDebug);
const locales = inDebug ? LOCALES : LOCALES.filter((locale) => locale.code !== "debug");
const options = locales.map(
(locale) => html`<option
value=${locale.code}
?selected=${locale.code === prompt.initialValue}
>
${locale.code.toUpperCase()} - ${locale.label()}
</option> `
);
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
<option value="" ?selected=${prompt.initialValue === ""}>
${msg("Auto-detect (based on your browser)")}
</option>
${options}
</select>`;
}
type Renderer = (prompt: StagePrompt) => TemplateResult;
export const promptRenderers = new Map<PromptTypeEnum, Renderer>([
[PromptTypeEnum.Text, renderText],
[PromptTypeEnum.TextArea, renderTextArea],
[PromptTypeEnum.TextReadOnly, renderTextReadOnly],
[PromptTypeEnum.TextAreaReadOnly, renderTextAreaReadOnly],
[PromptTypeEnum.Username, renderUsername],
[PromptTypeEnum.Email, renderEmail],
[PromptTypeEnum.Password, renderPassword],
[PromptTypeEnum.Number, renderNumber],
[PromptTypeEnum.Date, renderDate],
[PromptTypeEnum.DateTime, renderDateTime],
[PromptTypeEnum.File, renderFile],
[PromptTypeEnum.Separator, renderSeparator],
[PromptTypeEnum.Hidden, renderHidden],
[PromptTypeEnum.Static, renderStatic],
[PromptTypeEnum.Dropdown, renderDropdown],
[PromptTypeEnum.RadioButtonGroup, renderRadioButtonGroup],
[PromptTypeEnum.AkLocale, renderAkLocale],
]);
export default promptRenderers;

View File

@ -1,5 +1,3 @@
import { LOCALES } from "@goauthentik/common/ui/locale";
import { rootInterface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Divider";
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
@ -20,13 +18,14 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import {
CapabilitiesEnum,
PromptChallenge,
PromptChallengeResponseRequest,
PromptTypeEnum,
StagePrompt,
} from "@goauthentik/api";
import promptRenderers from "./FieldRenderers";
@customElement("ak-stage-prompt")
export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> {
static get styles(): CSSResult[] {
@ -50,174 +49,11 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
}
renderPromptInner(prompt: StagePrompt): TemplateResult {
switch (prompt.type) {
case PromptTypeEnum.Text:
return html`<input
type="text"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="off"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
case PromptTypeEnum.TextArea:
return html`<textarea
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="off"
class="pf-c-form-control"
?required=${prompt.required}
>
${prompt.initialValue}</textarea
>`;
case PromptTypeEnum.TextReadOnly:
return html`<input
type="text"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?readonly=${true}
value="${prompt.initialValue}"
/>`;
case PromptTypeEnum.TextAreaReadOnly:
return html`<textarea
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
readonly
>
${prompt.initialValue}</textarea
>`;
case PromptTypeEnum.Username:
return html`<input
type="text"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="username"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
case PromptTypeEnum.Email:
return html`<input
type="email"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
case PromptTypeEnum.Password:
return html`<input
type="password"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
autocomplete="new-password"
class="pf-c-form-control"
?required=${prompt.required}
/>`;
case PromptTypeEnum.Number:
return html`<input
type="number"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
case PromptTypeEnum.Date:
return html`<input
type="date"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
case PromptTypeEnum.DateTime:
return html`<input
type="datetime"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
case PromptTypeEnum.File:
return html`<input
type="file"
name="${prompt.fieldKey}"
placeholder="${prompt.placeholder}"
class="pf-c-form-control"
?required=${prompt.required}
value="${prompt.initialValue}"
/>`;
case PromptTypeEnum.Separator:
return html`<ak-divider>${prompt.placeholder}</ak-divider>`;
case PromptTypeEnum.Hidden:
return html`<input
type="hidden"
name="${prompt.fieldKey}"
value="${prompt.initialValue}"
class="pf-c-form-control"
?required=${prompt.required}
/>`;
case PromptTypeEnum.Static:
return html`<p>${unsafeHTML(prompt.initialValue)}</p>`;
case PromptTypeEnum.Dropdown:
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
${prompt.choices?.map((choice) => {
return html`<option
value="${choice}"
?selected=${prompt.initialValue === choice}
>
${choice}
</option>`;
})}
</select>`;
case PromptTypeEnum.RadioButtonGroup:
return html`${(prompt.choices || []).map((choice) => {
const id = `${prompt.fieldKey}-${choice}`;
return html`<div class="pf-c-check">
<input
type="radio"
class="pf-c-check__input"
name="${prompt.fieldKey}"
id="${id}"
?checked="${prompt.initialValue === choice}"
?required="${prompt.required}"
value="${choice}"
/>
<label class="pf-c-check__label" for=${id}>${choice}</label>
</div> `;
})}`;
case PromptTypeEnum.AkLocale: {
const inDebug = rootInterface()?.config?.capabilities.includes(
CapabilitiesEnum.CanDebug,
);
const locales = inDebug
? LOCALES
: LOCALES.filter((locale) => locale.code !== "debug");
const options = locales.map(
(locale) => html`<option
value=${locale.code}
?selected=${locale.code === prompt.initialValue}
>
${locale.code.toUpperCase()} - ${locale.label()}
</option> `,
);
return html`<select class="pf-c-form-control" name="${prompt.fieldKey}">
<option value="" ?selected=${prompt.initialValue === ""}>
${msg("Auto-detect (based on your browser)")}
</option>
${options}
</select>`;
}
default:
const renderer = promptRenderers.get(prompt.type);
if (!renderer) {
return html`<p>invalid type '${prompt.type}'</p>`;
}
return renderer(prompt);
}
renderPromptHelpText(prompt: StagePrompt): TemplateResult {
@ -229,14 +65,13 @@ ${prompt.initialValue}</textarea
shouldRenderInWrapper(prompt: StagePrompt): boolean {
// Special types that aren't rendered in a wrapper
if (
prompt.type === PromptTypeEnum.Static ||
prompt.type === PromptTypeEnum.Hidden ||
prompt.type === PromptTypeEnum.Separator
) {
return false;
}
return true;
const specialTypes = [
PromptTypeEnum.Static,
PromptTypeEnum.Hidden,
PromptTypeEnum.Separator,
];
const special = specialTypes.find((s) => s === prompt.type);
return !special;
}
renderField(prompt: StagePrompt): TemplateResult {