web: Indicate when caps-lock is active during password input. (#12733) Determining the state of the caps-lock key can be tricky as we're dependant on a user-provided input to set a value. Thus, our initial state defaults to not display any warning until the first keystroke. - Revise to better use lit-html. Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
This commit is contained in:
![98988430+gcp-cherry-pick-bot[bot]@users.noreply.github.com](/assets/img/avatar_default.png)
committed by
GitHub

parent
5d391424f7
commit
8f285fbcc5
@ -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);
|
||||
|
27
web/src/elements/utils/focus.ts
Normal file
27
web/src/elements/utils/focus.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @fileoverview Utilities for DOM element interaction, focus management, and event handling.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Recursively check if the target element or any of its children are active (i.e. "focused").
|
||||
*
|
||||
* @param targetElement The element to check if it is active.
|
||||
* @param containerElement The container element to check if the target element is active within.
|
||||
*/
|
||||
export function isActiveElement(
|
||||
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 isActiveElement(containerElement.shadowRoot.activeElement, containerElement);
|
||||
}
|
@ -1,36 +1,93 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base.js";
|
||||
import { bound } from "@goauthentik/elements/decorators/bound";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
import { isActiveElement } from "@goauthentik/elements/utils/focus";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing, render } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { 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";
|
||||
|
||||
/**
|
||||
* 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<string, VisibilityProps>;
|
||||
|
||||
@customElement("ak-flow-input-password")
|
||||
export class InputPassword extends AKElement {
|
||||
static get styles() {
|
||||
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<string, string> = {};
|
||||
|
||||
@ -41,113 +98,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<HTMLInputElement> = createRef();
|
||||
|
||||
toggleVisibilityRef: Ref<HTMLButtonElement> = 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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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<typeof setInterval>;
|
||||
|
||||
/**
|
||||
* 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 (isActiveElement(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");
|
||||
}
|
||||
|
||||
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`<i class="fas fa-eye" aria-hidden="true"></i>`
|
||||
: html`<i class="fas fa-eye-slash" aria-hidden="true"></i>`,
|
||||
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.
|
||||
renderVisibilityToggle() {
|
||||
if (!this.allowShowPassword) return nothing;
|
||||
|
||||
if (this.grabFocus) {
|
||||
this.timer = window.setInterval(() => {
|
||||
if (!this.input) {
|
||||
return;
|
||||
const { label, icon } = this.passwordVisible ? Visibility.Mask : Visibility.Reveal;
|
||||
|
||||
return html`<button
|
||||
${ref(this.toggleVisibilityRef)}
|
||||
aria-label=${msg(label)}
|
||||
@click=${this.togglePasswordVisibility}
|
||||
class="pf-c-button pf-m-control"
|
||||
type="button"
|
||||
>
|
||||
<i class="fas ${icon}" aria-hidden="true"></i>
|
||||
</button>`;
|
||||
}
|
||||
// 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;
|
||||
|
||||
renderHelperText() {
|
||||
if (!this.capsLock) return nothing;
|
||||
|
||||
return html`<div
|
||||
class="pf-c-form__helper-text"
|
||||
id="helper-text-form-caps-lock-helper"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="pf-c-helper-text">
|
||||
<div class="pf-c-helper-text__item pf-m-warning">
|
||||
<span class="pf-c-helper-text__item-icon">
|
||||
<i class="fas fa-fw fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
</span>
|
||||
|
||||
<span class="pf-c-helper-text__item-text">${msg("Caps Lock is enabled.")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -157,22 +316,34 @@ export class InputPassword extends AKElement {
|
||||
class="pf-c-form__group"
|
||||
.errors=${this.errors}
|
||||
>
|
||||
<div class="pf-c-form__group-control">
|
||||
<div class="pf-c-input-group">
|
||||
${this.renderInput()}
|
||||
${this.allowShowPassword
|
||||
? html` <button
|
||||
id="${this.inputId}-visibility-toggle"
|
||||
class="pf-c-button pf-m-control ak-stage-password-toggle-visibility"
|
||||
type="button"
|
||||
aria-label=${msg("Show password")}
|
||||
@click=${(ev: PointerEvent) => this.togglePasswordVisibility(ev)}
|
||||
>
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>`
|
||||
: nothing}
|
||||
<input
|
||||
type=${this.passwordVisible ? "text" : "password"}
|
||||
id=${this.inputId}
|
||||
name=${this.name}
|
||||
placeholder=${this.placeholder}
|
||||
autocomplete="current-password"
|
||||
class="${classMap({
|
||||
"pf-c-form-control": true,
|
||||
"pf-m-icon": true,
|
||||
"pf-m-caps-lock": this.capsLock,
|
||||
})}"
|
||||
required
|
||||
aria-invalid=${ifDefined(this.invalid)}
|
||||
value=${this.initialValue}
|
||||
${ref(this.inputRef)}
|
||||
/>
|
||||
|
||||
${this.renderVisibilityToggle()}
|
||||
</div>
|
||||
|
||||
${this.renderHelperText()}
|
||||
</div>
|
||||
</ak-form-element>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
Reference in New Issue
Block a user