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:
parent
d0f0f9b29e
commit
a71778651f
|
@ -3,6 +3,15 @@
|
||||||
This is the default UI for the authentik server. The documentation is going to be a little sparse
|
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.
|
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
|
# Comments
|
||||||
|
|
||||||
**NOTE:** The comments in this section are for specific changes to this repository that cannot be
|
**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
|
- `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
|
does not support generics well when parsing a subtype of `HTMLElement`. As a result, this threw
|
||||||
too many errors to be supportable.
|
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`.
|
||||||
|
|
|
@ -35,7 +35,8 @@
|
||||||
"mermaid": "^10.2.2",
|
"mermaid": "^10.2.2",
|
||||||
"rapidoc": "^9.3.4",
|
"rapidoc": "^9.3.4",
|
||||||
"webcomponent-qr-code": "^1.1.1",
|
"webcomponent-qr-code": "^1.1.1",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.22.1",
|
"@babel/core": "^7.22.1",
|
||||||
|
@ -64,6 +65,7 @@
|
||||||
"@types/chart.js": "^2.9.37",
|
"@types/chart.js": "^2.9.37",
|
||||||
"@types/codemirror": "5.60.8",
|
"@types/codemirror": "5.60.8",
|
||||||
"@types/grecaptcha": "^3.0.4",
|
"@types/grecaptcha": "^3.0.4",
|
||||||
|
"@types/zxcvbn": "^4.4.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.59.9",
|
"@typescript-eslint/eslint-plugin": "^5.59.9",
|
||||||
"@typescript-eslint/parser": "^5.59.9",
|
"@typescript-eslint/parser": "^5.59.9",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-macros": "^3.1.0",
|
||||||
|
@ -6507,6 +6509,12 @@
|
||||||
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
|
"integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "5.59.9",
|
"version": "5.59.9",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.9.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.9.tgz",
|
||||||
|
@ -18034,6 +18042,11 @@
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"watch": "run-s build-locales rollup:watch",
|
"watch": "run-s build-locales rollup:watch",
|
||||||
"lint": "eslint . --max-warnings 0 --fix",
|
"lint": "eslint . --max-warnings 0 --fix",
|
||||||
"lit-analyse": "lit-analyzer src",
|
"lit-analyse": "lit-analyzer src",
|
||||||
|
"precommit": "run-s build-locales lint tsc prettier",
|
||||||
"prettier-check": "prettier --check .",
|
"prettier-check": "prettier --check .",
|
||||||
"prettier": "prettier --write .",
|
"prettier": "prettier --write .",
|
||||||
"tsc:execute": "tsc --noEmit -p .",
|
"tsc:execute": "tsc --noEmit -p .",
|
||||||
|
@ -51,7 +52,8 @@
|
||||||
"mermaid": "^10.2.2",
|
"mermaid": "^10.2.2",
|
||||||
"rapidoc": "^9.3.4",
|
"rapidoc": "^9.3.4",
|
||||||
"webcomponent-qr-code": "^1.1.1",
|
"webcomponent-qr-code": "^1.1.1",
|
||||||
"yaml": "^2.3.1"
|
"yaml": "^2.3.1",
|
||||||
|
"zxcvbn": "^4.4.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.22.1",
|
"@babel/core": "^7.22.1",
|
||||||
|
@ -80,6 +82,7 @@
|
||||||
"@types/chart.js": "^2.9.37",
|
"@types/chart.js": "^2.9.37",
|
||||||
"@types/codemirror": "5.60.8",
|
"@types/codemirror": "5.60.8",
|
||||||
"@types/grecaptcha": "^3.0.4",
|
"@types/grecaptcha": "^3.0.4",
|
||||||
|
"@types/zxcvbn": "^4.4.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.59.9",
|
"@typescript-eslint/eslint-plugin": "^5.59.9",
|
||||||
"@typescript-eslint/parser": "^5.59.9",
|
"@typescript-eslint/parser": "^5.59.9",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-macros": "^3.1.0",
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import PasswordMatchIndicator from "./password-match-indicator.js";
|
||||||
|
|
||||||
|
export { PasswordMatchIndicator };
|
||||||
|
|
||||||
|
export default PasswordMatchIndicator;
|
|
@ -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>`;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,5 @@
|
||||||
|
import PasswordStrengthIndicator from "./password-strength-indicator.js";
|
||||||
|
|
||||||
|
export { PasswordStrengthIndicator };
|
||||||
|
|
||||||
|
export default PasswordStrengthIndicator;
|
|
@ -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>`;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,5 +1,3 @@
|
||||||
import { LOCALES } from "@goauthentik/common/ui/locale";
|
|
||||||
import { rootInterface } from "@goauthentik/elements/Base";
|
|
||||||
import "@goauthentik/elements/Divider";
|
import "@goauthentik/elements/Divider";
|
||||||
import "@goauthentik/elements/EmptyState";
|
import "@goauthentik/elements/EmptyState";
|
||||||
import "@goauthentik/elements/forms/FormElement";
|
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 PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CapabilitiesEnum,
|
|
||||||
PromptChallenge,
|
PromptChallenge,
|
||||||
PromptChallengeResponseRequest,
|
PromptChallengeResponseRequest,
|
||||||
PromptTypeEnum,
|
PromptTypeEnum,
|
||||||
StagePrompt,
|
StagePrompt,
|
||||||
} from "@goauthentik/api";
|
} from "@goauthentik/api";
|
||||||
|
|
||||||
|
import promptRenderers from "./FieldRenderers";
|
||||||
|
|
||||||
@customElement("ak-stage-prompt")
|
@customElement("ak-stage-prompt")
|
||||||
export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> {
|
export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> {
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
|
@ -50,174 +49,11 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPromptInner(prompt: StagePrompt): TemplateResult {
|
renderPromptInner(prompt: StagePrompt): TemplateResult {
|
||||||
switch (prompt.type) {
|
const renderer = promptRenderers.get(prompt.type);
|
||||||
case PromptTypeEnum.Text:
|
if (!renderer) {
|
||||||
return html`<input
|
return html`<p>invalid type '${prompt.type}'</p>`;
|
||||||
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:
|
|
||||||
return html`<p>invalid type '${prompt.type}'</p>`;
|
|
||||||
}
|
}
|
||||||
|
return renderer(prompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPromptHelpText(prompt: StagePrompt): TemplateResult {
|
renderPromptHelpText(prompt: StagePrompt): TemplateResult {
|
||||||
|
@ -229,14 +65,13 @@ ${prompt.initialValue}</textarea
|
||||||
|
|
||||||
shouldRenderInWrapper(prompt: StagePrompt): boolean {
|
shouldRenderInWrapper(prompt: StagePrompt): boolean {
|
||||||
// Special types that aren't rendered in a wrapper
|
// Special types that aren't rendered in a wrapper
|
||||||
if (
|
const specialTypes = [
|
||||||
prompt.type === PromptTypeEnum.Static ||
|
PromptTypeEnum.Static,
|
||||||
prompt.type === PromptTypeEnum.Hidden ||
|
PromptTypeEnum.Hidden,
|
||||||
prompt.type === PromptTypeEnum.Separator
|
PromptTypeEnum.Separator,
|
||||||
) {
|
];
|
||||||
return false;
|
const special = specialTypes.find((s) => s === prompt.type);
|
||||||
}
|
return !special;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderField(prompt: StagePrompt): TemplateResult {
|
renderField(prompt: StagePrompt): TemplateResult {
|
||||||
|
|
Reference in New Issue