web: provide 'show password' button (#10337)
* web: fix esbuild issue with style sheets Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious pain. This fix better identifies the value types (instances) being passed from various sources in the repo to the three *different* kinds of style processors we're using (the native one, the polyfill one, and whatever the heck Storybook does internally). Falling back to using older CSS instantiating techniques one era at a time seems to do the trick. It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content (FLoUC), it's the logic with which we're left. In standard mode, the following warning appears on the console when running a Flow: ``` Autofocus processing was blocked because a document already has a focused element. ``` In compatibility mode, the following **error** appears on the console when running a Flow: ``` crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'. at initDomMutationObservers (crawler-inject.js:1106:18) at crawler-inject.js:1114:24 at Array.forEach (<anonymous>) at initDomMutationObservers (crawler-inject.js:1114:10) at crawler-inject.js:1549:1 initDomMutationObservers @ crawler-inject.js:1106 (anonymous) @ crawler-inject.js:1114 initDomMutationObservers @ crawler-inject.js:1114 (anonymous) @ crawler-inject.js:1549 ``` Despite this error, nothing seems to be broken and flows work as anticipated. * web: provide `show password` on login page Provide a `show password` icon, text, and button for the password field both in the IdentificationStage and the PasswordStage. Essentially the same code for both, although the id of the password field is unique to each. Requested by Cloudflare. Seems to be a common thing anyway. Should it be an administrative option that this facility is available? From where should I derive that information? I suspect the answer is "a site attribute," but I'd like to get confirmation. * web: comment doesn't need to be exposed. It's sufficient where it is . * web: fix button rendering issues During testing, the buttons did not change as expected. We are using pure DOM state to control the look of the button, and avoiding using `.requestUpdate()` to avoid losing customer input, so depending upon Lit to re-render just the button was an error. This commit goes old-school and updates the button's label and icon using standard DOM features, although we do lean into Lit-html`s `render()` function to create the DOM component for the icon. * web: provide `show password` on login page Provide a `show password` icon, text, and button for the password field both in the IdentificationStage and the PasswordStage. Essentially the same code for both, although the id of the password field is unique to each. Provide a configuration detail server-side to allow administrator to enable or disable the 'show password' feature. Off by default. Requested by Cloudflare. Seems to be a common thing anyway. Making it configurable wasn't in Cloudfare's request, but it seemed logical to add. * ensure the tests pass; quibbling over the wording of the admin field continues. * Removed some manually identified fluff. * web: break out `show password`-enabled input field into its own component Provides a `show password` field, but as a LightDOM-oriented web component. This form of input[type="password"] is for flows only, as it has a number of specializations for understanding a flow's validating round-trip, possible error messages within the challenge, and is left within the LightDOM both to support compatibility issues and to avoid using `elementInterals`, which is a DOM feature not supported by some older browsers. Avoids having to maintain two different instances of the same logic, both for permitting 'show password', and for handling it. * web: update PasswordStageForm according to lit-analyzer With lit-analyzer in the mix and functional, we're seeing new complaints about inconsistent typing in lit objects, and this was one of them. * Another lit-analyze error found.
This commit is contained in:
@ -45,6 +45,7 @@ class TestFlowInspector(APITestCase):
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{
|
||||
"allow_show_password": False,
|
||||
"component": "ak-stage-identification",
|
||||
"flow_info": {
|
||||
"background": flow.background_url,
|
||||
|
@ -38,10 +38,11 @@ class IdentificationStage(Stage):
|
||||
help_text=_(
|
||||
(
|
||||
"When set, shows a password field, instead of showing the "
|
||||
"password field as seaprate step."
|
||||
"password field as separate step."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
case_insensitive_matching = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_("When enabled, user fields are matched regardless of their casing."),
|
||||
|
@ -64,6 +64,7 @@ class IdentificationChallenge(Challenge):
|
||||
|
||||
user_fields = ListField(child=CharField(), allow_empty=True, allow_null=True)
|
||||
password_fields = BooleanField()
|
||||
allow_show_password = BooleanField(default=False)
|
||||
application_pre = CharField(required=False)
|
||||
flow_designation = ChoiceField(FlowDesignation.choices)
|
||||
|
||||
@ -197,6 +198,8 @@ class IdentificationStageView(ChallengeStageView):
|
||||
"primary_action": self.get_primary_action(),
|
||||
"user_fields": current_stage.user_fields,
|
||||
"password_fields": bool(current_stage.password_stage),
|
||||
"allow_show_password": bool(current_stage.password_stage)
|
||||
and current_stage.password_stage.allow_show_password,
|
||||
"show_source_labels": current_stage.show_source_labels,
|
||||
"flow_designation": self.executor.flow.designation,
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ class PasswordStageSerializer(StageSerializer):
|
||||
"backends",
|
||||
"configure_flow",
|
||||
"failed_attempts_before_cancel",
|
||||
"allow_show_password",
|
||||
]
|
||||
|
||||
|
||||
@ -28,6 +29,7 @@ class PasswordStageViewSet(UsedByMixin, ModelViewSet):
|
||||
"name",
|
||||
"configure_flow",
|
||||
"failed_attempts_before_cancel",
|
||||
"allow_show_password",
|
||||
]
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.0.6 on 2024-07-02 18:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_password", "0008_replace_inbuilt"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="passwordstage",
|
||||
name="allow_show_password",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text="When enabled, provides a 'show password' button with the password input field.",
|
||||
),
|
||||
),
|
||||
]
|
@ -43,6 +43,12 @@ class PasswordStage(ConfigurableStage, Stage):
|
||||
"To lock the user out, use a reputation policy and a user_write stage."
|
||||
),
|
||||
)
|
||||
allow_show_password = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"When enabled, provides a 'show password' button with the password input field."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
|
@ -9,7 +9,7 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -76,6 +76,8 @@ class PasswordChallenge(WithUserInfoChallenge):
|
||||
|
||||
component = CharField(default="ak-stage-password")
|
||||
|
||||
allow_show_password = BooleanField(default=False)
|
||||
|
||||
|
||||
class PasswordChallengeResponse(ChallengeResponse):
|
||||
"""Password challenge response"""
|
||||
@ -134,7 +136,11 @@ class PasswordStageView(ChallengeStageView):
|
||||
response_class = PasswordChallengeResponse
|
||||
|
||||
def get_challenge(self) -> Challenge:
|
||||
challenge = PasswordChallenge(data={})
|
||||
challenge = PasswordChallenge(
|
||||
data={
|
||||
"allow_show_password": self.executor.current_stage.allow_show_password,
|
||||
}
|
||||
)
|
||||
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
|
||||
if recovery_flow.exists():
|
||||
recover_url = reverse(
|
||||
|
@ -6905,7 +6905,7 @@
|
||||
"password_stage": {
|
||||
"type": "integer",
|
||||
"title": "Password stage",
|
||||
"description": "When set, shows a password field, instead of showing the password field as seaprate step."
|
||||
"description": "When set, shows a password field, instead of showing the password field as separate step."
|
||||
},
|
||||
"case_insensitive_matching": {
|
||||
"type": "boolean",
|
||||
@ -7207,6 +7207,11 @@
|
||||
"maximum": 2147483647,
|
||||
"title": "Failed attempts before cancel",
|
||||
"description": "How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage."
|
||||
},
|
||||
"allow_show_password": {
|
||||
"type": "boolean",
|
||||
"title": "Allow show password",
|
||||
"description": "When enabled, provides a 'show password' button with the password input field."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
28
schema.yml
28
schema.yml
@ -29993,6 +29993,10 @@ paths:
|
||||
operationId: stages_password_list
|
||||
description: PasswordStage Viewset
|
||||
parameters:
|
||||
- in: query
|
||||
name: allow_show_password
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: configure_flow
|
||||
schema:
|
||||
@ -37067,6 +37071,9 @@ components:
|
||||
nullable: true
|
||||
password_fields:
|
||||
type: boolean
|
||||
allow_show_password:
|
||||
type: boolean
|
||||
default: false
|
||||
application_pre:
|
||||
type: string
|
||||
flow_designation:
|
||||
@ -37149,7 +37156,7 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, shows a password field, instead of showing the password
|
||||
field as seaprate step.
|
||||
field as separate step.
|
||||
case_insensitive_matching:
|
||||
type: boolean
|
||||
description: When enabled, user fields are matched regardless of their casing.
|
||||
@ -37217,7 +37224,7 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, shows a password field, instead of showing the password
|
||||
field as seaprate step.
|
||||
field as separate step.
|
||||
case_insensitive_matching:
|
||||
type: boolean
|
||||
description: When enabled, user fields are matched regardless of their casing.
|
||||
@ -40953,6 +40960,9 @@ components:
|
||||
type: string
|
||||
recovery_url:
|
||||
type: string
|
||||
allow_show_password:
|
||||
type: boolean
|
||||
default: false
|
||||
required:
|
||||
- pending_user
|
||||
- pending_user_avatar
|
||||
@ -41235,6 +41245,10 @@ components:
|
||||
minimum: -2147483648
|
||||
description: How many attempts a user has before the flow is canceled. To
|
||||
lock the user out, use a reputation policy and a user_write stage.
|
||||
allow_show_password:
|
||||
type: boolean
|
||||
description: When enabled, provides a 'show password' button with the password
|
||||
input field.
|
||||
required:
|
||||
- backends
|
||||
- component
|
||||
@ -41271,6 +41285,10 @@ components:
|
||||
minimum: -2147483648
|
||||
description: How many attempts a user has before the flow is canceled. To
|
||||
lock the user out, use a reputation policy and a user_write stage.
|
||||
allow_show_password:
|
||||
type: boolean
|
||||
description: When enabled, provides a 'show password' button with the password
|
||||
input field.
|
||||
required:
|
||||
- backends
|
||||
- name
|
||||
@ -42092,7 +42110,7 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, shows a password field, instead of showing the password
|
||||
field as seaprate step.
|
||||
field as separate step.
|
||||
case_insensitive_matching:
|
||||
type: boolean
|
||||
description: When enabled, user fields are matched regardless of their casing.
|
||||
@ -42804,6 +42822,10 @@ components:
|
||||
minimum: -2147483648
|
||||
description: How many attempts a user has before the flow is canceled. To
|
||||
lock the user out, use a reputation policy and a user_write stage.
|
||||
allow_show_password:
|
||||
type: boolean
|
||||
description: When enabled, provides a 'show password' button with the password
|
||||
input field.
|
||||
PatchedPermissionAssignRequest:
|
||||
type: object
|
||||
description: Request to assign a new permission
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
|
||||
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { first } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-switch-input.js";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import "@goauthentik/elements/forms/SearchSelect";
|
||||
@ -9,7 +9,6 @@ import "@goauthentik/elements/forms/SearchSelect";
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import {
|
||||
BackendsEnum,
|
||||
@ -72,10 +71,10 @@ export class PasswordStageForm extends BaseStageForm<PasswordStage> {
|
||||
return html` <span>
|
||||
${msg("Validate the user's password against the selected backend(s).")}
|
||||
</span>
|
||||
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
|
||||
<ak-form-element-horizontal label=${msg("Name")} required name="name">
|
||||
<input
|
||||
type="text"
|
||||
value="${ifDefined(this.instance?.name || "")}"
|
||||
value="${this.instance?.name || ""}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
@ -158,7 +157,7 @@ export class PasswordStageForm extends BaseStageForm<PasswordStage> {
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value="${first(this.instance?.failedAttemptsBeforeCancel, 5)}"
|
||||
value="${this.instance?.failedAttemptsBeforeCancel ?? 5}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
@ -168,6 +167,12 @@ export class PasswordStageForm extends BaseStageForm<PasswordStage> {
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-switch-input
|
||||
name="allowShowPassword"
|
||||
label="Allow Show Password"
|
||||
?checked=${this.instance?.allowShowPassword ?? false}
|
||||
help=${msg("Provide users with a 'show password' button.")}
|
||||
></ak-switch-input>
|
||||
</div>
|
||||
</ak-form-group>`;
|
||||
}
|
||||
|
181
web/src/flow/components/ak-flow-password-input.ts
Normal file
181
web/src/flow/components/ak-flow-password-input.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { AKElement } from "@goauthentik/elements/Base.js";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing, render } from "lit";
|
||||
import { customElement, property } from "lit/decorators.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";
|
||||
|
||||
@customElement("ak-flow-input-password")
|
||||
export class InputPassword extends AKElement {
|
||||
static get styles() {
|
||||
return [PFBase, PFInputGroup, PFFormControl, PFButton];
|
||||
}
|
||||
|
||||
@property({ type: String, attribute: "input-id" })
|
||||
inputId = "ak-stage-password-input";
|
||||
|
||||
@property({ type: String })
|
||||
name = "password";
|
||||
|
||||
@property({ type: String })
|
||||
label = msg("Password");
|
||||
|
||||
@property({ type: String })
|
||||
placeholder = msg("Please enter your password");
|
||||
|
||||
@property({ type: String, attribute: "prefill" })
|
||||
passwordPrefill = "";
|
||||
|
||||
@property({ type: Object })
|
||||
errors: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* Forwarded to the input tag's aria-invalid attribute, if set
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: String })
|
||||
invalid?: string;
|
||||
|
||||
@property({ type: Boolean, attribute: "allow-show-password" })
|
||||
allowShowPassword = false;
|
||||
|
||||
/**
|
||||
* Automatically grab focus after rendering.
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "grab-focus" })
|
||||
grabFocus = false;
|
||||
|
||||
timer?: number;
|
||||
|
||||
input?: HTMLInputElement;
|
||||
|
||||
cleanup(): void {
|
||||
if (this.timer) {
|
||||
console.debug("authentik/stages/password: cleared focus timer");
|
||||
window.clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
if (!passwordField) {
|
||||
throw new Error("ak-flow-password-input: unable to identify input field");
|
||||
}
|
||||
|
||||
passwordField.type = passwordField.type === "password" ? "text" : "password";
|
||||
this.renderPasswordVisibilityFeatures(passwordField);
|
||||
}
|
||||
|
||||
// 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(
|
||||
"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,
|
||||
);
|
||||
}
|
||||
|
||||
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 = true;
|
||||
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;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <ak-form-element
|
||||
label="${this.label}"
|
||||
required
|
||||
class="pf-c-form__group"
|
||||
.errors=${this.errors}
|
||||
>
|
||||
<div class="pf-c-input-group">
|
||||
${this.renderInput()}
|
||||
${this.allowShowPassword
|
||||
? html` <button
|
||||
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}
|
||||
</div>
|
||||
</ak-form-element>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-flow-input-password": InputPassword;
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import { renderSourceIcon } from "@goauthentik/admin/sources/utils";
|
||||
import "@goauthentik/elements/Divider";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
import "@goauthentik/flow/components/ak-flow-password-input.js";
|
||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
@ -12,6 +13,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
@ -45,22 +47,32 @@ export class IdentificationStage extends BaseStage<
|
||||
form?: HTMLFormElement;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton].concat(css`
|
||||
return [
|
||||
PFBase,
|
||||
PFAlert,
|
||||
PFInputGroup,
|
||||
PFLogin,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
PFButton,
|
||||
/* login page's icons */
|
||||
.pf-c-login__main-footer-links-item button {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.pf-c-login__main-footer-links-item img {
|
||||
fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill);
|
||||
width: 100px;
|
||||
max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width);
|
||||
height: 100%;
|
||||
max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height);
|
||||
}
|
||||
`);
|
||||
css`
|
||||
.pf-c-login__main-footer-links-item button {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
.pf-c-login__main-footer-links-item img {
|
||||
fill: var(--pf-c-login__main-footer-links-item-link-svg--Fill);
|
||||
width: 100px;
|
||||
max-width: var(--pf-c-login__main-footer-links-item-link-svg--Width);
|
||||
height: 100%;
|
||||
max-height: var(--pf-c-login__main-footer-links-item-link-svg--Height);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues<this>) {
|
||||
@ -250,22 +262,16 @@ export class IdentificationStage extends BaseStage<
|
||||
</ak-form-element>
|
||||
${this.challenge.passwordFields
|
||||
? html`
|
||||
<ak-form-element
|
||||
label="${msg("Password")}"
|
||||
?required="${true}"
|
||||
<ak-flow-input-password
|
||||
label=${msg("Password")}
|
||||
inputId="ak-stage-identification-password"
|
||||
required
|
||||
grab-focus
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge.responseErrors || {})["password"]}
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="${msg("Password")}"
|
||||
autocomplete="current-password"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
value=${PasswordManagerPrefill.password || ""}
|
||||
/>
|
||||
</ak-form-element>
|
||||
.errors=${(this.challenge?.responseErrors || {})["password"]}
|
||||
?allow-show-password=${this.challenge.allowShowPassword}
|
||||
prefill=${PasswordManagerPrefill["password"] ?? ""}
|
||||
></ak-flow-input-password>
|
||||
`
|
||||
: nothing}
|
||||
${"non_field_errors" in (this.challenge?.responseErrors || {})
|
||||
|
@ -1,6 +1,7 @@
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
import "@goauthentik/flow/FormStatic";
|
||||
import "@goauthentik/flow/components/ak-flow-password-input.js";
|
||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||
import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage";
|
||||
|
||||
@ -12,6 +13,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
@ -21,62 +23,14 @@ import { PasswordChallenge, PasswordChallengeResponseRequest } from "@goauthenti
|
||||
@customElement("ak-stage-password")
|
||||
export class PasswordStage extends BaseStage<PasswordChallenge, PasswordChallengeResponseRequest> {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFLogin, PFForm, PFFormControl, PFButton, PFTitle];
|
||||
return [PFBase, PFLogin, PFInputGroup, PFForm, PFFormControl, PFButton, PFTitle];
|
||||
}
|
||||
|
||||
input?: HTMLInputElement;
|
||||
|
||||
timer?: number;
|
||||
|
||||
hasError(field: string): boolean {
|
||||
const errors = (this.challenge?.responseErrors || {})[field];
|
||||
return (errors || []).length > 0;
|
||||
}
|
||||
|
||||
renderInput(): HTMLInputElement {
|
||||
this.input = document.createElement("input");
|
||||
this.input.type = "password";
|
||||
this.input.name = "password";
|
||||
this.input.placeholder = msg("Please enter your password");
|
||||
this.input.autofocus = true;
|
||||
this.input.autocomplete = "current-password";
|
||||
this.input.classList.add("pf-c-form-control");
|
||||
this.input.required = true;
|
||||
this.input.value = PasswordManagerPrefill.password || "";
|
||||
this.input.setAttribute("aria-invalid", this.hasError("password").toString());
|
||||
// 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.
|
||||
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;
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
if (this.timer) {
|
||||
console.debug("authentik/stages/password: cleared focus timer");
|
||||
window.clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
if (!this.challenge) {
|
||||
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
|
||||
@ -109,14 +63,16 @@ export class PasswordStage extends BaseStage<PasswordChallenge, PasswordChalleng
|
||||
type="hidden"
|
||||
value="${this.challenge.pendingUser}"
|
||||
/>
|
||||
<ak-form-element
|
||||
label="${msg("Password")}"
|
||||
?required="${true}"
|
||||
<ak-flow-input-password
|
||||
label=${msg("Password")}
|
||||
required
|
||||
grab-focus
|
||||
class="pf-c-form__group"
|
||||
.errors=${(this.challenge?.responseErrors || {})["password"]}
|
||||
>
|
||||
${this.renderInput()}
|
||||
</ak-form-element>
|
||||
?allow-show-password=${this.challenge.allowShowPassword}
|
||||
invalid=${this.hasError("password").toString()}
|
||||
prefill=${PasswordManagerPrefill["password"] ?? ""}
|
||||
></ak-flow-input-password>
|
||||
|
||||
${this.challenge.recoveryUrl
|
||||
? html`<a href="${this.challenge.recoveryUrl}">
|
||||
|
Reference in New Issue
Block a user