diff --git a/web/src/common/styles/authentik.css b/web/src/common/styles/authentik.css index 9804d343f3f8..30c7002bb1ff 100644 --- a/web/src/common/styles/authentik.css +++ b/web/src/common/styles/authentik.css @@ -45,6 +45,8 @@ html > form > input { left: -2000px; } +/*#region Icons*/ + .pf-icon { display: inline-block; font-style: normal; @@ -54,6 +56,18 @@ html > form > input { vertical-align: middle; } +.pf-c-form-control { + --pf-c-form-control--m-caps-lock--BackgroundUrl: url("data:image/svg+xml;charset=utf8,%3Csvg fill='%23aaabac' viewBox='0 0 56 56' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M 20.7812 37.6211 L 35.2421 37.6211 C 38.5233 37.6211 40.2577 35.6992 40.2577 32.6055 L 40.2577 28.4570 L 49.1404 28.4570 C 51.0859 28.4570 52.6329 27.3086 52.6329 25.5039 C 52.6329 24.4024 52.0703 23.5351 51.0158 22.6211 L 30.9062 4.8789 C 29.9452 4.0351 29.0546 3.4727 27.9999 3.4727 C 26.9687 3.4727 26.0780 4.0351 25.1171 4.8789 L 4.9843 22.6445 C 3.8828 23.6055 3.3671 24.4024 3.3671 25.5039 C 3.3671 27.3086 4.9140 28.4570 6.8828 28.4570 L 15.7421 28.4570 L 15.7421 32.6055 C 15.7421 35.6992 17.4999 37.6211 20.7812 37.6211 Z M 21.1562 34.0820 C 20.2655 34.0820 19.6562 33.4961 19.6562 32.6055 L 19.6562 25.7149 C 19.6562 25.1524 19.4452 24.9180 18.8828 24.9180 L 8.6640 24.9180 C 8.4999 24.9180 8.4296 24.8476 8.4296 24.7305 C 8.4296 24.6367 8.4530 24.5430 8.5702 24.4492 L 27.5077 7.9961 C 27.7187 7.8086 27.8359 7.7383 27.9999 7.7383 C 28.1640 7.7383 28.3046 7.8086 28.4921 7.9961 L 47.4532 24.4492 C 47.5703 24.5430 47.5939 24.6367 47.5939 24.7305 C 47.5939 24.8476 47.4998 24.9180 47.3356 24.9180 L 37.1406 24.9180 C 36.5780 24.9180 36.3671 25.1524 36.3671 25.7149 L 36.3671 32.6055 C 36.3671 33.4727 35.7109 34.0820 34.8671 34.0820 Z M 19.7733 52.5273 L 36.0624 52.5273 C 38.7577 52.5273 40.3046 51.0273 40.3046 48.3086 L 40.3046 44.9336 C 40.3046 42.2148 38.7577 40.6680 36.0624 40.6680 L 19.7733 40.6680 C 17.0546 40.6680 15.5077 42.2383 15.5077 44.9336 L 15.5077 48.3086 C 15.5077 51.0039 17.0546 52.5273 19.7733 52.5273 Z M 20.3124 49.2227 C 19.4921 49.2227 19.0468 48.8008 19.0468 47.9805 L 19.0468 45.2617 C 19.0468 44.4414 19.4921 43.9727 20.3124 43.9727 L 35.5233 43.9727 C 36.3202 43.9727 36.7655 44.4414 36.7655 45.2617 L 36.7655 47.9805 C 36.7655 48.8008 36.3202 49.2227 35.5233 49.2227 Z'/%3E%3C/svg%3E"); +} + +.pf-c-form-control.pf-m-icon.pf-m-caps-lock { + --pf-c-form-control--m-icon--BackgroundUrl: var( + --pf-c-form-control--m-caps-lock--BackgroundUrl + ); +} + +/*#endregion*/ + .pf-c-page__header { z-index: 0; background-color: var(--ak-dark-background-light); diff --git a/web/src/flow/components/ak-flow-password-input.ts b/web/src/flow/components/ak-flow-password-input.ts index a4190a2519b0..7ac6dc550d17 100644 --- a/web/src/flow/components/ak-flow-password-input.ts +++ b/web/src/flow/components/ak-flow-password-input.ts @@ -1,36 +1,113 @@ import { AKElement } from "@goauthentik/elements/Base.js"; +import { bound } from "@goauthentik/elements/decorators/bound"; import "@goauthentik/elements/forms/FormElement"; import { msg } from "@lit/localize"; -import { html, nothing, render } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { CSSResult, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { classMap } from "lit/directives/class-map.js"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { Ref, createRef, ref } from "lit/directives/ref.js"; import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; +/** + * Recursively check if the target element is the active element. + * + * @param targetElement The element to check if it is the active element. + * @param containerElement The container element to check if the target element is active in. + */ +function isActive(targetElement: Element | null, containerElement: Element | null): boolean { + // Does the container element even exist? + if (!containerElement) return false; + + // Does the container element have a shadow root? + if (!("shadowRoot" in containerElement)) return false; + if (containerElement.shadowRoot === null) return false; + + // Is the target element the active element? + if (containerElement.shadowRoot.activeElement === targetElement) return true; + + // Let's check the children of the container element... + return isActive(containerElement.shadowRoot.activeElement, containerElement); +} + +/** + * A configuration object for the visibility states of the password input. + */ +interface VisibilityProps { + icon: string; + label: string; +} + +/** + * Enum-like object for the visibility states of the password input. + */ +const Visibility = { + Reveal: { + icon: "fa-eye", + label: msg("Show password"), + }, + Mask: { + icon: "fa-eye-slash", + label: msg("Hide password"), + }, +} as const satisfies Record; + @customElement("ak-flow-input-password") export class InputPassword extends AKElement { - static get styles() { + static get styles(): CSSResult[] { return [PFBase, PFInputGroup, PFFormControl, PFButton]; } + //#region Properties + + /** + * The ID of the input field. + * + * @attr + */ @property({ type: String, attribute: "input-id" }) inputId = "ak-stage-password-input"; + /** + * The name of the input field. + * + * @attr + */ @property({ type: String }) name = "password"; + /** + * The label for the input field. + * + * @attr + */ @property({ type: String }) label = msg("Password"); + /** + * The placeholder text for the input field. + * + * @attr + */ @property({ type: String }) placeholder = msg("Please enter your password"); + /** + * The initial value of the input field. + * + * @attr + */ @property({ type: String, attribute: "prefill" }) - passwordPrefill = ""; + initialValue = ""; + /** + * The errors for the input field. + */ @property({ type: Object }) errors: Record = {}; @@ -41,113 +118,215 @@ export class InputPassword extends AKElement { @property({ type: String }) invalid?: string; + /** + * Whether to allow the user to toggle the visibility of the password. + * + * @attr + */ @property({ type: Boolean, attribute: "allow-show-password" }) allowShowPassword = false; + /** + * Whether the password is currently visible. + * + * @attr + */ + @property({ type: Boolean, attribute: "password-visible" }) + passwordVisible = false; + /** * Automatically grab focus after rendering. + * * @attr */ @property({ type: Boolean, attribute: "grab-focus" }) grabFocus = false; - timer?: number; + //#endregion - input?: HTMLInputElement; + //#region Refs - cleanup(): void { - if (this.timer) { - console.debug("authentik/stages/password: cleared focus timer"); - window.clearInterval(this.timer); - this.timer = undefined; + inputRef: Ref = createRef(); + + toggleVisibilityRef: Ref = createRef(); + + //#endregion + + //#region State + + /** + * Whether the caps lock key is enabled. + */ + @state() + capsLock = false; + + //#endregion + + //#region Listeners + + /** + * Toggle the visibility of the password field. + * + * Directly affects the DOM, so no `.requestUpdate()` required. Effect is immediately visible. + * + * @param event The event that triggered the visibility toggle. + */ + @bound + togglePasswordVisibility(event?: PointerEvent) { + event?.stopPropagation(); + event?.preventDefault(); + + const input = this.inputRef.value; + + if (!input) { + console.warn("ak-flow-password-input: unable to identify input field"); + + return; } + + input.type = input.type === "password" ? "text" : "password"; + + this.syncVisibilityToggle(input); + } + + /** + * Listen for key events, synchronizing the caps lock indicators. + */ + @bound + capsLockListener(event: KeyboardEvent) { + this.capsLock = event.getModifierState("CapsLock"); + } + + //#region Lifecycle + + /** + * Interval ID for the focus observer. + * + * @see {@linkcode observeInputFocus} + */ + inputFocusIntervalID?: ReturnType; + + /** + * Periodically attempt to focus the input field until it is focused. + * + * This is some-what of a crude way to get autofocus, but in most cases + * the `autofocus` attribute isn't enough, due to timing within shadow doms and such. + */ + observeInputFocus(): void { + this.inputFocusIntervalID = setInterval(() => { + const input = this.inputRef.value; + + if (!input) return; + + if (isActive(input, document.activeElement)) { + console.debug("authentik/stages/password: cleared focus observer"); + clearInterval(this.inputFocusIntervalID); + } + + input.focus(); + }, 10); + + console.debug("authentik/stages/password: started focus observer"); } - // Must support both older browsers and shadyDom; we'll keep using this in-line, but it'll still - // be in the scope of the parent element, not an independent shadowDOM. + connectedCallback() { + super.connectedCallback(); + + this.observeInputFocus(); + + addEventListener("keydown", this.capsLockListener); + addEventListener("keyup", this.capsLockListener); + } + + disconnectedCallback() { + clearInterval(this.inputFocusIntervalID); + + super.disconnectedCallback(); + + removeEventListener("keydown", this.capsLockListener); + removeEventListener("keyup", this.capsLockListener); + } + + //#endregion + + //#region Render + + /** + * Create the render root for the password input. + * + * Must support both older browsers and shadyDom; we'll keep using this in-line, + * but it'll still be in the scope of the parent element, not an independent shadowDOM. + */ createRenderRoot() { return this; } - // State is saved in the DOM, and read from the DOM. Directly affects the DOM, - // so no `.requestUpdate()` required. Effect is immediately visible. - togglePasswordVisibility(ev: PointerEvent) { - const passwordField = this.renderRoot.querySelector(`#${this.inputId}`) as HTMLInputElement; - ev.stopPropagation(); - ev.preventDefault(); + /** + * Render the password visibility toggle button. + * + * In the unlikely event that we want to make "show password" the _default_ behavior, + * this effect handler is broken out into its own method. + * + * The current behavior in the main {@linkcode render} method assumes the field is of type "password." + * + * To have this effect, er, take effect, call it in an {@linkcode updated} method. + * + * @param input The password field to render the visibility features for. + */ + syncVisibilityToggle(input: HTMLInputElement | undefined = this.inputRef.value): void { + if (!input) return; - if (!passwordField) { - throw new Error("ak-flow-password-input: unable to identify input field"); - } + const toggleElement = this.toggleVisibilityRef.value; - passwordField.type = passwordField.type === "password" ? "text" : "password"; - this.renderPasswordVisibilityFeatures(passwordField); - } + if (!toggleElement) return; - // In the unlikely event that we want to make "show password" the _default_ behavior, this - // effect handler is broken out into its own method. The current behavior in the main - // `.render()` method assumes the field is of type "password." To have this effect, er, take - // effect, call it in an `.updated()` method. - renderPasswordVisibilityFeatures(passwordField: HTMLInputElement) { - const toggleId = `#${this.inputId}-visibility-toggle`; - const visibilityToggle = this.renderRoot.querySelector(toggleId) as HTMLButtonElement; - if (!visibilityToggle) { - return; - } - const show = passwordField.type === "password"; - visibilityToggle?.setAttribute( + const masked = input.type === "password"; + + toggleElement.setAttribute( "aria-label", - show ? msg("Show password") : msg("Hide password"), - ); - visibilityToggle?.querySelector("i")?.remove(); - render( - show - ? html`` - : html``, - visibilityToggle, + msg(masked ? Visibility.Reveal.label : Visibility.Mask.label), ); + + const iconElement = toggleElement.querySelector("i")!; + + iconElement.classList.remove(Visibility.Mask.icon, Visibility.Reveal.icon); + iconElement.classList.add(masked ? Visibility.Reveal.icon : Visibility.Mask.icon); } - renderInput(): HTMLInputElement { - this.input = document.createElement("input"); - this.input.id = `${this.inputId}`; - this.input.type = "password"; - this.input.name = this.name; - this.input.placeholder = this.placeholder; - this.input.autofocus = this.grabFocus; - this.input.autocomplete = "current-password"; - this.input.classList.add("pf-c-form-control"); - this.input.required = true; - this.input.value = this.passwordPrefill ?? ""; - if (this.invalid) { - this.input.setAttribute("aria-invalid", this.invalid); - } - // This is somewhat of a crude way to get autofocus, but in most cases the `autofocus` attribute - // isn't enough, due to timing within shadow doms and such. - - if (this.grabFocus) { - this.timer = window.setInterval(() => { - if (!this.input) { - return; - } - // Because activeElement behaves differently with shadow dom - // we need to recursively check - const rootEl = document.activeElement; - const isActive = (el: Element | null): boolean => { - if (!rootEl) return false; - if (!("shadowRoot" in rootEl)) return false; - if (rootEl.shadowRoot === null) return false; - if (rootEl.shadowRoot.activeElement === el) return true; - return isActive(rootEl.shadowRoot.activeElement); - }; - if (isActive(this.input)) { - this.cleanup(); - } - this.input.focus(); - }, 10); - console.debug("authentik/stages/password: started focus timer"); - } - return this.input; + renderVisibilityToggle() { + if (!this.allowShowPassword) return nothing; + + const { label, icon } = this.passwordVisible ? Visibility.Mask : Visibility.Reveal; + + return html``; + } + + renderHelperText() { + if (!this.capsLock) return nothing; + + return html`
+
+
+ + + + + ${msg("Caps Lock is enabled.")} +
+
+
`; } render() { @@ -157,22 +336,34 @@ export class InputPassword extends AKElement { class="pf-c-form__group" .errors=${this.errors} > -
- ${this.renderInput()} - ${this.allowShowPassword - ? html` ` - : nothing} +
+
+ + + ${this.renderVisibilityToggle()} +
+ + ${this.renderHelperText()}
`; } + + //#endregion } declare global {