Compare commits

...
This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.

7 commits

Author SHA1 Message Date
Ken Sternberg 465820b002 Merge branch 'main' into 5165-password-strength-indicator
* main: (160 commits)
  website: update hackathon with prize pool (#6170)
  web: bump @babel/plugin-transform-runtime from 7.22.6 to 7.22.7 in /web (#6166)
  web: bump @babel/core from 7.22.6 to 7.22.7 in /web (#6165)
  web: bump @babel/plugin-proposal-decorators from 7.22.6 to 7.22.7 in /web (#6167)
  web: bump @babel/preset-env from 7.22.6 to 7.22.7 in /web (#6168)
  website: bump prettier from 2.8.8 to 3.0.0 in /website (#6155)
  web: bump storybook from 7.0.25 to 7.0.26 in /web (#6162)
  core: bump goauthentik.io/api/v3 from 3.2023054.2 to 3.2023054.4 (#6154)
  core: bump golang.org/x/oauth2 from 0.9.0 to 0.10.0 (#6153)
  web: bump @storybook/addon-essentials from 7.0.25 to 7.0.26 in /web (#6158)
  ci: bump actions/setup-node from 3.6.0 to 3.7.0 (#6156)
  web: bump core-js from 3.31.0 to 3.31.1 in /web (#6160)
  web: bump @storybook/addon-links from 7.0.25 to 7.0.26 in /web (#6159)
  web: bump @storybook/web-components-vite from 7.0.25 to 7.0.26 in /web (#6163)
  web: bump lit from 2.7.5 to 2.7.6 in /web (#6161)
  core: bump lxml from 4.9.2 to 4.9.3 (#6151)
  web: bump @babel/core from 7.22.5 to 7.22.6 in /web (#6143)
  web: bump @babel/plugin-transform-runtime from 7.22.5 to 7.22.6 in /web (#6142)
  web: bump @babel/preset-env from 7.22.5 to 7.22.6 in /web (#6144)
  web: bump @babel/plugin-proposal-decorators from 7.22.5 to 7.22.6 in /web (#6141)
  ...
2023-07-06 08:05:05 -07:00
Ken Sternberg a75c9434d9 Merge branch 'main' into 5165-password-strength-indicator
* main: (23 commits)
  web: bump API Client version (#5935)
  sources/ldap: add support for cert based auth (#5850)
  ci: replace status with state for auto-deployment
  ci: don't write CI status to file
  ci: add workflow to automatically update next branch (#5921)
  providers/ldap: fix Outpost provider listing excluding backchannel providers (#5933)
  root: revert to use secret_key for JWT signing (#5934)
  sources/ldap: fix duplicate bind when authenticating user directly to… (#5927)
  web: bump core-js from 3.30.2 to 3.31.0 in /web (#5928)
  core: bump pytest from 7.3.1 to 7.3.2 (#5929)
  web: bump @rollup/plugin-commonjs from 25.0.0 to 25.0.1 in /web (#5931)
  web: bump @formatjs/intl-listformat from 7.3.0 to 7.4.0 in /web (#5932)
  core: bump github.com/go-ldap/ldap/v3 from 3.4.4 to 3.4.5 (#5930)
  website/integrations: Fix header in dokuwiki instructions (#5926)
  providers/oauth2: launch url: if URL parsing fails, return no launch URL (#5918)
  web: bump @babel/core from 7.22.1 to 7.22.5 in /web (#5909)
  web: bump @babel/plugin-proposal-decorators from 7.22.3 to 7.22.5 in /web (#5910)
  web: bump @babel/preset-typescript from 7.21.5 to 7.22.5 in /web (#5912)
  web: bump @babel/preset-env from 7.22.4 to 7.22.5 in /web (#5915)
  core: bump requests-mock from 1.10.0 to 1.11.0 (#5911)
  ...
2023-06-12 09:55:35 -07:00
Ken Sternberg 4ea9b69ab5 web: fix out-of-date comment 2023-06-08 14:38:45 -07:00
Ken Sternberg c48eee0ebf web: add visualizing and testing for the FieldRenderers 2023-06-08 13:43:13 -07:00
Ken Sternberg 0d94373f10 web: password quality indicators
Resolves issue 5165

This commit updates the password match indicator so that the user, and not
the component, makes decisions about the names of the initial and confirmation
inputs.
2023-06-08 11:25:13 -07:00
Ken Sternberg 1c85dc512f Merge branch 'main' into 5165-password-strength-indicator
* main:
  providers/ldap: rework Schema and DSE (#5838)
  web/flows: update default flow background (#5905)
  web: bump @formatjs/intl-listformat from 7.2.2 to 7.3.0 in /web (#5866)
  website/integrations: add account linking note for WriteFreely (#5804)
  web: bump @storybook/addon-essentials from 7.0.18 to 7.0.20 in /web (#5894)
  web: bump @storybook/web-components-vite from 7.0.18 to 7.0.20 in /web (#5895)
  web: bump @storybook/blocks from 7.0.18 to 7.0.20 in /web (#5893)
  web: bump storybook from 7.0.18 to 7.0.20 in /web (#5896)
  website/docs: correct LDAP StartTLS documentation (#5886)
  core: bump python from 3.11.3-slim-bullseye to 3.11.4-slim-bullseye (#5891)
  ci: bump docker/setup-qemu-action from 2.1.0 to 2.2.0 (#5892)
  core: bump selenium from 4.9.1 to 4.10.0 (#5897)
  web: bump pyright from 1.1.312 to 1.1.313 in /web (#5898)
  web: bump @storybook/addon-links from 7.0.18 to 7.0.20 in /web (#5899)
  web: bump @storybook/web-components from 7.0.18 to 7.0.20 in /web (#5900)
  core: bump urllib3 from 2.0.2 to 2.0.3 (#5901)
  core: bump ruff from 0.0.271 to 0.0.272 (#5902)
  core: bump sentry-sdk from 1.25.0 to 1.25.1 (#5903)
2023-06-08 08:42:11 -07:00
Ken Sternberg a71778651f 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.
2023-06-08 08:35:23 -07:00
14 changed files with 1092 additions and 1373 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 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`.

1545
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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 .",
@ -52,7 +53,8 @@
"rapidoc": "^9.3.4", "rapidoc": "^9.3.4",
"style-mod": "^4.0.3", "style-mod": "^4.0.3",
"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.7", "@babel/core": "^7.22.7",
@ -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.61.0", "@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0", "@typescript-eslint/parser": "^5.61.0",
"babel-plugin-macros": "^3.1.0", "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,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>`;

View file

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

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,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",
};

View 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;

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/Divider";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/elements/forms/FormElement";
@ -8,7 +6,6 @@ import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.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 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 { renderCheckbox } from "./FieldRenderers";
import {
renderContinue,
renderPromptHelpText,
renderPromptInner,
shouldRenderInWrapper,
} from "./helpers";
@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[] {
@ -49,234 +53,35 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
]; ];
} }
renderPromptInner(prompt: StagePrompt): TemplateResult { /* TODO: Legacy: None of these refer to the `this` field. Static fields are a code smell. */
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}"> renderPromptInner(prompt: StagePrompt) {
<option value="" ?selected=${prompt.initialValue === ""}> return renderPromptInner(prompt);
${msg("Auto-detect (based on your browser)")}
</option>
${options}
</select>`;
} }
default: renderPromptHelpText(prompt: StagePrompt) {
return html`<p>invalid type '${prompt.type}'</p>`; return renderPromptHelpText(prompt);
} }
} shouldRenderInWrapper(prompt: StagePrompt) {
return shouldRenderInWrapper(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;
} }
renderField(prompt: StagePrompt): TemplateResult { 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) { if (prompt.type === PromptTypeEnum.Checkbox) {
return html`<div class="pf-c-check"> return renderCheckbox(prompt);
<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>`;
} }
if (this.shouldRenderInWrapper(prompt)) {
if (shouldRenderInWrapper(prompt)) {
return html`<ak-form-element return html`<ak-form-element
label="${prompt.label}" label="${prompt.label}"
?required="${prompt.required}" ?required="${prompt.required}"
class="pf-c-form__group" class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]} .errors=${(this.challenge?.responseErrors || {})[prompt.fieldKey]}
> >
${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)} ${renderPromptInner(prompt)} ${renderPromptHelpText(prompt)}
</ak-form-element>`; </ak-form-element>`;
} }
return html` ${this.renderPromptInner(prompt)} ${this.renderPromptHelpText(prompt)}`; return html` ${renderPromptInner(prompt)} ${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>`;
} }
render(): TemplateResult { render(): TemplateResult {
@ -284,6 +89,7 @@ ${prompt.initialValue}</textarea
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}> return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
</ak-empty-state>`; </ak-empty-state>`;
} }
return html`<header class="pf-c-login__main-header"> return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1> <h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header> </header>
@ -302,7 +108,7 @@ ${prompt.initialValue}</textarea
this.challenge?.responseErrors?.non_field_errors || [], this.challenge?.responseErrors?.non_field_errors || [],
) )
: html``} : html``}
${this.renderContinue()} ${renderContinue()}
</form> </form>
</div> </div>
<footer class="pf-c-login__main-footer"> <footer class="pf-c-login__main-footer">

View 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>`;
}