mirror of
https://github.com/goauthentik/authentik
synced 2026-04-26 01:25:02 +02:00
Compare commits
8 Commits
admin/vers
...
form-submi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13af3e29b6 | ||
|
|
50ba50f63b | ||
|
|
1d66c87505 | ||
|
|
799a2a6bde | ||
|
|
de19df00ab | ||
|
|
2f097b8554 | ||
|
|
56fb8025d0 | ||
|
|
7529ab8fcf |
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
return result;
|
||||
}
|
||||
|
||||
resetForm(): void {
|
||||
super.resetForm();
|
||||
reset(): void {
|
||||
super.reset();
|
||||
this.result = undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
> `
|
||||
: 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>`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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``;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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")}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
web/src/flow/stages/captcha/grecaptcha.ts
Normal file
11
web/src/flow/stages/captcha/grecaptcha.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="@types/grecaptcha"/>
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
grecaptcha: ReCaptchaV2.ReCaptcha & {
|
||||
enterprise: ReCaptchaV2.ReCaptcha;
|
||||
};
|
||||
}
|
||||
}
|
||||
9
web/src/flow/stages/captcha/hcaptcha.ts
Normal file
9
web/src/flow/stages/captcha/hcaptcha.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="@hcaptcha/types"/>
|
||||
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
hcaptcha?: HCaptcha;
|
||||
}
|
||||
}
|
||||
54
web/src/flow/stages/captcha/shared.ts
Normal file
54
web/src/flow/stages/captcha/shared.ts
Normal 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> `,
|
||||
});
|
||||
}
|
||||
9
web/src/flow/stages/captcha/turnstile.ts
Normal file
9
web/src/flow/stages/captcha/turnstile.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user