Compare commits
7 commits
trustchain
...
5165-passw
Author | SHA1 | Date | |
---|---|---|---|
465820b002 | |||
a75c9434d9 | |||
4ea9b69ab5 | |||
c48eee0ebf | |||
0d94373f10 | |||
1c85dc512f | |||
a71778651f |
|
@ -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`.
|
||||
|
|
1545
web/package-lock.json
generated
1545
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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 .",
|
||||
|
@ -52,7 +53,8 @@
|
|||
"rapidoc": "^9.3.4",
|
||||
"style-mod": "^4.0.3",
|
||||
"webcomponent-qr-code": "^1.1.1",
|
||||
"yaml": "^2.3.1"
|
||||
"yaml": "^2.3.1",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.7",
|
||||
|
@ -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.61.0",
|
||||
"@typescript-eslint/parser": "^5.61.0",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
|
|
5
web/src/elements/password-match-indicator/index.ts
Normal file
5
web/src/elements/password-match-indicator/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import PasswordMatchIndicator from "./password-match-indicator.js";
|
||||
|
||||
export { PasswordMatchIndicator };
|
||||
|
||||
export default PasswordMatchIndicator;
|
|
@ -0,0 +1,19 @@
|
|||
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 style="margin-top:0.5em">
|
||||
Type some other text: <input id="primary-example_repeat" style="color:#000" />
|
||||
<ak-password-match-indicator
|
||||
first="#primary-example"
|
||||
second="#primary-example_repeat"
|
||||
></ak-password-match-indicator>
|
||||
</p>
|
||||
</div>`;
|
|
@ -0,0 +1,94 @@
|
|||
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";
|
||||
|
||||
const ELEMENT = "ak-password-match-indicator";
|
||||
|
||||
@customElement(ELEMENT)
|
||||
export class PasswordMatchIndicator extends AKElement {
|
||||
static styles = [
|
||||
PFBase,
|
||||
css`
|
||||
:host {
|
||||
display: grid;
|
||||
place-items: center center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/**
|
||||
* A valid selector for the first input element to observe. Attaching this to anything other
|
||||
* than an HTMLInputElement will throw an exception.
|
||||
*/
|
||||
@property({ attribute: true })
|
||||
first = "";
|
||||
|
||||
/**
|
||||
* A valid selector for the second input element to observe. Attaching this to anything other
|
||||
* than an HTMLInputElement will throw an exception.
|
||||
*/
|
||||
@property({ attribute: true })
|
||||
second = "";
|
||||
|
||||
firstElement?: HTMLInputElement;
|
||||
|
||||
secondElement?: HTMLInputElement;
|
||||
|
||||
@state()
|
||||
match = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.checkPasswordMatch = this.checkPasswordMatch.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.firstInput.addEventListener("keyup", this.checkPasswordMatch);
|
||||
this.secondInput.addEventListener("keyup", this.checkPasswordMatch);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.secondInput.removeEventListener("keyup", this.checkPasswordMatch);
|
||||
this.firstInput.removeEventListener("keyup", this.checkPasswordMatch);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
checkPasswordMatch() {
|
||||
this.match =
|
||||
this.firstInput.value.length > 0 &&
|
||||
this.secondInput.value.length > 0 &&
|
||||
this.firstInput.value === this.secondInput.value;
|
||||
}
|
||||
|
||||
get firstInput() {
|
||||
if (this.firstElement) {
|
||||
return this.firstElement;
|
||||
}
|
||||
return (this.firstElement = findInput(this.getRootNode() as Element, ELEMENT, this.first));
|
||||
}
|
||||
|
||||
get secondInput() {
|
||||
if (this.secondElement) {
|
||||
return this.secondElement;
|
||||
}
|
||||
return (this.secondElement = findInput(
|
||||
this.getRootNode() as Element,
|
||||
ELEMENT,
|
||||
this.second,
|
||||
));
|
||||
}
|
||||
|
||||
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;
|
18
web/src/elements/password-strength-indicator/findInput.ts
Normal file
18
web/src/elements/password-strength-indicator/findInput.ts
Normal 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;
|
5
web/src/elements/password-strength-indicator/index.ts
Normal file
5
web/src/elements/password-strength-indicator/index.ts
Normal file
|
@ -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;
|
108
web/src/flow/stages/prompt/FieldRenderers.stories.ts
Normal file
108
web/src/flow/stages/prompt/FieldRenderers.stories.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import "@patternfly/patternfly/components/Button/button.css";
|
||||
import "@patternfly/patternfly/components/Check/check.css";
|
||||
import "@patternfly/patternfly/components/Form/form.css";
|
||||
import "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import "@patternfly/patternfly/components/Login/login.css";
|
||||
import "@patternfly/patternfly/components/Title/title.css";
|
||||
import "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { PromptTypeEnum } from "@goauthentik/api";
|
||||
import type { StagePrompt } from "@goauthentik/api";
|
||||
|
||||
import promptRenderers from "./FieldRenderers";
|
||||
import { renderContinue, renderPromptHelpText, renderPromptInner } from "./helpers";
|
||||
|
||||
// Storybook stories are meant to show not just that the objects work, but to document good
|
||||
// practices around using them. Because of their uniform signature, the renderers can easily
|
||||
// be encapsulated into containers that show them at their most functional, even without
|
||||
// building Shadow DOMs with which to do it. This is 100% Light DOM work, and they still
|
||||
// work well.
|
||||
|
||||
const baseRenderer = (prompt: TemplateResult) =>
|
||||
html`<div style="background: #fff; padding: 4em; max-width: 24em;">
|
||||
<style>
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
button,
|
||||
.pf-c-form__helper-text:not(.pf-m-error),
|
||||
input + label.pf-c-check__label {
|
||||
color: #000;
|
||||
}
|
||||
input[readonly],
|
||||
textarea[readonly] {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
${prompt}
|
||||
</div>`;
|
||||
|
||||
function renderer(kind: PromptTypeEnum, prompt: Partial<StagePrompt>) {
|
||||
const renderer = promptRenderers.get(kind);
|
||||
if (!renderer) {
|
||||
throw new Error(`A renderer of type ${kind} does not exist.`);
|
||||
}
|
||||
return baseRenderer(html`${renderer(prompt as StagePrompt)}`);
|
||||
}
|
||||
|
||||
const textPrompt = {
|
||||
fieldKey: "test_text_field",
|
||||
placeholder: "This is the placeholder",
|
||||
required: false,
|
||||
initialValue: "initial value",
|
||||
};
|
||||
|
||||
export const Text = () => renderer(PromptTypeEnum.Text, textPrompt);
|
||||
export const TextArea = () => renderer(PromptTypeEnum.TextArea, textPrompt);
|
||||
export const TextReadOnly = () => renderer(PromptTypeEnum.TextReadOnly, textPrompt);
|
||||
export const TextAreaReadOnly = () => renderer(PromptTypeEnum.TextAreaReadOnly, textPrompt);
|
||||
export const Username = () => renderer(PromptTypeEnum.Username, textPrompt);
|
||||
export const Password = () => renderer(PromptTypeEnum.Password, textPrompt);
|
||||
|
||||
const emailPrompt = { ...textPrompt, initialValue: "example@example.fun" };
|
||||
export const Email = () => renderer(PromptTypeEnum.Email, emailPrompt);
|
||||
|
||||
const numberPrompt = { ...textPrompt, initialValue: "10" };
|
||||
export const Number = () => renderer(PromptTypeEnum.Number, numberPrompt);
|
||||
|
||||
const datePrompt = { ...textPrompt, initialValue: "2018-06-12T19:30" };
|
||||
export const Date = () => renderer(PromptTypeEnum.Date, datePrompt);
|
||||
export const DateTime = () => renderer(PromptTypeEnum.DateTime, datePrompt);
|
||||
|
||||
const separatorPrompt = { placeholder: "😊" };
|
||||
export const Separator = () => renderer(PromptTypeEnum.Separator, separatorPrompt);
|
||||
|
||||
const staticPrompt = { initialValue: "😊" };
|
||||
export const Static = () => renderer(PromptTypeEnum.Static, staticPrompt);
|
||||
|
||||
const choicePrompt = {
|
||||
fieldKey: "test_text_field",
|
||||
placeholder: "This is the placeholder",
|
||||
required: false,
|
||||
initialValue: "first",
|
||||
choices: ["first", "second", "third"],
|
||||
};
|
||||
|
||||
export const Dropdown = () => renderer(PromptTypeEnum.Dropdown, choicePrompt);
|
||||
export const RadioButtonGroup = () => renderer(PromptTypeEnum.RadioButtonGroup, choicePrompt);
|
||||
|
||||
const checkPrompt = { ...textPrompt, label: "Favorite Subtext?", subText: "(Xena & Gabrielle)" };
|
||||
export const Checkbox = () => renderer(PromptTypeEnum.Checkbox, checkPrompt);
|
||||
|
||||
const localePrompt = { ...textPrompt, initialValue: "en" };
|
||||
export const Locale = () => renderer(PromptTypeEnum.AkLocale, localePrompt);
|
||||
|
||||
export const PromptFailure = () =>
|
||||
baseRenderer(renderPromptInner({ type: null } as unknown as StagePrompt));
|
||||
|
||||
export const HelpText = () =>
|
||||
baseRenderer(renderPromptHelpText({ subText: "There is no subtext here." } as StagePrompt));
|
||||
|
||||
export const Continue = () => baseRenderer(renderContinue());
|
||||
|
||||
export default {
|
||||
title: "Flow Components/Field Renderers",
|
||||
};
|
272
web/src/flow/stages/prompt/FieldRenderers.ts
Normal file
272
web/src/flow/stages/prompt/FieldRenderers.ts
Normal file
|
@ -0,0 +1,272 @@
|
|||
import { LOCALES } from "@goauthentik/common/ui/locale";
|
||||
import { rootInterface } from "@goauthentik/elements/Base";
|
||||
import "@goauthentik/elements/password-match-indicator";
|
||||
import "@goauthentik/elements/password-strength-indicator";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import { CapabilitiesEnum, PromptTypeEnum, StagePrompt } 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>`;
|
||||
}
|
||||
|
||||
const REPEAT = /_repeat/;
|
||||
|
||||
export function repeatPassword(prompt: StagePrompt) {
|
||||
const first = `input[name="${prompt.fieldKey}"]`;
|
||||
const second = `input[name="${prompt.fieldKey.replace(REPEAT, "")}"]`;
|
||||
|
||||
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
|
||||
first="${first}"
|
||||
second="${second}"
|
||||
></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 renderCheckbox(prompt: StagePrompt) {
|
||||
return html`<div class="pf-c-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="pf-c-check__input"
|
||||
id="${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
?checked=${prompt.initialValue !== ""}
|
||||
?required=${prompt.required}
|
||||
/>
|
||||
<label class="pf-c-check__label" for="${prompt.fieldKey}">${prompt.label}</label>
|
||||
${prompt.required
|
||||
? html`<p class="pf-c-form__helper-text">${msg("Required.")}</p>`
|
||||
: html``}
|
||||
<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>
|
||||
</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.Checkbox, renderCheckbox],
|
||||
[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/EmptyState";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
|
@ -8,7 +6,6 @@ import { BaseStage } from "@goauthentik/flow/stages/base";
|
|||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
|
@ -20,13 +17,20 @@ 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 { renderCheckbox } from "./FieldRenderers";
|
||||
import {
|
||||
renderContinue,
|
||||
renderPromptHelpText,
|
||||
renderPromptInner,
|
||||
shouldRenderInWrapper,
|
||||
} from "./helpers";
|
||||
|
||||
@customElement("ak-stage-prompt")
|
||||
export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> {
|
||||
static get styles(): CSSResult[] {
|
||||
|
@ -49,234 +53,35 @@ 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> `,
|
||||
);
|
||||
/* TODO: Legacy: None of these refer to the `this` field. Static fields are a code smell. */
|
||||
|
||||
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>`;
|
||||
renderPromptInner(prompt: StagePrompt) {
|
||||
return renderPromptInner(prompt);
|
||||
}
|
||||
default:
|
||||
return html`<p>invalid type '${prompt.type}'</p>`;
|
||||
renderPromptHelpText(prompt: StagePrompt) {
|
||||
return renderPromptHelpText(prompt);
|
||||
}
|
||||
}
|
||||
|
||||
renderPromptHelpText(prompt: StagePrompt): TemplateResult {
|
||||
if (prompt.subText === "") {
|
||||
return html``;
|
||||
}
|
||||
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
|
||||
}
|
||||
|
||||
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;
|
||||
shouldRenderInWrapper(prompt: StagePrompt) {
|
||||
return shouldRenderInWrapper(prompt);
|
||||
}
|
||||
|
||||
renderField(prompt: StagePrompt): TemplateResult {
|
||||
// Checkbox is rendered differently
|
||||
// Checkbox has a slightly different layout, so it must be intercepted early.
|
||||
if (prompt.type === PromptTypeEnum.Checkbox) {
|
||||
return html`<div class="pf-c-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="pf-c-check__input"
|
||||
id="${prompt.fieldKey}"
|
||||
name="${prompt.fieldKey}"
|
||||
?checked=${prompt.initialValue !== ""}
|
||||
?required=${prompt.required}
|
||||
/>
|
||||
<label class="pf-c-check__label" for="${prompt.fieldKey}">${prompt.label}</label>
|
||||
${prompt.required
|
||||
? html`<p class="pf-c-form__helper-text">${msg("Required.")}</p>`
|
||||
: html``}
|
||||
<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>
|
||||
</div>`;
|
||||
return renderCheckbox(prompt);
|
||||
}
|
||||
if (this.shouldRenderInWrapper(prompt)) {
|
||||
|
||||
if (shouldRenderInWrapper(prompt)) {
|
||||
return html`<ak-form-element
|
||||
label="${prompt.label}"
|
||||
?required="${prompt.required}"
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
|
||||
>
|
||||
${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}
|
||||
${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}
|
||||
</ak-form-element>`;
|
||||
}
|
||||
return html` ${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}`;
|
||||
}
|
||||
|
||||
renderContinue(): TemplateResult {
|
||||
return html` <div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${msg("Continue")}
|
||||
</button>
|
||||
</div>`;
|
||||
return html` ${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
|
@ -284,6 +89,7 @@ ${prompt.initialValue}</textarea
|
|||
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
|
||||
</ak-empty-state>`;
|
||||
}
|
||||
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||
</header>
|
||||
|
@ -302,7 +108,7 @@ ${prompt.initialValue}</textarea
|
|||
this.challenge?.responseErrors?.non_field_errors || [],
|
||||
)
|
||||
: html``}
|
||||
${this.renderContinue()}
|
||||
${renderContinue()}
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-login__main-footer">
|
||||
|
|
37
web/src/flow/stages/prompt/helpers.ts
Normal file
37
web/src/flow/stages/prompt/helpers.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
|
||||
import { PromptTypeEnum, StagePrompt } from "@goauthentik/api";
|
||||
|
||||
import promptRenderers from "./FieldRenderers";
|
||||
|
||||
export function renderPromptInner(prompt: StagePrompt) {
|
||||
const renderer = promptRenderers.get(prompt.type);
|
||||
if (!renderer) {
|
||||
return html`<p>invalid type '${JSON.stringify(prompt.type, null, 2)}'</p>`;
|
||||
}
|
||||
return renderer(prompt);
|
||||
}
|
||||
|
||||
export function renderPromptHelpText(prompt: StagePrompt) {
|
||||
if (prompt.subText === "") {
|
||||
return html``;
|
||||
}
|
||||
return html`<p class="pf-c-form__helper-text">${unsafeHTML(prompt.subText)}</p>`;
|
||||
}
|
||||
|
||||
export function shouldRenderInWrapper(prompt: StagePrompt) {
|
||||
// Special types that aren't rendered in a wrapper
|
||||
const specialTypes = [PromptTypeEnum.Static, PromptTypeEnum.Hidden, PromptTypeEnum.Separator];
|
||||
const special = specialTypes.find((s) => s === prompt.type);
|
||||
return !special;
|
||||
}
|
||||
|
||||
export function renderContinue() {
|
||||
return html` <div class="pf-c-form__group pf-m-action">
|
||||
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
|
||||
${msg("Continue")}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
Reference in a new issue