Compare commits

...

8 Commits

Author SHA1 Message Date
Teffen Ellis
13af3e29b6 web: Fix issues surrounding iframe lifecycles, loading states, captchas. 2025-07-09 12:38:23 +02:00
Teffen Ellis
50ba50f63b web: Fix issue where websocket has stale reference after reconnecting. 2025-07-09 12:35:41 +02:00
Teffen Ellis
1d66c87505 web: Clean up flow stage imports. 2025-07-09 12:35:41 +02:00
Teffen Ellis
799a2a6bde web: Flesh out captcha clean up. 2025-07-09 12:35:41 +02:00
Teffen Ellis
de19df00ab web: Fix dangling <div>. Clean up submit listeners. 2025-07-09 12:35:41 +02:00
Teffen Ellis
2f097b8554 web: Fix issues surrounding form submission, field validation. 2025-07-09 12:35:40 +02:00
Teffen Ellis
56fb8025d0 web: Fix issue where setting password warns of missing username. 2025-07-09 12:35:40 +02:00
Teffen Ellis
7529ab8fcf web: Clean up file methods. 2025-07-09 12:35:37 +02:00
64 changed files with 1638 additions and 1332 deletions

View File

@@ -54,8 +54,6 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
@property({ type: Boolean })
public apiDrawerOpen = getURLParam("apiDrawerOpen", false);
protected readonly ws: WebsocketClient;
@property({ type: Object, attribute: false })
public user?: SessionUser;
@@ -75,6 +73,8 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
this.sidebarOpen = event.matches;
};
#ws: WebsocketClient | null = null;
//#endregion
//#region Styles
@@ -129,7 +129,9 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
constructor() {
configureSentry(true);
super();
this.ws = new WebsocketClient();
this.#ws = WebsocketClient.connect();
this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)");
this.sidebarOpen = this.#sidebarMatcher.matches;
}
@@ -159,6 +161,8 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
public disconnectedCallback(): void {
super.disconnectedCallback();
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
this.#ws?.close();
}
async firstUpdated(): Promise<void> {

View File

@@ -12,7 +12,7 @@ import "#elements/buttons/SpinnerButton/ak-spinner-button";
import "#elements/forms/ModalForm";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { CSSResult, html, nothing } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
@@ -30,56 +30,46 @@ import { AdminApi, Settings } from "@goauthentik/api";
@customElement("ak-admin-settings")
export class AdminSettingsPage extends AKElement {
static get styles() {
return [
PFBase,
PFButton,
PFPage,
PFGrid,
PFContent,
PFCard,
PFDescriptionList,
PFForm,
PFFormControl,
PFBanner,
];
}
static styles: CSSResult[] = [
PFBase,
PFButton,
PFPage,
PFGrid,
PFContent,
PFCard,
PFDescriptionList,
PFForm,
PFFormControl,
PFBanner,
];
@query("ak-admin-settings-form#form")
form?: AdminSettingsForm;
protected form?: AdminSettingsForm;
@state()
settings?: Settings;
protected settings?: Settings;
constructor() {
super();
AdminSettingsPage.fetchSettings().then((settings) => {
this.#refresh();
this.addEventListener("ak-admin-setting-changed", this.#refresh);
}
#refresh = () => {
return new AdminApi(DEFAULT_CONFIG).adminSettingsRetrieve().then((settings) => {
this.settings = settings;
});
this.save = this.save.bind(this);
this.reset = this.reset.bind(this);
this.addEventListener("ak-admin-setting-changed", this.handleUpdate.bind(this));
}
};
static async fetchSettings() {
return await new AdminApi(DEFAULT_CONFIG).adminSettingsRetrieve();
}
#save = () => {
return this.form?.submit(new SubmitEvent("submit")).then(this.#refresh);
};
async handleUpdate() {
this.settings = await AdminSettingsPage.fetchSettings();
}
async save() {
if (!this.form) {
return;
}
await this.form.submit(new Event("submit"));
this.settings = await AdminSettingsPage.fetchSettings();
}
async reset() {
this.form?.resetForm();
}
#reset = () => {
return this.form?.reset();
};
render() {
if (!this.settings) return nothing;
@@ -93,10 +83,10 @@ export class AdminSettingsPage extends AKElement {
</ak-admin-settings-form>
</div>
<div class="pf-c-card__footer">
<ak-spinner-button .callAction=${this.save} class="pf-m-primary"
<ak-spinner-button .callAction=${this.#save} class="pf-m-primary"
>${msg("Save")}</ak-spinner-button
>
<ak-spinner-button .callAction=${this.reset} class="pf-m-secondary"
<ak-spinner-button .callAction=${this.#reset} class="pf-m-secondary"
>${msg("Cancel")}</ak-spinner-button
>
</div>

View File

@@ -43,8 +43,8 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
return (this.result = result);
}
resetForm(): void {
super.resetForm();
reset(): void {
super.reset();
this.result = undefined;
}

View File

@@ -76,7 +76,7 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
});
}
if (this.can(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles().metaIcon;
const icon = this.files().get("metaIcon");
if (icon || this.clearIcon) {
await new CoreApi(DEFAULT_CONFIG).coreApplicationsSetIconCreate({
slug: app.slug,

View File

@@ -5,8 +5,7 @@ import {
WizardNavigationEvent,
WizardUpdateEvent,
} from "@goauthentik/components/ak-wizard/events";
import { KeyUnknown, serializeForm } from "@goauthentik/elements/forms/Form";
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
import { serializeForm } from "@goauthentik/elements/forms/Form";
import { msg } from "@lit/localize";
import { property, query } from "lit/decorators.js";
@@ -19,7 +18,7 @@ import {
type ApplicationWizardStateUpdate,
} from "./types";
export class ApplicationWizardStep extends WizardStep {
export class ApplicationWizardStep<T = Record<string, unknown>> extends WizardStep {
static get styles() {
return [...WizardStep.styles, ...styles];
}
@@ -37,14 +36,11 @@ export class ApplicationWizardStep extends WizardStep {
@query("form")
form!: HTMLFormElement;
get formValues(): KeyUnknown | undefined {
const elements = [
...Array.from(
this.form.querySelectorAll<HorizontalFormElement>("ak-form-element-horizontal"),
),
...Array.from(this.form.querySelectorAll<HTMLElement>("[data-ak-control=true]")),
];
return serializeForm(elements as unknown as NodeListOf<HorizontalFormElement>);
get formValues(): T {
return serializeForm<T>([
...this.form.querySelectorAll("ak-form-element-horizontal"),
...this.form.querySelectorAll("[data-ak-control]"),
]);
}
protected removeErrors(

View File

@@ -7,7 +7,6 @@ import "@goauthentik/components/ak-slug-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import { type NavigableButton, type WizardButton } from "@goauthentik/components/ak-wizard/types";
import { type KeyUnknown } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { isSlug } from "@goauthentik/elements/router/utils.js";
@@ -23,7 +22,7 @@ import { ApplicationWizardStateUpdate, ValidationRecord } from "../types";
const autoTrim = (v: unknown) => (typeof v === "string" ? v.trim() : v);
const trimMany = (o: KeyUnknown, vs: string[]) =>
const trimMany = (o: Record<string, unknown>, vs: string[]) =>
Object.fromEntries(vs.map((v) => [v, autoTrim(o[v])]));
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -4,7 +4,7 @@ import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import { AKElement } from "@goauthentik/elements/Base.js";
import { KeyUnknown, serializeForm } from "@goauthentik/elements/forms/Form";
import { serializeForm } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
@@ -30,14 +30,11 @@ export class ApplicationWizardProviderForm<T extends OneOfProvider> extends AKEl
@query("form#providerform")
form!: HTMLFormElement;
get formValues(): KeyUnknown | undefined {
const elements = [
...Array.from(
this.form.querySelectorAll<HorizontalFormElement>("ak-form-element-horizontal"),
),
...Array.from(this.form.querySelectorAll<HTMLElement>("[data-ak-control=true]")),
];
return serializeForm(elements as unknown as NodeListOf<HorizontalFormElement>);
get formValues() {
return serializeForm([
...this.form.querySelectorAll("ak-form-element-horizontal"),
...this.form.querySelectorAll("[data-ak-control]"),
]);
}
get valid() {

View File

@@ -55,7 +55,7 @@ export class FlowForm extends WithCapabilitiesConfig(ModelForm<Flow, string>) {
}
if (this.can(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles().background;
const icon = this.files().get("background");
if (icon || this.clearBackground) {
await new FlowsApi(DEFAULT_CONFIG).flowsInstancesSetBackgroundCreate({
slug: flow.slug,

View File

@@ -27,7 +27,7 @@ export class FlowImportForm extends Form<Flow> {
}
async send(): Promise<FlowImportResult> {
const file = this.getFormFiles().flow;
const file = this.files().get("flow");
if (!file) {
throw new SentryIgnoredError("No form data");
}

View File

@@ -283,8 +283,14 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
<div class="pf-c-description-list__text">
<ak-forms-modal>
<span slot="submit">${msg("Update password")}</span>
<span slot="header">${msg("Update password")}</span>
<span slot="header">
${msg(
str`Update ${item.name || item.username}'s password`,
)}
</span>
<ak-user-password-form
username=${item.username}
email=${ifDefined(item.email)}
slot="form"
.instancePk=${item.pk}
></ak-user-password-form>

View File

@@ -18,7 +18,7 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
}
async send(data: SAMLProvider): Promise<void> {
const file = this.getFormFiles().metadata;
const file = this.files().get("metadata");
if (!file) {
throw new SentryIgnoredError("No form data");
}

View File

@@ -62,7 +62,7 @@ export class KerberosSourceForm extends WithCapabilitiesConfig(BaseSourceForm<Ke
}
const c = await config();
if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles().icon;
const icon = this.files().get("icon");
if (icon || this.clearIcon) {
await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({
slug: source.slug,

View File

@@ -87,7 +87,7 @@ export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm<OAuth
}
const c = await config();
if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles().icon;
const icon = this.files().get("icon");
if (icon || this.clearIcon) {
await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({
slug: source.slug,

View File

@@ -74,7 +74,7 @@ export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm<PlexSo
});
}
if (this.can(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles().icon;
const icon = this.files().get("icon");
if (icon || this.clearIcon) {
await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({
slug: source.slug,

View File

@@ -62,7 +62,7 @@ export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm<SAMLSo
}
const c = await config();
if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles().icon;
const icon = this.files().get("icon");
if (icon || this.clearIcon) {
await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({
slug: source.slug,

View File

@@ -74,8 +74,8 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
name="privateKey"
label=${msg("Private Key")}
input-hint="code"
required
?revealed=${this.instance === undefined}
?required=${typeof this.instance === "undefined"}
?revealed=${typeof this.instance === "undefined"}
help=${msg(
"Private key, acquired from https://www.google.com/recaptcha/intro/v3.html.",
)}

View File

@@ -70,7 +70,7 @@ export class PromptForm extends ModelForm<Prompt, string> {
async refreshPreview(prompt?: Prompt): Promise<void> {
if (!prompt) {
prompt = this.serializeForm();
prompt = this.serialize();
if (!prompt) {
return;
}

View File

@@ -49,8 +49,8 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
return result;
}
resetForm(): void {
super.resetForm();
reset(): void {
super.reset();
this.result = undefined;
}

View File

@@ -32,6 +32,7 @@ import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
@@ -341,8 +342,14 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
id="update-password-request"
>
<span slot="submit">${msg("Update password")}</span>
<span slot="header">${msg("Update password")}</span>
<span slot="header">
${msg(
str`Update ${item.name || item.username}'s password`,
)}
</span>
<ak-user-password-form
username=${item.username}
email=${ifDefined(item.email)}
slot="form"
.instancePk=${item.pk}
></ak-user-password-form>

View File

@@ -4,32 +4,82 @@ import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { CoreApi, UserPasswordSetRequest } from "@goauthentik/api";
@customElement("ak-user-password-form")
export class UserPasswordForm extends Form<UserPasswordSetRequest> {
@property({ type: Number })
instancePk?: number;
//#region Properties
getSuccessMessage(): string {
@property({ type: Number })
public instancePk?: number;
@property({ type: String })
public label = msg("New Password");
@property({ type: String })
public placeholder = msg("New Password");
@property({ type: String })
public username?: string;
@property({ type: String })
public email?: string;
//#endregion
public override getSuccessMessage(): string {
return msg("Successfully updated password.");
}
async send(data: UserPasswordSetRequest): Promise<void> {
public override async send(data: UserPasswordSetRequest): Promise<void> {
return new CoreApi(DEFAULT_CONFIG).coreUsersSetPasswordCreate({
id: this.instancePk || 0,
userPasswordSetRequest: data,
});
}
//#region Render
renderForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${msg("Password")} required name="password">
<input type="password" value="" class="pf-c-form-control" required />
</ak-form-element-horizontal>`;
return html` ${this.username
? html`<input
hidden
readonly
autocomplete="username"
type="text"
name="username"
value=${this.username}
/>`
: nothing}
${this.email
? html`<input
hidden
autocomplete="email"
readonly
type="email"
name="email"
value=${this.email}
/>`
: nothing}
<ak-form-element-horizontal label=${this.label} required name="password">
<input
type="password"
value=""
class="pf-c-form-control"
required
placeholder=${ifDefined(this.placeholder || this.label)}
aria-label=${this.label}
autocomplete="new-password"
/>
</ak-form-element-horizontal>`;
}
//#endregion
}
declare global {

View File

@@ -234,8 +234,17 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
return html`<div class="ak-button-collection">
<ak-forms-modal size=${PFSize.Medium} id="update-password-request">
<span slot="submit">${msg("Update password")}</span>
<span slot="header">${msg("Update password")}</span>
<ak-user-password-form slot="form" .instancePk=${user.pk}></ak-user-password-form>
<span slot="header">
${msg(str`Update ${user.name || user.username}'s password`)}
</span>
<ak-user-password-form
username=${user.username}
email=${ifDefined(user.email)}
slot="form"
.instancePk=${user.pk}
>
</ak-user-password-form>
<button slot="trigger" class="pf-c-button pf-m-secondary pf-m-block">
<pf-tooltip position="top" content=${msg("Enter a new password for this user")}>
${msg("Set password")}
@@ -378,12 +387,12 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
>
<div class="pf-l-grid pf-m-gutter">
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-4-col-on-xl pf-m-4-col-on-2xl"
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-5-col-on-xl pf-m-5-col-on-2xl"
>
${this.renderUserCard()}
</div>
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-8-col-on-xl pf-m-8-col-on-2xl"
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-7-col-on-xl pf-m-7-col-on-2xl"
>
<div class="pf-c-card__title">
${msg("Actions over the last week (per 8 hours)")}

View File

@@ -102,6 +102,7 @@ export const DOM_PURIFY_STRICT = {
*/
export function renderStaticHTMLUnsafe(untrustedHTML: unknown): string {
const container = document.createElement("html");
render(untrustedHTML, container);
const result = container.innerHTML;

View File

@@ -73,6 +73,12 @@ html > form > input {
/* #endregion */
/* #region Form */
.pf-c-form {
--pf-c-form__group--m-action--MarginTop: var(--pf-global--spacer--form-element);
}
/* #region Icons */
.pf-icon {

View File

@@ -8,59 +8,81 @@ export interface WSMessage {
message_type: string;
}
export class WebsocketClient {
messageSocket?: WebSocket;
retryDelay = 200;
export class WebsocketClient extends WebSocket implements Disposable {
#retryDelay = 200;
constructor(url: string | URL) {
super(url);
this.addEventListener("open", this.#openListener);
this.addEventListener("close", this.#closeListener);
this.addEventListener("message", this.#messageListener);
this.addEventListener("error", this.#errorListener);
}
public static connect(): WebsocketClient | null {
if (navigator.webdriver) {
return null;
}
const apiURL = new URL(globalAK().api.base);
const wsURL = `${window.location.protocol.replace("http", "ws")}//${apiURL.host}${apiURL.pathname}ws/client/`;
constructor() {
try {
this.connect();
return new WebsocketClient(wsURL);
} catch (error) {
console.warn(`authentik/ws: failed to connect to ws ${error}`);
return null;
}
}
connect(): void {
if (navigator.webdriver) return;
const apiURL = new URL(globalAK().api.base);
const wsUrl = `${window.location.protocol.replace("http", "ws")}//${apiURL.host}${apiURL.pathname}ws/client/`;
this.messageSocket = new WebSocket(wsUrl);
this.messageSocket.addEventListener("open", () => {
console.debug(`authentik/ws: connected to ${wsUrl}`);
this.retryDelay = 200;
});
this.messageSocket.addEventListener("close", (e) => {
console.debug("authentik/ws: closed ws connection", e);
if (this.retryDelay > 6000) {
window.dispatchEvent(
new CustomEvent(EVENT_MESSAGE, {
bubbles: true,
composed: true,
detail: {
level: MessageLevel.error,
message: msg("Connection error, reconnecting..."),
},
}),
);
}
setTimeout(() => {
console.debug(`authentik/ws: reconnecting ws in ${this.retryDelay}ms`);
this.connect();
}, this.retryDelay);
this.retryDelay = this.retryDelay * 2;
});
this.messageSocket.addEventListener("message", (e) => {
const data = JSON.parse(e.data);
#errorListener = () => {
this.#retryDelay = this.#retryDelay * 2;
};
#messageListener = (e: MessageEvent<string>) => {
const data: WSMessage = JSON.parse(e.data);
window.dispatchEvent(
new CustomEvent(EVENT_WS_MESSAGE, {
bubbles: true,
composed: true,
detail: data,
}),
);
};
#openListener = () => {
console.debug(`authentik/ws: connected to ${this.url}`);
this.#retryDelay = 200;
};
#closeListener = (e: CloseEvent) => {
console.debug("authentik/ws: closed ws connection", e);
if (this.#retryDelay > 6000) {
window.dispatchEvent(
new CustomEvent(EVENT_WS_MESSAGE, {
bubbles: true,
composed: true,
detail: data as WSMessage,
detail: {
level: MessageLevel.error,
message: msg("Connection error, reconnecting..."),
},
}),
);
});
this.messageSocket.addEventListener("error", () => {
this.retryDelay = this.retryDelay * 2;
});
}
setTimeout(() => {
console.debug(`authentik/ws: reconnecting ws in ${this.#retryDelay}ms`);
WebsocketClient.connect();
}, this.#retryDelay);
this.#retryDelay = this.#retryDelay * 2;
};
public [Symbol.dispose]() {
this.close();
}
}

View File

@@ -1,4 +1,4 @@
import { AKElement } from "./Base";
import { AKElement } from "#elements/Base";
/**
* @class - prototype for all of our hand-made input elements
@@ -26,3 +26,32 @@ export class AkControlElement<T = string | string[]> extends AKElement {
return true;
}
}
export function isControlElement(element: Element | HTMLElement): element is AkControlElement {
if (!(element instanceof HTMLElement)) return false;
if (element instanceof AkControlElement) return true;
return "dataset" in element && element.dataset.akControl === "true";
}
/**
* An HTML element with a name attribute.
*/
export interface NamedElement extends Element {
name: string;
}
export function isNamedElement(element: Element): element is NamedElement {
if (!(element instanceof HTMLElement)) {
return false;
}
return "name" in element.attributes;
}
declare global {
interface HTMLElementTagNameMap {
"[data-ak-control]": AkControlElement;
}
}

View File

@@ -1,3 +1,4 @@
import { Form } from "#elements/forms/Form";
import { PFSize } from "@goauthentik/common/enums.js";
import { AKElement } from "@goauthentik/elements/Base";
import {
@@ -37,6 +38,8 @@ export const MODAL_BUTTON_STYLES = css`
@customElement("ak-modal-button")
export class ModalButton extends AKElement {
//#region Properties
@property()
size: PFSize = PFSize.Large;
@@ -46,6 +49,8 @@ export class ModalButton extends AKElement {
@property({ type: Boolean })
locked = false;
//#endregion
handlerBound = false;
static get styles(): CSSResult[] {
@@ -79,10 +84,8 @@ export class ModalButton extends AKElement {
}
resetForms(): void {
this.querySelectorAll<HTMLFormElement>("[slot=form]").forEach((form) => {
if ("resetForm" in form) {
form?.resetForm();
}
this.querySelectorAll<HTMLFormElement | Form>("[slot=form]").forEach((form) => {
form.reset?.();
});
}
@@ -96,6 +99,8 @@ export class ModalButton extends AKElement {
});
}
//#region Render
renderModalInner(): TemplateResult | typeof nothing {
return html`<slot name="modal"></slot>`;
}
@@ -133,6 +138,8 @@ export class ModalButton extends AKElement {
return html` <slot name="trigger" @click=${() => this.onClick()}></slot>
${this.open ? this.renderModal() : nothing}`;
}
//#endregion
}
declare global {

View File

@@ -1,3 +1,5 @@
import { NamedElement, isControlElement, isNamedElement } from "#elements/AkControlElement";
import { SlottedTemplateResult } from "#elements/types";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "@goauthentik/common/errors/network";
import { MessageLevel } from "@goauthentik/common/messages";
@@ -9,8 +11,9 @@ import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -23,27 +26,29 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { instanceOfValidationError } from "@goauthentik/api";
export interface KeyUnknown {
[key: string]: unknown;
function isIgnored<T extends Element>(element: T) {
if (!(element instanceof HTMLElement)) return false;
return element.dataset.formIgnore === "true";
}
// Literally the only field `assignValue()` cares about.
type HTMLNamedElement = Pick<HTMLInputElement, "name">;
export type AkControlElement<T = string | string[]> = HTMLInputElement & { json: () => T };
const doNotProcess = <T extends HTMLElement>(element: T) => element.dataset.formIgnore === "true";
/**
* Recursively assign `value` into `json` while interpreting the dot-path of `element.name`
*/
function assignValue(element: HTMLNamedElement, value: unknown, json: KeyUnknown): void {
let parent = json;
function assignValue(
element: NamedElement,
value: unknown,
destination: Record<string, unknown>,
): void {
let parent = destination;
if (!element.name?.includes(".")) {
parent[element.name] = value;
return;
}
const nameElements = element.name.split(".");
for (let index = 0; index < nameElements.length - 1; index++) {
const nameEl = nameElements[index];
// Ensure all nested structures exist
@@ -52,6 +57,7 @@ function assignValue(element: HTMLNamedElement, value: unknown, json: KeyUnknown
}
parent = parent[nameEl] as { [key: string]: unknown };
}
parent[nameElements[nameElements.length - 1]] = value;
}
@@ -59,67 +65,75 @@ function assignValue(element: HTMLNamedElement, value: unknown, json: KeyUnknown
* Convert the elements of the form to JSON.[4]
*
*/
export function serializeForm<T extends KeyUnknown>(
elements: NodeListOf<HorizontalFormElement>,
): T | undefined {
const json: { [key: string]: unknown } = {};
elements.forEach((element) => {
export function serializeForm<T = Record<string, unknown>>(elements: Iterable<AKElement>): T {
const json: Record<string, unknown> = {};
Array.from(elements).forEach((element) => {
element.requestUpdate();
if (element.hidden) {
if (element.hidden) return;
if (isNamedElement(element) && isControlElement(element)) {
return assignValue(element, element.json(), json);
}
const inputElement = element.querySelector("[name]");
if (element.hidden || !inputElement || isIgnored(inputElement)) {
return;
}
if ("akControl" in element.dataset) {
assignValue(element, (element as unknown as AkControlElement).json(), json);
return;
if (isNamedElement(element) && isControlElement(inputElement)) {
return assignValue(element, inputElement.json(), json);
}
const inputElement = element.querySelector<AkControlElement>("[name]");
if (element.hidden || !inputElement || doNotProcess(inputElement)) {
return;
}
if ("akControl" in inputElement.dataset) {
assignValue(element, (inputElement as unknown as AkControlElement).json(), json);
return;
}
if (
inputElement.tagName.toLowerCase() === "select" &&
"multiple" in inputElement.attributes
) {
if (inputElement instanceof HTMLSelectElement && inputElement.multiple) {
const selectElement = inputElement as unknown as HTMLSelectElement;
assignValue(
return assignValue(
inputElement,
Array.from(selectElement.selectedOptions).map((v) => v.value),
Array.from(selectElement.selectedOptions, (v) => v.value),
json,
);
} else if (inputElement.tagName.toLowerCase() === "input" && inputElement.type === "date") {
assignValue(inputElement, inputElement.valueAsDate, json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "datetime-local"
) {
assignValue(inputElement, dateToUTC(new Date(inputElement.valueAsNumber)), json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
"type" in inputElement.dataset &&
inputElement.dataset.type === "datetime-local"
) {
// Workaround for Firefox <93, since 92 and older don't support
// datetime-local fields
assignValue(inputElement, dateToUTC(new Date(inputElement.value)), json);
} else if (
inputElement.tagName.toLowerCase() === "input" &&
inputElement.type === "checkbox"
) {
assignValue(inputElement, inputElement.checked, json);
} else if ("selectedFlow" in inputElement) {
assignValue(inputElement, inputElement.value, json);
} else {
assignValue(inputElement, inputElement.value, json);
}
if (inputElement instanceof HTMLInputElement) {
if (inputElement.type === "date") {
return assignValue(inputElement, inputElement.valueAsDate, json);
}
if (inputElement.type === "datetime-local") {
return assignValue(
inputElement,
dateToUTC(new Date(inputElement.valueAsNumber)),
json,
);
}
if ("type" in inputElement.dataset && inputElement.dataset.type === "datetime-local") {
// Workaround for Firefox <93, since 92 and older don't support
// datetime-local fields
return assignValue(inputElement, dateToUTC(new Date(inputElement.value)), json);
}
if (inputElement.type === "checkbox") {
return assignValue(inputElement, inputElement.checked, json);
}
}
if (isNamedElement(inputElement) && "value" in inputElement) {
return assignValue(inputElement, inputElement.value, json);
}
console.error(`authentik/forms: Could not find value for element`, {
element,
inputElement,
json,
});
throw new Error(`Could not find value for element ${inputElement.tagName}`);
});
return json as unknown as T;
}
@@ -153,107 +167,126 @@ export function serializeForm<T extends KeyUnknown>(
*
*
*/
export abstract class Form<T> extends AKElement {
export abstract class Form<T = unknown> extends AKElement {
abstract send(data: T): Promise<unknown>;
viewportCheck = true;
//#region Properties
@property()
successMessage = "";
@property({ type: String })
autocomplete?: AutoFill;
//#endregion
public get form(): HTMLFormElement | null {
return this.shadowRoot?.querySelector("form") || null;
}
@state()
nonFieldErrors?: string[];
static get styles(): CSSResult[] {
return [
PFBase,
PFCard,
PFButton,
PFForm,
PFAlert,
PFInputGroup,
PFFormControl,
PFSwitch,
css`
select[multiple] {
height: 15em;
}
`,
];
}
static styles: CSSResult[] = [
PFBase,
PFCard,
PFButton,
PFForm,
PFAlert,
PFInputGroup,
PFFormControl,
PFSwitch,
css`
select[multiple] {
height: 15em;
}
`,
];
/**
* Called by the render function. Blocks rendering the form if the form is not within the
* Called by the render function.
*
* Blocks rendering the form if the form is not within the
* viewport.
*
* @todo Consider using a observer instead.
*/
get isInViewport(): boolean {
public get isInViewport(): boolean {
const rect = this.getBoundingClientRect();
return rect.x + rect.y + rect.width + rect.height !== 0;
}
getSuccessMessage(): string {
public getSuccessMessage(): string {
return this.successMessage;
}
resetForm(): void {
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
form?.reset();
//#region Public methods
public reset(): void {
const form = this.shadowRoot?.querySelector("form");
return form?.reset();
}
/**
* Return the form elements that may contain filenames. Not sure why this is quite so
* convoluted. There is exactly one case where this is used:
* `./flow/stages/prompt/PromptStage: 147: case PromptTypeEnum.File.`
* Consider moving this functionality to there.
* Return the form elements that may contain filenames.
*/
getFormFiles(): { [key: string]: File } {
const files: { [key: string]: File } = {};
const elements =
this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
"ak-form-element-horizontal",
) || [];
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
public files<T extends PropertyKey = PropertyKey>(): Map<T, File> {
const record = new Map<T, File>();
const elements = this.shadowRoot?.querySelectorAll("ak-form-element-horizontal");
for (const element of elements || []) {
element.requestUpdate();
const inputElement = element.querySelector<HTMLInputElement>("[name]");
if (!inputElement) {
continue;
}
if (inputElement.tagName.toLowerCase() === "input" && inputElement.type === "file") {
if ((inputElement.files || []).length < 1) {
continue;
}
files[element.name] = (inputElement.files || [])[0];
}
const inputElement = element.querySelector<HTMLInputElement>("input[type=file]");
if (!inputElement) continue;
const file = inputElement.files?.[0];
const name = element.name;
if (!file || !name) continue;
record.set(name as T, file);
}
return files;
return record;
}
public checkValidity(): boolean {
return !!this.form?.checkValidity?.();
}
public reportValidity(): boolean {
return !!this.form?.reportValidity?.();
}
/**
* Convert the elements of the form to JSON.[4]
*
*/
serializeForm(): T | undefined {
const elements = this.shadowRoot?.querySelectorAll<HorizontalFormElement>(
"ak-form-element-horizontal",
);
protected serialize(): T | undefined {
const elements = this.shadowRoot?.querySelectorAll("ak-form-element-horizontal");
if (!elements) {
return {} as T;
}
return serializeForm(elements) as T;
return serializeForm<T>(elements) as T;
}
/**
* Serialize and send the form to the destination. The `send()` method must be overridden for
* this to work. If processing the data results in an error, we catch the error, distribute
* field-levels errors to the fields, and send the rest of them to the Notifications.
*
*/
async submit(event: Event): Promise<unknown | undefined> {
public submit(event: SubmitEvent): Promise<unknown | false> {
event.preventDefault();
const data = this.serializeForm();
if (!data) return;
const data = this.serialize();
if (!data) return Promise.resolve(false);
return this.send(data)
.then((response) => {
@@ -327,29 +360,37 @@ export abstract class Form<T> extends AKElement {
});
}
renderFormWrapper(): TemplateResult {
//#endregion
//#region Render
public renderFormWrapper(): TemplateResult {
const inline = this.renderForm();
if (inline) {
return html`<form
class="pf-c-form pf-m-horizontal"
@submit=${(ev: Event) => {
ev.preventDefault();
}}
>
${inline}
</form>`;
if (!inline) {
return html`<slot></slot>`;
}
return html`<slot></slot>`;
return html`<form
class="pf-c-form pf-m-horizontal"
autocomplete=${ifDefined(this.autocomplete)}
@submit=${(event: SubmitEvent) => {
event.preventDefault();
}}
>
${inline}
</form>`;
}
renderForm(): TemplateResult | undefined {
return undefined;
public renderForm(): SlottedTemplateResult | null {
return null;
}
renderNonFieldErrors(): TemplateResult {
public renderNonFieldErrors(): SlottedTemplateResult {
if (!this.nonFieldErrors) {
return html``;
return nothing;
}
return html`<div class="pf-c-form__alert">
${this.nonFieldErrors.map((err) => {
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
@@ -362,14 +403,17 @@ export abstract class Form<T> extends AKElement {
</div>`;
}
renderVisible(): TemplateResult {
public renderVisible(): TemplateResult {
return html` ${this.renderNonFieldErrors()} ${this.renderFormWrapper()}`;
}
render(): TemplateResult {
public render(): SlottedTemplateResult {
if (this.viewportCheck && !this.isInViewport) {
return html``;
return nothing;
}
return this.renderVisible();
}
//#endregion
}

View File

@@ -1,6 +1,6 @@
import { isControlElement } from "#elements/AkControlElement";
import { AKElement } from "@goauthentik/elements/Base";
import { FormGroup } from "@goauthentik/elements/forms/FormGroup";
import { formatSlug } from "@goauthentik/elements/router/utils.js";
import { msg, str } from "@lit/localize";
import { CSSResult, css } from "lit";
@@ -31,12 +31,6 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
* being very few unique uses.
*/
const isAkControl = (el: unknown): boolean =>
el instanceof HTMLElement &&
"dataset" in el &&
el.dataset instanceof DOMStringMap &&
"akControl" in el.dataset;
const nameables = new Set([
"input",
"textarea",
@@ -107,7 +101,7 @@ export class HorizontalFormElement extends AKElement {
input.focus();
});
this.querySelectorAll("*").forEach((input) => {
if (isAkControl(input) && !input.getAttribute("name")) {
if (isControlElement(input) && !input.getAttribute("name")) {
input.setAttribute("name", this.name);
return;
}

View File

@@ -6,37 +6,60 @@ import { ModalHideEvent } from "@goauthentik/elements/controllers/ModalOrchestra
import { Form } from "@goauthentik/elements/forms/Form";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-forms-modal")
export class ModalForm extends ModalButton {
@property({ type: Boolean })
closeAfterSuccessfulSubmit = true;
//#region Properties
@property({ type: Boolean })
showSubmitButton = true;
public closeAfterSuccessfulSubmit = true;
@property({ type: Boolean })
loading = false;
public showSubmitButton = true;
@property({ type: Boolean })
public loading = false;
@property({ type: String })
cancelText = msg("Cancel");
public cancelText = msg("Cancel");
//#endregion
#confirm = async (): Promise<void> => {
const form = this.querySelector<Form>("[slot=form]");
async confirm(): Promise<void> {
const form = this.querySelector<Form<unknown>>("[slot=form]");
if (!form) {
return Promise.reject(msg("No form found"));
throw new Error(msg("No form found"));
}
const formPromise = form.submit(new Event("submit"));
if (!formPromise) {
return Promise.reject(msg("Form didn't return a promise for submitting"));
if (!(form instanceof Form)) {
console.warn("authentik/forms: form inside the form slot is not a Form", form);
throw new Error(msg("Element inside the form slot is not a Form"));
}
if (!form.reportValidity()) {
this.loading = false;
this.locked = false;
return;
}
this.loading = true;
this.locked = true;
const formPromise = form.submit(
new SubmitEvent("submit", {
submitter: this,
}),
);
return formPromise
.then(() => {
if (this.closeAfterSuccessfulSubmit) {
this.open = false;
form?.resetForm();
form?.reset();
// TODO: We may be fetching too frequently.
// Repeat dispatching will prematurely abort refresh listeners and cause several fetches and re-renders.
@@ -47,6 +70,7 @@ export class ModalForm extends ModalButton {
}),
);
}
this.loading = false;
this.locked = false;
})
@@ -56,12 +80,28 @@ export class ModalForm extends ModalButton {
throw error;
});
}
};
#cancel = (): void => {
const defaultInvoked = this.dispatchEvent(new ModalHideEvent(this));
if (defaultInvoked) {
this.resetForms();
}
};
#scrollListener = () => {
window.dispatchEvent(
new CustomEvent("scroll", {
bubbles: true,
}),
);
};
renderModalInner(): TemplateResult {
return html`${this.loading
? html`<ak-loading-overlay topmost></ak-loading-overlay>`
: html``}
: nothing}
<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">
@@ -70,37 +110,16 @@ export class ModalForm extends ModalButton {
</div>
</section>
<slot name="above-form"></slot>
<section
class="pf-c-modal-box__body"
@scroll=${() => {
window.dispatchEvent(
new CustomEvent("scroll", {
bubbles: true,
}),
);
}}
>
<section class="pf-c-modal-box__body" @scroll=${this.#scrollListener}>
<slot name="form"></slot>
</section>
<footer class="pf-c-modal-box__footer">
${this.showSubmitButton
? html`<ak-spinner-button
.callAction=${() => {
this.loading = true;
this.locked = true;
return this.confirm();
}}
class="pf-m-primary"
>
? html`<ak-spinner-button .callAction=${this.#confirm} class="pf-m-primary">
<slot name="submit"></slot> </ak-spinner-button
>&nbsp;`
: html``}
<ak-spinner-button
.callAction=${async () => {
this.dispatchEvent(new ModalHideEvent(this));
}}
class="pf-m-secondary"
>
: nothing}
<ak-spinner-button .callAction=${this.#cancel} class="pf-m-secondary">
${this.cancelText}
</ak-spinner-button>
</footer>`;

View File

@@ -1,3 +1,4 @@
import { SlottedTemplateResult } from "#elements/types";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/elements/EmptyState";
import { Form } from "@goauthentik/elements/forms/Form";
@@ -64,7 +65,7 @@ export abstract class ModelForm<T, PKT extends string | number> extends Form<T>
});
}
resetForm(): void {
reset(): void {
this.instance = undefined;
this._initialLoad = false;
}
@@ -76,7 +77,7 @@ export abstract class ModelForm<T, PKT extends string | number> extends Form<T>
return super.renderVisible();
}
render(): TemplateResult {
render(): SlottedTemplateResult {
// if we're in viewport now and haven't loaded AND have a PK set, load now
// Or if we don't check for viewport in some cases
const viewportVisible = this.isInViewport || !this.viewportCheck;

View File

@@ -16,12 +16,16 @@ export abstract class ProxyForm extends Form<unknown> {
innerElement?: Form<unknown>;
async submit(ev: Event): Promise<unknown | undefined> {
public override get form(): HTMLFormElement | null {
return this.innerElement?.form || null;
}
async submit(ev: SubmitEvent): Promise<unknown | undefined> {
return this.innerElement?.submit(ev);
}
resetForm(): void {
this.innerElement?.resetForm();
reset(): void {
this.innerElement?.reset();
}
getSuccessMessage(): string {
@@ -29,24 +33,31 @@ export abstract class ProxyForm extends Form<unknown> {
}
async requestUpdate(name?: PropertyKey | undefined, oldValue?: unknown): Promise<unknown> {
const result = await super.requestUpdate(name, oldValue);
await this.innerElement?.requestUpdate();
const result = super.requestUpdate(name, oldValue);
this.innerElement?.requestUpdate();
return result;
}
renderVisible(): TemplateResult {
let elementName = this.type;
if (this.type in this.typeMap) {
elementName = this.typeMap[this.type];
}
if (!this.innerElement) {
this.innerElement = document.createElement(elementName) as Form<unknown>;
}
this.innerElement.viewportCheck = this.viewportCheck;
for (const k in this.args) {
this.innerElement.setAttribute(k, this.args[k] as string);
(this.innerElement as unknown as Record<string, unknown>)[k] = this.args[k];
}
return html`${this.innerElement}`;
}
}

View File

@@ -80,15 +80,18 @@ export class TableSearch extends WithLicenseSummary(AKElement) {
return html`<form
class="pf-c-input-group"
method="get"
@submit=${(e: Event) => {
e.preventDefault();
@submit=${(event: SubmitEvent) => {
event.preventDefault();
if (!this.onSearch) return;
const el = this.shadowRoot?.querySelector<HTMLInputElement | HTMLTextAreaElement>(
"[name=search]",
);
if (!el) return;
if (el.value === "") return;
this.onSearch(el?.value);
const element = this.shadowRoot?.querySelector<
HTMLInputElement | HTMLTextAreaElement
>("[name=search]");
if (!element?.value) return;
this.onSearch(element.value);
}}
>
${this.renderInput()}

View File

@@ -1,55 +1,31 @@
/**
* @file IFrame Utilities
*/
import { renderStaticHTMLUnsafe } from "#common/purify";
interface IFrameLoadResult {
contentWindow: Window;
contentDocument: Document;
}
import { MaybeCompiledTemplateResult } from "lit";
export function pluckIFrameContent(iframe: HTMLIFrameElement) {
const contentWindow = iframe.contentWindow;
const contentDocument = iframe.contentDocument;
if (!contentWindow) {
throw new Error("Iframe contentWindow is not accessible");
}
if (!contentDocument) {
throw new Error("Iframe contentDocument is not accessible");
}
return {
contentWindow,
contentDocument,
};
}
export function resolveIFrameContent(iframe: HTMLIFrameElement): Promise<IFrameLoadResult> {
if (iframe.contentDocument?.readyState === "complete") {
return Promise.resolve(pluckIFrameContent(iframe));
}
return new Promise((resolve) => {
iframe.addEventListener("load", () => resolve(pluckIFrameContent(iframe)), { once: true });
});
export interface CreateHTMLObjectInit {
body: string | MaybeCompiledTemplateResult;
head?: string | MaybeCompiledTemplateResult;
}
/**
* Creates a minimal HTML wrapper for an iframe.
* Render untrusted HTML to a string without escaping it.
*
* @deprecated Use the `contentDocument.body` directly instead.
* @returns {string} The rendered HTML string.
*/
export function createIFrameHTMLWrapper(bodyContent: string): string {
const html = String.raw;
export function createDocumentTemplate(init: CreateHTMLObjectInit): string {
const body = renderStaticHTMLUnsafe(init.body);
const head = init.head ? renderStaticHTMLUnsafe(init.head) : "";
return html`<!doctype html>
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
${head}
</head>
<body style="display:flex;flex-direction:row;justify-content:center;">
${bodyContent}
<body>
${body}
</body>
</html>`;
}

View File

@@ -26,7 +26,7 @@ export class FormWizardPage extends WizardPage {
return Promise.reject(msg("No form found"));
}
const formPromise = form.submit(new Event("submit"));
const formPromise = form.submit(new SubmitEvent("submit"));
if (!formPromise) {
return Promise.reject(msg("Form didn't return a promise for submitting"));

View File

@@ -1,4 +1,4 @@
import { Form, KeyUnknown } from "@goauthentik/elements/forms/Form";
import { Form } from "@goauthentik/elements/forms/Form";
import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { CSSResult, TemplateResult, html } from "lit";
@@ -12,23 +12,26 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export abstract class WizardForm extends Form<KeyUnknown> {
export abstract class WizardForm extends Form<Record<string, unknown>> {
viewportCheck = false;
@property({ attribute: false })
nextDataCallback!: (data: KeyUnknown) => Promise<boolean>;
nextDataCallback!: (data: Record<string, unknown>) => Promise<boolean>;
/* Override the traditional behavior of the form and instead simply serialize the form and push
* it's contents to the next page.
*/
async submit(): Promise<boolean | undefined> {
const data = this.serializeForm();
if (!data) {
return;
}
const files = this.getFormFiles();
const finalData = Object.assign({}, data, files);
return this.nextDataCallback(finalData);
const data = this.serialize();
if (!data) return;
const files = this.files();
return this.nextDataCallback({
...data,
...files,
});
}
}
@@ -38,7 +41,7 @@ export class WizardFormPage extends WizardPage {
}
inputCallback(): void {
const form = this.shadowRoot?.querySelector<HTMLFormElement>("form");
const form = this.shadowRoot?.querySelector("form");
if (!form) return;
@@ -59,9 +62,10 @@ export class WizardFormPage extends WizardPage {
return Boolean(response);
};
nextDataCallback: (data: KeyUnknown) => Promise<boolean> = async (): Promise<boolean> => {
return false;
};
nextDataCallback: (data: Record<string, unknown>) => Promise<boolean> =
async (): Promise<boolean> => {
return false;
};
renderForm(): TemplateResult {
return html``;

View File

@@ -1,5 +1,6 @@
import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_FLOW_ADVANCE, EVENT_FLOW_INSPECTOR_TOGGLE } from "#common/constants";
import { pluckErrorDetail } from "#common/errors/network";
import { globalAK } from "#common/global";
import { configureSentry } from "#common/sentry/index";
import { WebsocketClient } from "#common/ws";
@@ -16,10 +17,19 @@ import "#flow/sources/plex/PlexLoginInit";
import "#flow/stages/FlowErrorStage";
import "#flow/stages/FlowFrameStage";
import "#flow/stages/RedirectStage";
import { StageHost, SubmitOptions } from "#flow/stages/base";
import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base";
import { spread } from "@open-wc/lit-helpers";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
import {
CSSResult,
MaybeCompiledTemplateResult,
PropertyValues,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { until } from "lit/directives/until.js";
@@ -41,6 +51,7 @@ import {
FlowErrorChallenge,
FlowLayoutEnum,
FlowsApi,
IdentificationChallenge,
ResponseError,
ShellChallenge,
} from "@goauthentik/api";
@@ -50,46 +61,17 @@ export class FlowExecutor
extends WithCapabilitiesConfig(WithBrandConfig(Interface))
implements StageHost
{
@property()
flowSlug: string = window.location.pathname.split("/")[3];
//#region Styles
private _challenge?: ChallengeTypes;
@property({ attribute: false })
set challenge(value: ChallengeTypes | undefined) {
this._challenge = value;
if (value?.flowInfo?.title) {
document.title = `${value.flowInfo?.title} - ${this.brandingTitle}`;
} else {
document.title = this.brandingTitle;
}
if (value?.flowInfo) {
this.flowInfo = value.flowInfo;
} else {
this.requestUpdate();
}
}
get challenge(): ChallengeTypes | undefined {
return this._challenge;
}
@property({ type: Boolean })
loading = false;
@state()
inspectorOpen = false;
@state()
inspectorAvailable = false;
@state()
flowInfo?: ContextualFlowInfo;
ws: WebsocketClient;
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFDrawer, PFButton, PFTitle, PFList, PFBackgroundImage].concat(css`
static styles: CSSResult[] = [
PFBase,
PFLogin,
PFDrawer,
PFButton,
PFTitle,
PFList,
PFBackgroundImage,
css`
:host {
--pf-c-login__main-body--PaddingBottom: var(--pf-global--spacer--2xl);
}
@@ -172,29 +154,81 @@ export class FlowExecutor
right: 1rem;
z-index: 100;
}
`);
`,
];
//#endregion
//#region Properties
@property()
public flowSlug: string = window.location.pathname.split("/")[3];
#challenge?: ChallengeTypes;
@property({ attribute: false })
public set challenge(value: ChallengeTypes | undefined) {
this.#challenge = value;
if (value?.flowInfo?.title) {
document.title = `${value.flowInfo?.title} - ${this.brandingTitle}`;
} else {
document.title = this.brandingTitle;
}
this.requestUpdate();
}
public get challenge(): ChallengeTypes | undefined {
return this.#challenge;
}
@property({ type: Boolean })
public loading = false;
//#endregion
//#region State
@state()
protected inspectorOpen?: boolean;
@state()
protected inspectorAvailable?: boolean;
@state()
public flowInfo?: ContextualFlowInfo;
#ws: WebsocketClient | null = null;
//#endregion
//#region Lifecycle
constructor() {
configureSentry();
super();
this.ws = new WebsocketClient();
this.#ws = WebsocketClient.connect();
const inspector = new URL(window.location.toString()).searchParams.get("inspector");
if (inspector === "" || inspector === "open") {
this.inspectorOpen = true;
this.inspectorAvailable = true;
} else if (inspector === "available") {
this.inspectorAvailable = true;
}
this.addEventListener(EVENT_FLOW_INSPECTOR_TOGGLE, () => {
this.inspectorOpen = !this.inspectorOpen;
});
window.addEventListener("message", (event) => {
const msg: {
source?: string;
context?: string;
message: string;
} = event.data;
if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") {
return;
}
@@ -204,89 +238,119 @@ export class FlowExecutor
});
}
async submit(
payload?: FlowChallengeResponseRequest,
options?: SubmitOptions,
): Promise<boolean> {
if (!payload) return Promise.reject();
if (!this.challenge) return Promise.reject();
// @ts-expect-error
payload.component = this.challenge.component;
if (!options?.invisible) {
this.loading = true;
}
try {
const challenge = await new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({
flowSlug: this.flowSlug,
query: window.location.search.substring(1),
flowChallengeResponseRequest: payload,
});
if (this.inspectorOpen) {
window.dispatchEvent(
new CustomEvent(EVENT_FLOW_ADVANCE, {
bubbles: true,
composed: true,
}),
);
}
this.challenge = challenge;
return !this.challenge.responseErrors;
} catch (exc: unknown) {
this.errorMessage(exc as Error | ResponseError | FetchError);
return false;
} finally {
this.loading = false;
}
}
async firstUpdated(): Promise<void> {
public async firstUpdated(): Promise<void> {
if (this.can(CapabilitiesEnum.CanDebug)) {
this.inspectorAvailable = true;
}
this.loading = true;
try {
const challenge = await new FlowsApi(DEFAULT_CONFIG).flowsExecutorGet({
return new FlowsApi(DEFAULT_CONFIG)
.flowsExecutorGet({
flowSlug: this.flowSlug,
query: window.location.search.substring(1),
})
.then((challenge: ChallengeTypes) => {
if (this.inspectorOpen) {
window.dispatchEvent(
new CustomEvent(EVENT_FLOW_ADVANCE, {
bubbles: true,
composed: true,
}),
);
}
this.challenge = challenge;
if (this.challenge.flowInfo) {
this.flowInfo = this.challenge.flowInfo;
}
})
.catch((error) => {
const challenge: FlowErrorChallenge = {
component: "ak-stage-flow-error",
error: pluckErrorDetail(error),
requestId: "",
};
this.challenge = challenge as ChallengeTypes;
})
.finally(() => {
this.loading = false;
});
if (this.inspectorOpen) {
window.dispatchEvent(
new CustomEvent(EVENT_FLOW_ADVANCE, {
bubbles: true,
composed: true,
}),
);
}
this.challenge = challenge;
} catch (exc: unknown) {
// Catch JSON or Update errors
this.errorMessage(exc as Error | ResponseError | FetchError);
} finally {
this.loading = false;
}
// DOM post-processing has to happen after the render.
public updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("flowInfo") && this.flowInfo) {
this.#setShadowStyles(this.flowInfo);
}
}
async errorMessage(error: Error | ResponseError | FetchError): Promise<void> {
let body = "";
if (error instanceof FetchError) {
body = msg("Request failed. Please try again later.");
} else if (error instanceof ResponseError) {
body = await error.response.text();
} else if (error instanceof Error) {
body = error.message;
}
const challenge: FlowErrorChallenge = {
component: "ak-stage-flow-error",
error: body,
requestId: "",
};
this.challenge = challenge as ChallengeTypes;
public disconnectedCallback(): void {
super.disconnectedCallback();
this.#ws?.close();
}
setShadowStyles(value: ContextualFlowInfo) {
if (!value) {
return;
//#endregion
//#region Public Methods
public submit = async (
payload?: FlowChallengeResponseRequest,
options?: SubmitOptions,
): Promise<boolean> => {
if (!payload) throw new Error("No payload provided");
if (!this.challenge) throw new Error("No challenge provided");
payload.component = this.challenge.component as FlowChallengeResponseRequest["component"];
if (!options?.invisible) {
this.loading = true;
}
return new FlowsApi(DEFAULT_CONFIG)
.flowsExecutorSolve({
flowSlug: this.flowSlug,
query: window.location.search.substring(1),
flowChallengeResponseRequest: payload,
})
.then((challenge) => {
if (this.inspectorOpen) {
window.dispatchEvent(
new CustomEvent(EVENT_FLOW_ADVANCE, {
bubbles: true,
composed: true,
}),
);
}
this.challenge = challenge;
if (this.challenge.flowInfo) {
this.flowInfo = this.challenge.flowInfo;
}
return !this.challenge.responseErrors;
})
.catch((error: unknown) => {
const challenge: FlowErrorChallenge = {
component: "ak-stage-flow-error",
error: pluckErrorDetail(error),
requestId: "",
};
this.challenge = challenge as ChallengeTypes;
return false;
})
.finally(() => {
this.loading = false;
});
};
#setShadowStyles(value: ContextualFlowInfo) {
if (!value) return;
this.shadowRoot
?.querySelectorAll<HTMLDivElement>(".pf-c-background-image")
.forEach((bg) => {
@@ -294,190 +358,7 @@ export class FlowExecutor
});
}
// DOM post-processing has to happen after the render.
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("flowInfo") && this.flowInfo !== undefined) {
this.setShadowStyles(this.flowInfo);
}
}
async renderChallenge(): Promise<TemplateResult> {
if (!this.challenge) {
return html`<ak-flow-card loading></ak-flow-card>`;
}
switch (this.challenge?.component) {
case "ak-stage-access-denied":
await import("@goauthentik/flow/stages/access_denied/AccessDeniedStage");
return html`<ak-stage-access-denied
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-access-denied>`;
case "ak-stage-identification":
await import("@goauthentik/flow/stages/identification/IdentificationStage");
return html`<ak-stage-identification
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-identification>`;
case "ak-stage-password":
await import("@goauthentik/flow/stages/password/PasswordStage");
return html`<ak-stage-password
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-password>`;
case "ak-stage-captcha":
await import("@goauthentik/flow/stages/captcha/CaptchaStage");
return html`<ak-stage-captcha
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-captcha>`;
case "ak-stage-consent":
await import("@goauthentik/flow/stages/consent/ConsentStage");
return html`<ak-stage-consent
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-consent>`;
case "ak-stage-dummy":
await import("@goauthentik/flow/stages/dummy/DummyStage");
return html`<ak-stage-dummy
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-dummy>`;
case "ak-stage-email":
await import("@goauthentik/flow/stages/email/EmailStage");
return html`<ak-stage-email
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-email>`;
case "ak-stage-autosubmit":
await import("@goauthentik/flow/stages/autosubmit/AutosubmitStage");
return html`<ak-stage-autosubmit
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-autosubmit>`;
case "ak-stage-prompt":
await import("@goauthentik/flow/stages/prompt/PromptStage");
return html`<ak-stage-prompt
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-prompt>`;
case "ak-stage-authenticator-totp":
await import("@goauthentik/flow/stages/authenticator_totp/AuthenticatorTOTPStage");
return html`<ak-stage-authenticator-totp
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-totp>`;
case "ak-stage-authenticator-duo":
await import("@goauthentik/flow/stages/authenticator_duo/AuthenticatorDuoStage");
return html`<ak-stage-authenticator-duo
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-duo>`;
case "ak-stage-authenticator-static":
await import(
"@goauthentik/flow/stages/authenticator_static/AuthenticatorStaticStage"
);
return html`<ak-stage-authenticator-static
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-static>`;
case "ak-stage-authenticator-webauthn":
return html`<ak-stage-authenticator-webauthn
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-webauthn>`;
case "ak-stage-authenticator-email":
await import(
"@goauthentik/flow/stages/authenticator_email/AuthenticatorEmailStage"
);
return html`<ak-stage-authenticator-email
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-email>`;
case "ak-stage-authenticator-sms":
await import("@goauthentik/flow/stages/authenticator_sms/AuthenticatorSMSStage");
return html`<ak-stage-authenticator-sms
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-sms>`;
case "ak-stage-authenticator-validate":
await import(
"@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStage"
);
return html`<ak-stage-authenticator-validate
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-validate>`;
case "ak-stage-user-login":
await import("@goauthentik/flow/stages/user_login/UserLoginStage");
return html`<ak-stage-user-login
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-user-login>`;
// Sources
case "ak-source-plex":
return html`<ak-flow-source-plex
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-flow-source-plex>`;
case "ak-source-oauth-apple":
return html`<ak-flow-source-oauth-apple
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-flow-source-oauth-apple>`;
// Providers
case "ak-provider-oauth2-device-code":
await import("@goauthentik/flow/providers/oauth2/DeviceCode");
return html`<ak-flow-provider-oauth2-code
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-flow-provider-oauth2-code>`;
case "ak-provider-oauth2-device-code-finish":
await import("@goauthentik/flow/providers/oauth2/DeviceCodeFinish");
return html`<ak-flow-provider-oauth2-code-finish
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-flow-provider-oauth2-code-finish>`;
case "ak-stage-session-end":
await import("@goauthentik/flow/providers/SessionEnd");
return html`<ak-stage-session-end
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-session-end>`;
// Internal stages
case "ak-stage-flow-error":
return html`<ak-stage-flow-error
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-flow-error>`;
case "xak-flow-redirect":
return html`<ak-stage-redirect
.host=${this as StageHost}
.challenge=${this.challenge}
?promptUser=${this.inspectorOpen}
>
</ak-stage-redirect>`;
case "xak-flow-shell":
return html`${unsafeHTML((this.challenge as ShellChallenge).body)}`;
case "xak-flow-frame":
return html`<xak-flow-frame
.host=${this as StageHost}
.challenge=${this.challenge}
></xak-flow-frame>`;
default:
return html`Invalid native challenge element`;
}
}
async renderInspector() {
if (!this.inspectorOpen) {
return nothing;
}
await import("@goauthentik/flow/FlowInspector");
return html`<ak-flow-inspector
class="pf-c-drawer__panel pf-m-width-33"
.flowSlug=${this.flowSlug}
></ak-flow-inspector>`;
}
//#region Render
getLayout(): string {
const prefilledFlow = globalAK()?.flow?.layout || FlowLayoutEnum.Stacked;
@@ -489,6 +370,7 @@ export class FlowExecutor
getLayoutClass(): string {
const layout = this.getLayout();
switch (layout) {
case FlowLayoutEnum.ContentLeft:
return "pf-c-login__container";
@@ -500,6 +382,117 @@ export class FlowExecutor
}
}
//#region Render Challenge
async #registerChallengeComponent(component: ChallengeTypes["component"]) {
switch (component) {
//#region Stages
case "ak-stage-access-denied":
return import("#flow/stages/access_denied/AccessDeniedStage");
case "ak-stage-identification":
return import("#flow/stages/identification/IdentificationStage");
case "ak-stage-password":
return import("#flow/stages/password/PasswordStage");
case "ak-stage-captcha":
return import("#flow/stages/captcha/CaptchaStage");
case "ak-stage-consent":
return import("#flow/stages/consent/ConsentStage");
case "ak-stage-dummy":
return import("#flow/stages/dummy/DummyStage");
case "ak-stage-email":
return import("#flow/stages/email/EmailStage");
case "ak-stage-autosubmit":
return import("#flow/stages/autosubmit/AutosubmitStage");
case "ak-stage-prompt":
return import("#flow/stages/prompt/PromptStage");
case "ak-stage-authenticator-totp":
return import("#flow/stages/authenticator_totp/AuthenticatorTOTPStage");
case "ak-stage-authenticator-duo":
return import("#flow/stages/authenticator_duo/AuthenticatorDuoStage");
case "ak-stage-authenticator-static":
return import("#flow/stages/authenticator_static/AuthenticatorStaticStage");
case "ak-stage-authenticator-email":
return import("#flow/stages/authenticator_email/AuthenticatorEmailStage");
case "ak-stage-authenticator-sms":
return import("#flow/stages/authenticator_sms/AuthenticatorSMSStage");
case "ak-stage-authenticator-validate":
return import("#flow/stages/authenticator_validate/AuthenticatorValidateStage");
case "ak-stage-user-login":
return import("#flow/stages/user_login/UserLoginStage");
case "ak-stage-session-end":
return import("#flow/providers/SessionEnd");
//#endregion
//#region Providers
case "ak-provider-oauth2-device-code":
return import("#flow/providers/oauth2/DeviceCode");
case "ak-provider-oauth2-device-code-finish":
return import("#flow/providers/oauth2/DeviceCodeFinish");
//#endregion
}
}
async renderChallenge(): Promise<MaybeCompiledTemplateResult | HTMLElement> {
const { challenge } = this;
if (!challenge) {
return html`<ak-flow-card loading></ak-flow-card>`;
}
const { component } = challenge;
await this.#registerChallengeComponent(component);
switch (component) {
case "xak-flow-redirect":
return html`<ak-stage-redirect
.host=${this}
.challenge=${challenge}
?promptUser=${this.inspectorOpen}
>
</ak-stage-redirect>`;
case "xak-flow-frame":
return html`<xak-flow-frame
.host=${this}
.challenge=${challenge}
></xak-flow-frame>`;
case "xak-flow-shell":
return html`${unsafeHTML((challenge as ShellChallenge).body)}`;
}
const ElementConstructor = customElements.get(component);
if (!ElementConstructor) {
return html`Invalid native challenge element "${component}"`;
}
const element = document.createElement(component) as BaseStage<ChallengeTypes, unknown>;
element.host = this;
element.challenge = this.challenge!;
return element;
}
//#endregion
async renderInspector() {
if (!this.inspectorOpen) {
return nothing;
}
return import("#flow/FlowInspector").then(
() =>
html`<ak-flow-inspector
class="pf-c-drawer__panel pf-m-width-33"
.flowSlug=${this.flowSlug}
></ak-flow-inspector>`,
);
}
render(): TemplateResult {
return html` <ak-locale-context>
<div class="pf-c-background-image"></div>

View File

@@ -1,7 +1,7 @@
import { AKElement } from "@goauthentik/elements/Base";
import { AKElement } from "#elements/Base";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { CSSResult, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@@ -10,42 +10,41 @@ import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
@customElement("ak-form-static")
export class FormStatic extends AKElement {
@property()
userAvatar?: string;
public userAvatar?: string;
@property()
user?: string;
public user?: string;
static get styles(): CSSResult[] {
return [
PFAvatar,
css`
/* Form with user */
.form-control-static {
margin-top: var(--pf-global--spacer--sm);
display: flex;
align-items: center;
justify-content: space-between;
}
.form-control-static .avatar {
display: flex;
align-items: center;
}
.form-control-static img {
margin-right: var(--pf-global--spacer--xs);
}
.form-control-static a {
padding-top: var(--pf-global--spacer--xs);
padding-bottom: var(--pf-global--spacer--xs);
line-height: var(--pf-global--spacer--xl);
}
`,
];
}
static styles: CSSResult[] = [
PFAvatar,
css`
/* Form with user */
.form-control-static {
margin-top: var(--pf-global--spacer--sm);
display: flex;
align-items: center;
justify-content: space-between;
}
.form-control-static .avatar {
display: flex;
align-items: center;
}
.form-control-static img {
margin-right: var(--pf-global--spacer--xs);
}
.form-control-static a {
padding-top: var(--pf-global--spacer--xs);
padding-bottom: var(--pf-global--spacer--xs);
line-height: var(--pf-global--spacer--xl);
}
`,
];
render() {
if (!this.user) {
return nothing;
}
return html`
<div class="form-control-static">
<div class="avatar">

View File

@@ -30,40 +30,34 @@ export class OAuth2DeviceCode extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
<form class="pf-c-form" @submit=${this.submitForm}>
<p>${msg("Enter the code shown on your device.")}</p>
<ak-form-element
label="${msg("Code")}"
required
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {}).code}
>
<p>${msg("Enter the code shown on your device.")}</p>
<ak-form-element
label="${msg("Code")}"
<input
type="text"
name="code"
inputmode="numeric"
pattern="[0-9]*"
placeholder="${msg("Please enter your Code")}"
autofocus=""
autocomplete="off"
class="pf-c-form-control"
value=""
required
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {}).code}
>
<input
type="text"
name="code"
inputmode="numeric"
pattern="[0-9]*"
placeholder="${msg("Please enter your Code")}"
autofocus=""
autocomplete="off"
class="pf-c-form-control"
value=""
required
/>
</ak-form-element>
/>
</ak-form-element>
<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>
</form>
</div>
<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>
</form>
</ak-flow-card>`;
}
}

View File

@@ -16,28 +16,21 @@ import { FrameChallenge, FrameChallengeResponseRequest } from "@goauthentik/api"
@customElement("xak-flow-frame")
export class FlowFrameStage extends BaseStage<FrameChallenge, FrameChallengeResponseRequest> {
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, css``];
}
static styles: CSSResult[] = [PFBase, PFLogin, PFForm, PFFormControl, PFTitle];
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
${
this.challenge.loadingOverlay
? html`<ak-empty-state loading
>${this.challenge.loadingText
? html`<span>${this.challenge.loadingText}</span>`
: nothing}
</ak-empty-state>`
: nothing
}
<iframe
style=${
this.challenge.loadingOverlay ? "width:0;height:0;position:absolute;" : ""
}
src=${this.challenge.url}
></iframe>
</div>
${this.challenge.loadingOverlay
? html`<ak-empty-state loading
>${this.challenge.loadingText
? html`<span>${this.challenge.loadingText}</span>`
: nothing}
</ak-empty-state>`
: nothing}
<iframe
style=${this.challenge.loadingOverlay ? "width:0;height:0;position:absolute;" : ""}
src=${this.challenge.url}
></iframe>
</ak-flow-card>`;
}
}

View File

@@ -65,12 +65,7 @@ export class AuthenticatorDuoStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"

View File

@@ -32,12 +32,7 @@ export class AuthenticatorEmailStage extends BaseStage<
renderEmailInput(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
@@ -90,12 +85,7 @@ export class AuthenticatorEmailStage extends BaseStage<
</ak-form-static>
A verification token has been sent to your configured email address
${ifDefined(this.challenge.email)}
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-element
label="${msg("Code")}"
required

View File

@@ -26,64 +26,59 @@ export class AuthenticatorSMSStage extends BaseStage<
AuthenticatorSMSChallenge,
AuthenticatorSMSChallengeResponseRequest
> {
static get styles(): CSSResult[] {
return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
}
static styles: CSSResult[] = [
PFBase,
PFAlert,
PFLogin,
PFForm,
PFFormControl,
PFTitle,
PFButton,
];
renderPhoneNumber(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
<ak-form-element
label="${msg("Phone number")}"
required
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {}).phone_number}
>
<input
type="tel"
name="phoneNumber"
placeholder="${msg("Please enter your Phone number.")}"
autofocus=""
autocomplete="tel"
class="pf-c-form-control"
required
/>
</ak-form-element>
${this.renderNonFieldErrors()}
<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 slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</form>
</div>
</ak-flow-card>`;
</ak-form-static>
<ak-form-element
label="${msg("Phone number")}"
required
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {}).phone_number}
>
<input
type="tel"
name="phoneNumber"
placeholder="${msg("Please enter your Phone number.")}"
autofocus=""
autocomplete="tel"
class="pf-c-form-control"
required
/>
</ak-form-element>
${this.renderNonFieldErrors()}
<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>
</form>
</ak-flow-card>`;
}
renderCode(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
@@ -127,6 +122,7 @@ export class AuthenticatorSMSStage extends BaseStage<
if (this.challenge.phoneNumberRequired) {
return this.renderPhoneNumber();
}
return this.renderCode();
}
}

View File

@@ -53,12 +53,7 @@ export class AuthenticatorStaticStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"

View File

@@ -48,12 +48,7 @@ export class AuthenticatorTOTPStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"

View File

@@ -66,33 +66,27 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
}
render(): TemplateResult {
return html`<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
const deviceClass = this.deviceChallenge?.deviceClass;
return html`<form class="pf-c-form" @submit=${this.submitForm}>
${this.renderUserInfo()}
<div class="icon-description">
<i class="fa ${this.deviceIcon()}" aria-hidden="true"></i>
<p>${this.deviceMessage()}</p>
</div>
<ak-form-element
label="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
label="${deviceClass === DeviceClassesEnum.Static
? msg("Static token")
: msg("Authentication code")}"
required
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {}).code}
>
<!-- @ts-ignore -->
<input
type="text"
name="code"
inputmode="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
? "text"
: "numeric"}"
pattern="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
inputmode="${deviceClass === DeviceClassesEnum.Static ? "text" : "numeric"}"
pattern="${deviceClass === DeviceClassesEnum.Static
? "[0-9a-zA-Z]*"
: "[0-9]*"}"
placeholder="${msg("Please enter your code")}"

View File

@@ -51,12 +51,7 @@ export class AuthenticatorValidateStageWebDuo extends BaseDeviceStage<
}
const errors = this.challenge.responseErrors?.duo || [];
const errorMessage = errors.map((err) => err.string);
return html` <form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
return html` <form class="pf-c-form" @submit=${this.submitForm}>
${this.renderUserInfo()}
<ak-empty-state ?loading="${this.authenticating}" icon="fas fa-times"
><span

View File

@@ -37,7 +37,7 @@ export class BaseDeviceStage<
display: flex;
gap: 16px;
margin-top: 0;
margin-bottom: calc(var(--pf-c-form__group--m-action--MarginTop) / 2);
margin-bottom: var(--pf-c-form__group--m-action--MarginTop);
flex-direction: column;
}
`,

View File

@@ -56,7 +56,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage<
display: flex;
gap: 16px;
margin-top: 0;
margin-bottom: calc(var(--pf-c-form__group--m-action--MarginTop) / 2);
margin-bottom: var(--pf-c-form__group--m-action--MarginTop);
flex-direction: column;
}
`,

View File

@@ -1,5 +1,5 @@
import { pluckErrorDetail } from "#common/errors/network";
import { AKElement } from "@goauthentik/elements/Base";
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
@@ -45,57 +45,63 @@ export interface PendingUserChallenge {
export interface ResponseErrorsChallenge {
responseErrors?: {
[key: string]: Array<ErrorDetail>;
[key: string]: ErrorDetail[];
};
}
export class BaseStage<
export abstract class BaseStage<
Tin extends FlowInfoChallenge & PendingUserChallenge & ResponseErrorsChallenge,
Tout,
> extends AKElement {
host!: StageHost;
@property({ attribute: false })
challenge!: Tin;
public challenge!: Tin;
async submitForm(e: Event, defaults?: Tout): Promise<boolean> {
e.preventDefault();
const object: KeyUnknown = defaults || {};
const form = new FormData(this.shadowRoot?.querySelector("form") || undefined);
public submitForm = async (event?: SubmitEvent, defaults?: Tout): Promise<boolean> => {
event?.preventDefault();
for await (const [key, value] of form.entries()) {
if (value instanceof Blob) {
object[key] = await readFileAsync(value);
} else {
object[key] = value;
const payload: Record<string, unknown> = defaults || {};
const form = this.shadowRoot?.querySelector("form");
if (form) {
const data = new FormData(form);
for await (const [key, value] of data.entries()) {
if (value instanceof Blob) {
payload[key] = await readFileAsync(value);
} else {
payload[key] = value;
}
}
}
return this.host?.submit(object as unknown as Tout).then((successful) => {
return this.host?.submit(payload).then((successful) => {
if (successful) {
this.onSubmitSuccess();
} else {
this.onSubmitFailure();
}
return successful;
});
}
};
renderNonFieldErrors() {
const errors = this.challenge?.responseErrors || {};
if (!("non_field_errors" in errors)) {
return nothing;
}
const nonFieldErrors = errors.non_field_errors;
const nonFieldErrors = this.challenge?.responseErrors?.non_field_errors;
if (!nonFieldErrors) {
return nothing;
}
return html`<div class="pf-c-form__alert">
${nonFieldErrors.map((err) => {
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
</div>
<h4 class="pf-c-alert__title">${err.string}</h4>
<h4 class="pf-c-alert__title">${pluckErrorDetail(err)}</h4>
</div>`;
})}
</div>`;

View File

@@ -1,21 +1,21 @@
/// <reference types="@hcaptcha/types"/>
/// <reference types="turnstile-types"/>
import { pluckErrorDetail } from "#common/errors/network";
import { createDocumentTemplate } from "#elements/utils/iframe";
import { CaptchaHandler, iframeTemplate } from "#flow/stages/captcha/shared";
import { renderStaticHTMLUnsafe } from "@goauthentik/common/purify";
import { akEmptyState } from "@goauthentik/elements/EmptyState";
import { bound } from "@goauthentik/elements/decorators/bound";
import "@goauthentik/elements/forms/FormElement";
import { createIFrameHTMLWrapper } from "@goauthentik/elements/utils/iframe";
import { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic";
import "@goauthentik/flow/components/ak-flow-card.js";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { P, match } from "ts-pattern";
import { match } from "ts-pattern";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
@@ -25,209 +25,155 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
type TokenHandler = (token: string) => void;
export type TokenListener = (token: string) => void;
type Dims = { height: number };
type IframeCaptchaMessage = {
interface CaptchaMessage {
source?: string;
context?: string;
message: "captcha";
token: string;
};
}
type IframeResizeMessage = {
interface LoadMessage {
source?: string;
context?: string;
message: "resize";
size: Dims;
};
type IframeMessageEvent = MessageEvent<IframeCaptchaMessage | IframeResizeMessage>;
type CaptchaHandler = {
name: string;
interactive: () => Promise<unknown>;
execute: () => Promise<unknown>;
refreshInteractive: () => Promise<unknown>;
refresh: () => Promise<unknown>;
};
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
// a resize. Because the Captcha is itself in an iframe, the reported height is often off by some
// margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden
// rendering.
function iframeTemplate(children: TemplateResult, challengeURL: string): TemplateResult {
return html` ${children}
<script>
new ResizeObserver((entries) => {
const height =
document.body.offsetHeight +
parseFloat(getComputedStyle(document.body).fontSize) * 2;
window.parent.postMessage({
message: "resize",
source: "goauthentik.io",
context: "flow-executor",
size: { height },
});
}).observe(document.querySelector(".ak-captcha-container"));
</script>
<script src=${challengeURL}></script>
<script>
function callback(token) {
window.parent.postMessage({
message: "captcha",
source: "goauthentik.io",
context: "flow-executor",
token,
});
}
</script>`;
message: "load";
}
type IframeMessageEvent = MessageEvent<CaptchaMessage | LoadMessage>;
@customElement("ak-stage-captcha")
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
static get styles(): CSSResult[] {
return [
PFBase,
PFLogin,
PFForm,
PFFormControl,
PFTitle,
css`
iframe {
width: 100%;
height: 0;
static styles: CSSResult[] = [
PFBase,
PFLogin,
PFForm,
PFFormControl,
PFTitle,
css`
:host {
--captcha-background-to: var(--pf-global--BackgroundColor--light-100);
--captcha-background-from: var(--pf-global--BackgroundColor--light-300);
}
:host([theme="dark"]) {
--captcha-background-to: var(--ak-dark-background-light);
--captcha-background-from: var(--ak-dark-background-light-ish);
}
@keyframes captcha-background-animation {
0% {
background-color: var(--captcha-background-from);
}
`,
];
}
50% {
background-color: var(--captcha-background-to);
}
100% {
background-color: var(--captcha-background-from);
}
}
#ak-captcha {
width: 100%;
min-height: 65px;
background-color: var(--captcha-background-from);
animation: captcha-background-animation 1s infinite var(--pf-global--TimingFunction);
}
`,
];
//#region Properties
@property({ type: Boolean })
embedded = false;
public embedded = false;
@property()
onTokenChange: TokenHandler = (token: string) => {
public onTokenChange: TokenListener = (token: string) => {
this.host.submit({ component: "ak-stage-captcha", token });
};
@property()
public onLoad?: () => void;
@property({ attribute: false })
refreshedAt = new Date();
public refreshedAt = new Date();
//#endregion
//#region State
@state()
activeHandler?: CaptchaHandler = undefined;
protected activeHandler: CaptchaHandler | null = null;
@state()
error?: string;
protected error: string | null = null;
handlers: CaptchaHandler[] = [
{
name: "grecaptcha",
interactive: this.renderGReCaptchaFrame,
execute: this.executeGReCaptcha,
refreshInteractive: this.refreshGReCaptchaFrame,
refresh: this.refreshGReCaptcha,
},
{
name: "hcaptcha",
interactive: this.renderHCaptchaFrame,
execute: this.executeHCaptcha,
refreshInteractive: this.refreshHCaptchaFrame,
refresh: this.refreshHCaptcha,
},
{
name: "turnstile",
interactive: this.renderTurnstileFrame,
execute: this.executeTurnstile,
refreshInteractive: this.refreshTurnstileFrame,
refresh: this.refreshTurnstile,
},
];
@state()
protected iframeHeight = 65;
_captchaFrame?: HTMLIFrameElement;
_captchaDocumentContainer?: HTMLDivElement;
_listenController = new ListenerController();
#scriptElement?: HTMLScriptElement;
connectedCallback(): void {
super.connectedCallback();
window.addEventListener("message", this.onIframeMessage, {
signal: this._listenController.signal,
});
}
#iframeSource = "about:blank";
#iframeRef = createRef<HTMLIFrameElement>();
disconnectedCallback(): void {
this._listenController.abort();
if (!this.challenge?.interactive) {
if (document.body.contains(this.captchaDocumentContainer)) {
document.body.removeChild(this.captchaDocumentContainer);
}
#captchaDocumentContainer?: HTMLDivElement;
#listenController = new ListenerController();
//#endregion
//#region Getters/Setters
protected get captchaDocumentContainer(): HTMLDivElement {
if (this.#captchaDocumentContainer) {
return this.#captchaDocumentContainer;
}
super.disconnectedCallback();
this.#captchaDocumentContainer = document.createElement("div");
this.#captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
return this.#captchaDocumentContainer;
}
get captchaDocumentContainer(): HTMLDivElement {
if (this._captchaDocumentContainer) {
return this._captchaDocumentContainer;
}
this._captchaDocumentContainer = document.createElement("div");
this._captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
return this._captchaDocumentContainer;
}
//#endregion
get captchaFrame(): HTMLIFrameElement {
if (this._captchaFrame) {
return this._captchaFrame;
}
this._captchaFrame = document.createElement("iframe");
this._captchaFrame.src = "about:blank";
this._captchaFrame.id = `ak-captcha-${randomId()}`;
return this._captchaFrame;
}
onFrameResize({ height }: Dims) {
this.captchaFrame.style.height = `${height}px`;
}
//#region Listeners
// ADR: Did not to put anything into `otherwise` or `exhaustive` here because iframe messages
// that were not of interest to us also weren't necessarily corrupt or suspicious. For example,
// during testing Storybook throws a lot of cross-iframe messages that we don't care about.
@bound
onIframeMessage({ data }: IframeMessageEvent) {
match(data)
.with(
{ source: "goauthentik.io", context: "flow-executor", message: "captcha" },
({ token }) => this.onTokenChange(token),
)
.with(
{ source: "goauthentik.io", context: "flow-executor", message: "resize" },
({ size }) => this.onFrameResize(size),
)
.with(
{ source: "goauthentik.io", context: "flow-executor", message: P.any },
({ message }) => {
console.debug(`authentik/stages/captcha: Unknown message: ${message}`);
},
)
.otherwise(() => {});
}
#messageListener = ({ data }: IframeMessageEvent) => {
if (!data) return;
async renderGReCaptchaFrame() {
this.renderFrame(
html`<div
class="g-recaptcha ak-captcha-container"
data-sitekey="${this.challenge.siteKey}"
data-callback="callback"
></div>`,
);
}
if (data.source !== "goauthentik.io" || data.context !== "flow-executor") {
return;
}
return match(data)
.with({ message: "captcha" }, ({ token }) => this.onTokenChange(token))
.with({ message: "load" }, this.#loadListener)
.otherwise(({ message }) => {
console.debug(`authentik/stages/captcha: Unknown message: ${message}`);
});
};
//#endregion
//#region g-recaptcha
protected renderGReCaptchaFrame = () => {
return html`<div
id="ak-container"
class="g-recaptcha"
data-sitekey="${this.challenge.siteKey}"
data-callback="callback"
></div>`;
};
async executeGReCaptcha() {
return grecaptcha.ready(() => {
grecaptcha.execute(
await grecaptcha.ready(() => {
return grecaptcha.execute(
grecaptcha.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
@@ -238,7 +184,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}
async refreshGReCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.grecaptcha.reset();
this.#iframeRef.value?.contentWindow?.grecaptcha.reset();
}
async refreshGReCaptcha() {
@@ -246,19 +192,22 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
window.grecaptcha.execute();
}
async renderHCaptchaFrame() {
this.renderFrame(
html`<div
class="h-captcha ak-captcha-container"
data-sitekey="${this.challenge.siteKey}"
data-theme="${this.activeTheme ? this.activeTheme : "light"}"
data-callback="callback"
></div> `,
);
}
//#endregion
//#region h-captcha
protected renderHCaptchaFrame = () => {
return html`<div
id="ak-container"
class="h-captcha"
data-sitekey="${this.challenge.siteKey}"
data-theme="${this.activeTheme ? this.activeTheme : "light"}"
data-callback="callback"
></div>`;
};
async executeHCaptcha() {
return hcaptcha.execute(
await hcaptcha.execute(
hcaptcha.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
@@ -268,7 +217,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}
async refreshHCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.hcaptcha.reset();
this.#iframeRef.value?.contentWindow?.hcaptcha?.reset();
}
async refreshHCaptcha() {
@@ -276,154 +225,275 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
window.hcaptcha.execute();
}
async renderTurnstileFrame() {
this.renderFrame(
html`<div
class="cf-turnstile ak-captcha-container"
data-sitekey="${this.challenge.siteKey}"
data-callback="callback"
></div>`,
);
}
//#endregion
//#region Turnstile
protected renderTurnstileFrame = () => {
return html`<div
id="ak-container"
class="cf-turnstile"
data-sitekey="${this.challenge.siteKey}"
data-callback="callback"
data-size="flexible"
></div>`;
};
async executeTurnstile() {
return window.turnstile.render(this.captchaDocumentContainer, {
window.turnstile.render(this.captchaDocumentContainer, {
sitekey: this.challenge.siteKey,
callback: this.onTokenChange,
});
}
async refreshTurnstileFrame() {
(this.captchaFrame.contentWindow as typeof window)?.turnstile.reset();
this.#iframeRef.value?.contentWindow?.turnstile.reset();
}
async refreshTurnstile() {
window.turnstile.reset();
}
async renderFrame(captchaElement: TemplateResult) {
const { contentDocument } = this.captchaFrame || {};
//#endregion
if (!contentDocument) {
console.debug(
"authentik/stages/captcha: unable to render captcha frame, no contentDocument",
);
#handlers = new Map<string, CaptchaHandler>([
[
"grecaptcha",
{
interactive: this.renderGReCaptchaFrame,
execute: this.executeGReCaptcha,
refreshInteractive: this.refreshGReCaptchaFrame,
refresh: this.refreshGReCaptcha,
},
],
[
"hcaptcha",
{
interactive: this.renderHCaptchaFrame,
execute: this.executeHCaptcha,
refreshInteractive: this.refreshHCaptchaFrame,
refresh: this.refreshHCaptcha,
},
],
[
"turnstile",
{
interactive: this.renderTurnstileFrame,
refreshInteractive: this.refreshTurnstileFrame,
execute: this.executeTurnstile,
refresh: this.refreshTurnstile,
},
],
]);
return;
}
contentDocument.open();
contentDocument.write(
createIFrameHTMLWrapper(
renderStaticHTMLUnsafe(iframeTemplate(captchaElement, this.challenge.jsUrl)),
),
);
contentDocument.close();
}
//#region Render
renderBody() {
// [hasError, isInteractive]
// prettier-ignore
return match([Boolean(this.error), Boolean(this.challenge?.interactive)])
.with([true, P.any], () => akEmptyState({ icon: "fa-times" }, { heading: this.error }))
.with([false, true], () => html`${this.captchaFrame}`)
.with([false, false], () => akEmptyState({ loading: true }, { heading: msg("Verifying...") }))
.exhaustive();
if (this.error) {
return akEmptyState({ icon: "fa-times" }, { heading: this.error });
}
if (this.challenge?.interactive) {
return html`
<iframe
${ref(this.#iframeRef)}
style="height: ${this.iframeHeight}px;"
id="ak-captcha"
></iframe>
`;
}
return akEmptyState({ loading: true }, { heading: msg("Verifying...") });
}
renderMain() {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${this.renderBody()}
</form>
</div>
</ak-flow-card>`;
<form class="pf-c-form">
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${this.renderBody()}
</form>
</ak-flow-card>`;
}
render() {
// [isEmbedded, hasChallenge, isInteractive]
// prettier-ignore
return match([this.embedded, Boolean(this.challenge), Boolean(this.challenge?.interactive)])
.with([true, false, P.any], () => nothing)
.with([true, true, false], () => nothing)
.with([true, true, true], () => this.renderBody())
.with([false, false, P.any], () => akEmptyState({ loading: true }))
.with([false, true, P.any], () => this.renderMain())
.exhaustive();
if (!this.challenge) {
return this.embedded ? nothing : akEmptyState({ loading: true });
}
if (!this.embedded) {
return this.renderMain();
}
return this.challenge.interactive ? this.renderBody() : nothing;
}
firstUpdated(changedProperties: PropertyValues<this>) {
if (!(changedProperties.has("challenge") && this.challenge !== undefined)) {
//#endregion;
//#region Lifecycle
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener("message", this.#messageListener, {
signal: this.#listenController.signal,
});
}
public disconnectedCallback(): void {
this.#listenController.abort();
if (!this.challenge?.interactive) {
if (document.body.contains(this.captchaDocumentContainer)) {
document.body.removeChild(this.captchaDocumentContainer);
}
}
super.disconnectedCallback();
}
//#endregion
public firstUpdated(changedProperties: PropertyValues<this>) {
if (!(changedProperties.has("challenge") && typeof this.challenge !== "undefined")) {
return;
}
const attachCaptcha = async () => {
console.debug("authentik/stages/captcha: script loaded");
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
let lastError = undefined;
let found = false;
for (const handler of handlers) {
console.debug(`authentik/stages/captcha: trying handler ${handler.name}`);
try {
const runner = this.challenge.interactive
? handler.interactive
: handler.execute;
await runner.apply(this);
console.debug(`authentik/stages/captcha[${handler.name}]: handler succeeded`);
found = true;
this.activeHandler = handler;
break;
} catch (exc) {
console.debug(`authentik/stages/captcha[${handler.name}]: handler failed`);
console.debug(exc);
lastError = exc;
}
}
this.error = found ? undefined : (lastError ?? "Unspecified error").toString();
};
this.#refreshVendor();
}
public updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("refreshedAt") || !this.challenge) {
return;
}
if (!this.activeHandler) {
return;
}
console.debug("authentik/stages/captcha: refresh triggered");
this.#run(this.activeHandler);
}
#refreshVendor() {
this.#scriptElement?.remove();
const scriptElement = document.createElement("script");
scriptElement.src = this.challenge.jsUrl;
scriptElement.async = true;
scriptElement.defer = true;
scriptElement.dataset.akCaptchaScript = "true";
scriptElement.onload = attachCaptcha;
scriptElement.onload = this.#scriptLoadListener;
document.head
.querySelectorAll("[data-ak-captcha-script=true]")
.forEach((el) => el.remove());
this.#scriptElement?.remove();
document.head.appendChild(scriptElement);
this.#scriptElement = document.head.appendChild(scriptElement);
if (!this.challenge.interactive) {
document.body.appendChild(this.captchaDocumentContainer);
}
}
updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("refreshedAt") || !this.challenge) {
//#endregion
//#region Listeners
#loadListener = () => {
const iframe = this.#iframeRef.value;
const contentDocument = iframe?.contentDocument;
if (!iframe || !contentDocument) return;
const resizeListener: ResizeObserverCallback = () => {
if (!this.#iframeRef) return;
const target = contentDocument.getElementById("ak-container");
if (!target) return;
this.iframeHeight = Math.round(target.clientHeight);
};
const resizeObserver = new ResizeObserver(resizeListener);
requestAnimationFrame(() => {
resizeObserver.observe(contentDocument.body);
this.onLoad?.();
});
};
#scriptLoadListener = async (): Promise<void> => {
console.debug("authentik/stages/captcha: script loaded");
this.error = null;
for (const [name, handler] of this.#handlers) {
if (!Object.hasOwn(window, name)) {
continue;
}
try {
await this.#run(handler);
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
this.activeHandler = handler;
return;
} catch (error) {
console.debug(`authentik/stages/captcha[${name}]: handler failed`);
console.debug(error);
this.error = pluckErrorDetail(error, "Unspecified error");
}
}
};
async #run(handler: CaptchaHandler) {
if (this.challenge.interactive) {
const iframe = this.#iframeRef.value;
if (!iframe) {
console.debug(`authentik/stages/captcha: No iframe found, skipping.`);
return;
}
console.debug(`authentik/stages/captcha: Rendering interactive.`);
const captchaElement = handler.interactive();
const template = iframeTemplate(captchaElement, this.challenge.jsUrl);
// We can use the `srcdoc` attribute as a proxy for the browser's support for
// URL objects as iframe sources.
if (!("srcdoc" in iframe.attributes)) {
URL.revokeObjectURL(this.#iframeSource);
const url = URL.createObjectURL(new Blob([template], { type: "text/html" }));
this.#iframeSource = url;
iframe.src = url;
} else if (iframe.contentDocument) {
iframe.contentDocument.open();
iframe.contentDocument.write(template);
iframe.contentDocument.close();
} else {
console.warn("authentik/stages/captcha: No support for iframes, skipping.");
}
return;
}
console.debug("authentik/stages/captcha: refresh triggered");
if (this.challenge.interactive) {
this.activeHandler?.refreshInteractive.apply(this);
} else {
this.activeHandler?.refresh.apply(this);
}
await handler.execute.apply(this);
}
}

View File

@@ -0,0 +1,11 @@
/// <reference types="@types/grecaptcha"/>
export {};
declare global {
interface Window {
grecaptcha: ReCaptchaV2.ReCaptcha & {
enterprise: ReCaptchaV2.ReCaptcha;
};
}
}

View File

@@ -0,0 +1,9 @@
/// <reference types="@hcaptcha/types"/>
export {};
declare global {
interface Window {
hcaptcha?: HCaptcha;
}
}

View File

@@ -0,0 +1,54 @@
import { createDocumentTemplate } from "#elements/utils/iframe";
import { TemplateResult, html } from "lit";
export interface CaptchaHandler {
interactive(): TemplateResult;
execute(): Promise<void>;
refreshInteractive(): Promise<void>;
refresh(): Promise<void>;
}
/**
* A container iframe for a hosted Captcha, with an event emitter to monitor
* when the Captcha forces a resize.
*
* Because the Captcha is itself in an iframe, the reported height is often off by some
* margin, adding 2rem of height to our container adds padding and prevents scrollbars
* or hidden rendering.
*/
export function iframeTemplate(children: TemplateResult, challengeURL: string): string {
return createDocumentTemplate({
head: html`<meta charset="UTF-8" />
<script>
"use strict";
function callback(token) {
self.parent.postMessage({
message: "captcha",
source: "goauthentik.io",
context: "flow-executor",
token,
});
}
function loadListener() {
self.parent.postMessage({
message: "load",
source: "goauthentik.io",
context: "flow-executor",
});
}
</script>
<style>
body {
margin: 0;
padding: 0;
}
</style>`,
body: html`${children}
<script onload="loadListener()" src="${challengeURL}"></script> `,
});
}

View File

@@ -0,0 +1,9 @@
/* eslint-disable @typescript-eslint/triple-slash-reference */
/// <reference types="turnstile-types"/>
import { TurnstileObject } from "turnstile-types";
declare global {
interface Window {
turnstile: TurnstileObject;
}
}

View File

@@ -108,8 +108,8 @@ export class ConsentStage extends BaseStage<ConsentChallenge, ConsentChallengeRe
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e, {
@submit=${(event: SubmitEvent) => {
this.submitForm(event, {
token: this.challenge.token,
});
}}

View File

@@ -23,12 +23,7 @@ export class DummyStage extends BaseStage<DummyChallenge, DummyChallengeResponse
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<p>${msg(str`Stage name: ${this.challenge.name}`)}</p>
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">

View File

@@ -22,12 +22,7 @@ export class EmailStage extends BaseStage<EmailChallenge, EmailChallengeResponse
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<div class="pf-c-form__group">
<p>${msg("Check your Inbox for a verification email.")}</p>
</div>

View File

@@ -11,6 +11,7 @@ import { AkRememberMeController } from "@goauthentik/flow/stages/identification/
import { msg, str } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -30,12 +31,9 @@ import {
} from "@goauthentik/api";
export const PasswordManagerPrefill: {
password: string | undefined;
totp: string | undefined;
} = {
password: undefined,
totp: undefined,
};
password?: string;
totp?: string;
} = {};
export const OR_LIST_FORMATTERS: Intl.ListFormat = new Intl.ListFormat("default", {
style: "short",
@@ -47,75 +45,111 @@ export class IdentificationStage extends BaseStage<
IdentificationChallenge,
IdentificationChallengeResponseRequest
> {
form?: HTMLFormElement;
static styles: CSSResult[] = [
PFBase,
PFAlert,
PFInputGroup,
PFLogin,
PFForm,
PFFormControl,
PFTitle,
PFButton,
AkRememberMeController.styles,
css`
/* 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);
}
rememberMe: AkRememberMeController;
.captcha-container {
position: relative;
.faux-input {
position: absolute;
bottom: 0;
left: 0;
opacity: 0;
pointer-events: none;
}
}
`,
];
#form?: HTMLFormElement;
#rememberMe = new AkRememberMeController(this);
//#region State
@state()
captchaToken = "";
protected captchaToken = "";
@state()
captchaRefreshedAt = new Date();
protected captchaRefreshedAt = new Date();
static get styles(): CSSResult[] {
return [
PFBase,
PFAlert,
PFInputGroup,
PFLogin,
PFForm,
PFFormControl,
PFTitle,
PFButton,
AkRememberMeController.styles,
css`
/* 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);
}
`,
];
}
@state()
protected captchaLoaded = false;
constructor() {
super();
this.rememberMe = new AkRememberMeController(this);
}
#captchaInputRef = createRef<HTMLInputElement>();
updated(changedProperties: PropertyValues<this>) {
#tokenChangeListener = (token: string) => {
const input = this.#captchaInputRef.value;
if (!input) return;
input.value = token;
};
#captchaLoadListener = () => {
this.captchaLoaded = true;
};
//#endregion
//#region Lifecycle
public updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("challenge") && this.challenge !== undefined) {
this.autoRedirect();
this.createHelperForm();
this.#autoRedirect();
this.#createHelperForm();
}
}
autoRedirect(): void {
//#endregion
#autoRedirect(): void {
if (!this.challenge) return;
// we only want to auto-redirect to a source if there's only one source
// We only want to auto-redirect to a source if there's only one source.
if (this.challenge.sources?.length !== 1) return;
// and we also only do an auto-redirect if no user fields are select
// And we also only do an auto-redirect if no user fields are select
// meaning that without the auto-redirect the user would only have the option
// to manually click on the source button
if ((this.challenge.userFields || []).length !== 0) return;
// we also don't want to auto-redirect if there's a passwordless URL configured
// We also don't want to auto-redirect if there's a passwordless URL configured
if (this.challenge.passwordlessUrl) return;
const source = this.challenge.sources[0];
this.host.challenge = source.challenge;
}
createHelperForm(): void {
//#region Helper Form
#createHelperForm(): void {
const compatMode = "ShadyDOM" in window;
this.form = document.createElement("form");
document.documentElement.appendChild(this.form);
this.#form = document.createElement("form");
document.documentElement.appendChild(this.#form);
// Only add the additional username input if we're in a shadow dom
// otherwise it just confuses browsers
if (!compatMode) {
@@ -136,7 +170,7 @@ export class IdentificationStage extends BaseStage<
input.focus();
});
};
this.form.appendChild(username);
this.#form.appendChild(username);
}
// Only add the password field when we don't already show a password field
if (!compatMode && !this.challenge.passwordFields) {
@@ -144,11 +178,13 @@ export class IdentificationStage extends BaseStage<
password.setAttribute("type", "password");
password.setAttribute("name", "password");
password.setAttribute("autocomplete", "current-password");
password.onkeyup = (ev: KeyboardEvent) => {
if (ev.key === "Enter") {
this.submitForm(ev);
password.onkeyup = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
this.submitForm();
}
const el = ev.target as HTMLInputElement;
const el = event.target as HTMLInputElement;
// Because the password field is not actually on this page,
// and we want to 'prefill' the password for the user,
// save it globally
@@ -163,17 +199,22 @@ export class IdentificationStage extends BaseStage<
input.focus();
});
};
this.form.appendChild(password);
this.#form.appendChild(password);
}
const totp = document.createElement("input");
totp.setAttribute("type", "text");
totp.setAttribute("name", "code");
totp.setAttribute("autocomplete", "one-time-code");
totp.onkeyup = (ev: KeyboardEvent) => {
if (ev.key === "Enter") {
this.submitForm(ev);
totp.onkeyup = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
this.submitForm();
}
const el = ev.target as HTMLInputElement;
const el = event.target as HTMLInputElement;
// Because the totp field is not actually on this page,
// and we want to 'prefill' the totp for the user,
// save it globally
@@ -188,19 +229,22 @@ export class IdentificationStage extends BaseStage<
input.focus();
});
};
this.form.appendChild(totp);
this.#form.appendChild(totp);
}
//#endregion
onSubmitSuccess(): void {
if (this.form) {
this.form.remove();
}
this.#form?.remove();
}
onSubmitFailure(): void {
this.captchaRefreshedAt = new Date();
}
//#region Render
renderSource(source: LoginSource): TemplateResult {
const icon = renderSourceIcon(source.name, source.iconUrl);
return html`<li class="pf-c-login__main-footer-links-item">
@@ -278,10 +322,10 @@ export class IdentificationStage extends BaseStage<
autocomplete="username"
spellcheck="false"
class="pf-c-form-control"
value=${this.rememberMe?.username ?? ""}
value=${this.#rememberMe?.username ?? ""}
required
/>
${this.rememberMe.render()}
${this.#rememberMe.render()}
</ak-form-element>
${this.challenge.passwordFields
? html`
@@ -299,19 +343,33 @@ export class IdentificationStage extends BaseStage<
${this.renderNonFieldErrors()}
${this.challenge.captchaStage
? html`
<input name="captchaToken" type="hidden" .value="${this.captchaToken}" />
<ak-stage-captcha
.challenge=${this.challenge.captchaStage}
.onTokenChange=${(token: string) => {
this.captchaToken = token;
}}
.refreshedAt=${this.captchaRefreshedAt}
embedded
></ak-stage-captcha>
<div class="captcha-container">
<ak-stage-captcha
.challenge=${this.challenge.captchaStage}
.onTokenChange=${this.#tokenChangeListener}
.onLoad=${this.#captchaLoadListener}
.refreshedAt=${this.captchaRefreshedAt}
embedded
>
</ak-stage-captcha>
<input
class="faux-input"
${ref(this.#captchaInputRef)}
name="captchaToken"
type="text"
required
value=""
/>
</div>
`
: nothing}
<div class="pf-c-form__group ${this.challenge.captchaStage ? "" : "pf-m-action"}">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
<button
?disabled=${this.challenge.captchaStage && !this.captchaLoaded}
type="submit"
class="pf-c-button pf-m-primary pf-m-block"
>
${this.challenge.primaryAction}
</button>
</div>
@@ -322,12 +380,7 @@ export class IdentificationStage extends BaseStage<
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
${this.challenge.applicationPre
? html`<p>
${msg(str`Login to continue to ${this.challenge.applicationPre}.`)}
@@ -357,6 +410,8 @@ export class IdentificationStage extends BaseStage<
${this.renderFooter()}
</ak-flow-card>`;
}
//#endregion
}
declare global {

View File

@@ -33,12 +33,7 @@ export class PasswordStage extends BaseStage<PasswordChallenge, PasswordChalleng
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<form class="pf-c-form" @submit=${this.submitForm}>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
@@ -52,8 +47,10 @@ export class PasswordStage extends BaseStage<PasswordChallenge, PasswordChalleng
</ak-form-static>
<input
name="username"
type="text"
autocomplete="username"
type="hidden"
hidden
readonly
value="${this.challenge.pendingUser}"
/>
<ak-flow-input-password

View File

@@ -278,15 +278,8 @@ ${prompt.initialValue}</textarea
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
${this.challenge.fields.map((prompt) => {
return this.renderField(prompt);
})}
<form class="pf-c-form" @submit=${this.submitForm}>
${this.challenge.fields.map((prompt) => this.renderField(prompt))}
${this.renderNonFieldErrors()} ${this.renderContinue()}
</form>
</ak-flow-card>`;

View File

@@ -23,13 +23,30 @@ export class PasswordStage extends BaseStage<
UserLoginChallenge,
UserLoginChallengeResponseRequest
> {
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFFormControl, PFSpacing, PFButton, PFTitle];
}
static styles: CSSResult[] = [
PFBase,
PFLogin,
PFForm,
PFFormControl,
PFSpacing,
PFButton,
PFTitle,
];
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form class="pf-c-form">
<form
class="pf-c-form"
@submit=${(event: SubmitEvent) => {
event.preventDefault();
const rememberMe = typeof event.submitter?.dataset.rememberMe === "string";
this.submitForm(event, {
rememberMe,
});
}}
>
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
@@ -51,26 +68,10 @@ export class PasswordStage extends BaseStage<
</div>
<div class="pf-c-form__group pf-m-action">
<button
@click=${(e: Event) => {
this.submitForm(e, {
rememberMe: true,
});
}}
class="pf-c-button pf-m-primary"
>
<button type="submit" data-remember-me class="pf-c-button pf-m-primary">
${msg("Yes")}
</button>
<button
@click=${(e: Event) => {
this.submitForm(e, {
rememberMe: false,
});
}}
class="pf-c-button pf-m-secondary"
>
${msg("No")}
</button>
<button type="submit" class="pf-c-button pf-m-secondary">${msg("No")}</button>
</div>
</form>
</ak-flow-card>`;

View File

@@ -10,12 +10,12 @@ import { FlowChallengeResponseRequest } from "@goauthentik/api";
export class StoryFlowInterface extends FlowExecutor {
async firstUpdated() {}
async submit(
submit = async (
payload?: FlowChallengeResponseRequest,
options?: SubmitOptions,
): Promise<boolean> {
): Promise<boolean> => {
return true;
}
};
async renderChallenge(): Promise<TemplateResult> {
return html`<slot></slot>`;

View File

@@ -268,8 +268,6 @@ export class UserInterface extends WithBrandConfig(AuthenticatedInterface) {
@state()
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
ws: WebsocketClient;
@state()
notificationsCount = 0;
@@ -279,10 +277,14 @@ export class UserInterface extends WithBrandConfig(AuthenticatedInterface) {
@state()
uiConfig: UIConfig | null = null;
#ws: WebsocketClient | null = null;
constructor() {
configureSentry(true);
super();
this.ws = new WebsocketClient();
this.#ws = WebsocketClient.connect();
this.fetchConfigurationDetails();
this.toggleNotificationDrawer = this.toggleNotificationDrawer.bind(this);
this.toggleApiDrawer = this.toggleApiDrawer.bind(this);
@@ -303,6 +305,8 @@ export class UserInterface extends WithBrandConfig(AuthenticatedInterface) {
window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
super.disconnectedCallback();
this.#ws?.close();
}
toggleNotificationDrawer() {

View File

@@ -4,7 +4,7 @@ import "@goauthentik/flow/components/ak-flow-card.js";
import { PromptStage } from "@goauthentik/flow/stages/prompt/PromptStage";
import { msg, str } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { customElement } from "lit/decorators.js";
import { PromptTypeEnum, StagePrompt } from "@goauthentik/api";
@@ -26,6 +26,7 @@ export class UserSettingsPromptStage extends PromptStage {
renderField(prompt: StagePrompt): TemplateResult {
const errors = (this.challenge?.responseErrors || {})[prompt.fieldKey];
if (this.shouldRenderInWrapper(prompt)) {
return html`
<ak-form-element-horizontal
@@ -57,7 +58,7 @@ export class UserSettingsPromptStage extends PromptStage {
>
${msg("Delete account")}
</a>`
: html``}
: nothing}
</div>
</div>
</div>`;
@@ -65,19 +66,11 @@ export class UserSettingsPromptStage extends PromptStage {
render(): TemplateResult {
return html`<ak-flow-card .challenge=${this.challenge}>
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
${this.challenge.fields.map((prompt) => {
return this.renderField(prompt);
})}
${this.renderNonFieldErrors()} ${this.renderContinue()}
</form>
</div>
</ak-flow-card>`;
<form class="pf-c-form" @submit=${this.submitForm}>
${this.challenge.fields.map((prompt) => this.renderField(prompt))}
${this.renderNonFieldErrors()} ${this.renderContinue()}
</form>
</ak-flow-card>`;
}
}