Merge branch 'main' into web/update-provider-forms-for-invalidation
* main: (142 commits) core: bump goauthentik.io/api/v3 from 3.2024102.2 to 3.2024104.1 (#12149) core: bump debugpy from 1.8.8 to 1.8.9 (#12150) core: bump webauthn from 2.2.0 to 2.3.0 (#12151) core: bump pydantic from 2.10.0 to 2.10.1 (#12152) translate: Updates for file web/xliff/en.xlf in zh_CN (#12156) translate: Updates for file web/xliff/en.xlf in zh-Hans (#12157) core: bump sentry-sdk from 2.18.0 to 2.19.0 (#12153) web: bump API Client version (#12147) root: Backport version change (#12146) website/docs: update info about footer links to match new UI (#12120) website/docs: prepare release notes (#12142) providers/oauth2: fix migration (#12138) providers/oauth2: fix migration dependencies (#12123) web: bump API Client version (#12129) providers/oauth2: fix redirect uri input (#12122) providers/proxy: fix redirect_uri (#12121) website/docs: prepare release notes (#12119) web: bump API Client version (#12118) security: fix CVE 2024 52289 (#12113) security: fix CVE 2024 52307 (#12115) ...
This commit is contained in:
100
web/src/admin/admin-settings/AdminSettingsFooterLinks.ts
Normal file
100
web/src/admin/admin-settings/AdminSettingsFooterLinks.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { type Spread } from "@goauthentik/elements/types";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { FooterLink } from "@goauthentik/api";
|
||||
|
||||
export interface IFooterLinkInput {
|
||||
footerLink: FooterLink;
|
||||
}
|
||||
|
||||
const LEGAL_SCHEMES = ["http://", "https://", "mailto:"];
|
||||
const hasLegalScheme = (url: string) =>
|
||||
LEGAL_SCHEMES.some((scheme) => url.substr(0, scheme.length).toLowerCase() === scheme);
|
||||
|
||||
@customElement("ak-admin-settings-footer-link")
|
||||
export class FooterLinkInput extends AkControlElement<FooterLink> {
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFInputGroup,
|
||||
PFFormControl,
|
||||
css`
|
||||
.pf-c-input-group input#linkname {
|
||||
flex-grow: 1;
|
||||
width: 8rem;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
footerLink: FooterLink = {
|
||||
name: "",
|
||||
href: "",
|
||||
};
|
||||
|
||||
@queryAll(".ak-form-control")
|
||||
controls?: HTMLInputElement[];
|
||||
|
||||
json() {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.controls ?? []).map((control) => [control.name, control.value]),
|
||||
) as unknown as FooterLink;
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
const href = this.json()?.href ?? "";
|
||||
return hasLegalScheme(href) && URL.canParse(href);
|
||||
}
|
||||
|
||||
render() {
|
||||
const onChange = () => {
|
||||
this.dispatchEvent(new Event("change", { composed: true, bubbles: true }));
|
||||
};
|
||||
|
||||
return html` <div class="pf-c-input-group">
|
||||
<input
|
||||
type="text"
|
||||
@change=${onChange}
|
||||
value=${this.footerLink.name}
|
||||
id="linkname"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
name="name"
|
||||
placeholder=${msg("Link Title")}
|
||||
tabindex="1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@change=${onChange}
|
||||
value="${ifDefined(this.footerLink.href ?? undefined)}"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
required
|
||||
placeholder=${msg("URL")}
|
||||
name="href"
|
||||
tabindex="1"
|
||||
/>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function akFooterLinkInput(properties: IFooterLinkInput) {
|
||||
return html`<ak-admin-settings-footer-link
|
||||
${spread(properties as unknown as Spread)}
|
||||
></ak-admin-settings-footer-link>`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-admin-settings-footer-link": FooterLinkInput;
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,7 @@ import { first } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-number-input";
|
||||
import "@goauthentik/components/ak-switch-input";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
import "@goauthentik/elements/CodeMirror";
|
||||
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
|
||||
import "@goauthentik/elements/ak-array-input.js";
|
||||
import { Form } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
@ -13,13 +12,16 @@ import "@goauthentik/elements/forms/SearchSelect";
|
||||
import "@goauthentik/elements/utils/TimeDeltaHelp";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFList from "@patternfly/patternfly/components/List/list.css";
|
||||
|
||||
import { AdminApi, Settings, SettingsRequest } from "@goauthentik/api";
|
||||
import { AdminApi, FooterLink, Settings, SettingsRequest } from "@goauthentik/api";
|
||||
|
||||
import "./AdminSettingsFooterLinks.js";
|
||||
import { IFooterLinkInput, akFooterLinkInput } from "./AdminSettingsFooterLinks.js";
|
||||
|
||||
@customElement("ak-admin-settings-form")
|
||||
export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
@ -40,7 +42,14 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
private _settings?: Settings;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return super.styles.concat(PFList);
|
||||
return super.styles.concat(
|
||||
PFList,
|
||||
css`
|
||||
ak-array-input {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
getSuccessMessage(): string {
|
||||
@ -166,15 +175,21 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-form-element-horizontal label=${msg("Footer links")} name="footerLinks">
|
||||
<ak-codemirror
|
||||
mode=${CodeMirrorMode.YAML}
|
||||
.value="${first(this._settings?.footerLinks, [])}"
|
||||
></ak-codemirror>
|
||||
<ak-array-input
|
||||
.items=${this._settings?.footerLinks ?? []}
|
||||
.newItem=${() => ({ name: "", href: "" })}
|
||||
.row=${(f?: FooterLink) =>
|
||||
akFooterLinkInput({
|
||||
".footerLink": f,
|
||||
"style": "width: 100%",
|
||||
"name": "footer-link",
|
||||
} as unknown as IFooterLinkInput)}
|
||||
>
|
||||
</ak-array-input>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"This option configures the footer links on the flow executor pages. It must be a valid YAML or JSON list and can be used as follows:",
|
||||
"This option configures the footer links on the flow executor pages. The URL is limited to web and mail addresses. If the name is left blank, the URL will be shown.",
|
||||
)}
|
||||
<code>[{"name": "Link Name","href":"https://goauthentik.io"}]</code>
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-switch-input
|
||||
@ -193,6 +208,13 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
help=${msg("Globally enable/disable impersonation.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-switch-input
|
||||
name="impersonationRequireReason"
|
||||
label=${msg("Require reason for impersonation")}
|
||||
?checked="${this._settings?.impersonationRequireReason}"
|
||||
help=${msg("Require administrators to provide a reason for impersonating a user.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-text-input
|
||||
name="defaultTokenDuration"
|
||||
label=${msg("Default token duration")}
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import { Meta, StoryObj, WebComponentsRenderer } from "@storybook/web-components";
|
||||
import { DecoratorFunction } from "storybook/internal/types";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import { FooterLinkInput } from "../AdminSettingsFooterLinks.js";
|
||||
import "../AdminSettingsFooterLinks.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Decorator = DecoratorFunction<WebComponentsRenderer, any>;
|
||||
|
||||
const metadata: Meta<FooterLinkInput> = {
|
||||
title: "Components / Footer Link Input",
|
||||
component: "ak-admin-settings-footer-link",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: "A stylized control for the footer links",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(story: Decorator) => {
|
||||
window.setTimeout(() => {
|
||||
const control = document.getElementById("footer-link");
|
||||
if (!control) {
|
||||
throw new Error("Test was not initialized correctly.");
|
||||
}
|
||||
const messages = document.getElementById("reported-value");
|
||||
control.addEventListener("change", (event: Event) => {
|
||||
if (!event.target) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as FooterLinkInput;
|
||||
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.isValid ? "Yes" : "No"}`;
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return html`<div
|
||||
style="background: #fff; padding: 2em; position: relative"
|
||||
id="the-main-event"
|
||||
>
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
#the-answer-block {
|
||||
padding-top: 3em;
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
${
|
||||
// @ts-expect-error The types for web components are not well-defined }
|
||||
story()
|
||||
}
|
||||
</div>
|
||||
<div style="margin-top: 2rem">
|
||||
<p>Reported value:</p>
|
||||
<pre id="reported-value"></pre>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default metadata;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () =>
|
||||
html` <ak-admin-settings-footer-link
|
||||
id="footer-link"
|
||||
name="the-footer"
|
||||
></ak-admin-settings-footer-link>`,
|
||||
};
|
||||
@ -0,0 +1,68 @@
|
||||
import { render } from "@goauthentik/elements/tests/utils.js";
|
||||
import { $, expect } from "@wdio/globals";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import "../AdminSettingsFooterLinks.js";
|
||||
|
||||
describe("ak-admin-settings-footer-link", () => {
|
||||
afterEach(async () => {
|
||||
await browser.execute(async () => {
|
||||
await document.body.querySelector("ak-admin-settings-footer-link")?.remove();
|
||||
if (document.body["_$litPart$"]) {
|
||||
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
|
||||
await delete document.body["_$litPart$"];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should render an empty control", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(false);
|
||||
await expect(await link.getProperty("toJson")).toEqual({ name: "", href: "" });
|
||||
});
|
||||
|
||||
it("should not be valid if just a name is filled in", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await link.$('input[name="name"]').setValue("foo");
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(false);
|
||||
await expect(await link.getProperty("toJson")).toEqual({ name: "foo", href: "" });
|
||||
});
|
||||
|
||||
it("should be valid if just a URL is filled in", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await link.$('input[name="href"]').setValue("https://foo.com");
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(true);
|
||||
await expect(await link.getProperty("toJson")).toEqual({
|
||||
name: "",
|
||||
href: "https://foo.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should be valid if both are filled in", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await link.$('input[name="name"]').setValue("foo");
|
||||
await link.$('input[name="href"]').setValue("https://foo.com");
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(true);
|
||||
await expect(await link.getProperty("toJson")).toEqual({
|
||||
name: "foo",
|
||||
href: "https://foo.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be valid if the URL is not valid", async () => {
|
||||
render(html`<ak-admin-settings-footer-link name="link"></ak-admin-settings-footer-link>`);
|
||||
const link = await $("ak-admin-settings-footer-link");
|
||||
await link.$('input[name="name"]').setValue("foo");
|
||||
await link.$('input[name="href"]').setValue("never://foo.com");
|
||||
await expect(await link.getProperty("toJson")).toEqual({
|
||||
name: "foo",
|
||||
href: "never://foo.com",
|
||||
});
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(false);
|
||||
});
|
||||
});
|
||||
@ -2,6 +2,7 @@ import "@goauthentik/admin/applications/ApplicationForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import MDApplication from "@goauthentik/docs/add-secure-apps/applications/index.md";
|
||||
import "@goauthentik/elements/AppIcon.js";
|
||||
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
|
||||
import "@goauthentik/elements/Markdown";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
import "@goauthentik/elements/forms/DeleteBulkForm";
|
||||
@ -12,7 +13,7 @@ import { TableColumn } from "@goauthentik/elements/table/Table";
|
||||
import { TablePage } from "@goauthentik/elements/table/TablePage";
|
||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
@ -40,7 +41,7 @@ export const applicationListStyle = css`
|
||||
`;
|
||||
|
||||
@customElement("ak-application-list")
|
||||
export class ApplicationListPage extends TablePage<Application> {
|
||||
export class ApplicationListPage extends WithBrandConfig(TablePage<Application>) {
|
||||
searchEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
@ -49,7 +50,7 @@ export class ApplicationListPage extends TablePage<Application> {
|
||||
}
|
||||
pageDescription(): string {
|
||||
return msg(
|
||||
"External applications that use authentik as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access.",
|
||||
str`External applications that use ${this.brand.brandingTitle || "authentik"} as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access.`,
|
||||
);
|
||||
}
|
||||
pageIcon(): string {
|
||||
|
||||
@ -28,6 +28,7 @@ import {
|
||||
type TransactionApplicationRequest,
|
||||
type TransactionApplicationResponse,
|
||||
ValidationError,
|
||||
instanceOfValidationError,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import BasePanel from "../BasePanel";
|
||||
@ -77,6 +78,8 @@ const successState: State = {
|
||||
};
|
||||
|
||||
type StrictProviderModelEnum = Exclude<ProviderModelEnum, "11184809">;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isValidationError = (v: any): v is ValidationError => instanceOfValidationError(v);
|
||||
|
||||
@customElement("ak-application-wizard-commit-application")
|
||||
export class ApplicationWizardCommitApplication extends BasePanel {
|
||||
@ -152,7 +155,23 @@ export class ApplicationWizardCommitApplication extends BasePanel {
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.catch(async (resolution: any) => {
|
||||
this.errors = await parseAPIError(resolution);
|
||||
const errors = await parseAPIError(resolution);
|
||||
console.log(errors);
|
||||
|
||||
// THIS is a really gross special case; if the user is duplicating the name of an
|
||||
// existing provider, the error appears on the `app` (!) error object. We have to
|
||||
// move that to the `provider.name` error field so it shows up in the right place.
|
||||
if (isValidationError(errors) && Array.isArray(errors?.app?.provider)) {
|
||||
const providerError = errors.app.provider;
|
||||
errors.provider = errors.provider ?? {};
|
||||
errors.provider.name = providerError;
|
||||
delete errors.app.provider;
|
||||
if (Object.keys(errors.app).length === 0) {
|
||||
delete errors.app;
|
||||
}
|
||||
}
|
||||
|
||||
this.errors = errors;
|
||||
this.dispatchWizardUpdate({
|
||||
update: {
|
||||
...this.wizard,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import "@goauthentik/admin/users/ServiceAccountForm";
|
||||
import "@goauthentik/admin/users/UserActiveForm";
|
||||
import "@goauthentik/admin/users/UserForm";
|
||||
import "@goauthentik/admin/users/UserImpersonateForm";
|
||||
import "@goauthentik/admin/users/UserPasswordForm";
|
||||
import "@goauthentik/admin/users/UserResetEmailForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { PFSize } from "@goauthentik/common/enums.js";
|
||||
import { MessageLevel } from "@goauthentik/common/messages";
|
||||
import { me } from "@goauthentik/common/users";
|
||||
import { getRelativeTime } from "@goauthentik/common/utils";
|
||||
@ -213,20 +215,22 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
</ak-forms-modal>
|
||||
${canImpersonate
|
||||
? html`
|
||||
<ak-action-button
|
||||
class="pf-m-tertiary"
|
||||
.apiRequest=${() => {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: item.pk,
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
}}
|
||||
>
|
||||
${msg("Impersonate")}
|
||||
</ak-action-button>
|
||||
<ak-forms-modal size=${PFSize.Medium} id="impersonate-request">
|
||||
<span slot="submit">${msg("Impersonate")}</span>
|
||||
<span slot="header">${msg("Impersonate")} ${item.username}</span>
|
||||
<ak-user-impersonate-form
|
||||
slot="form"
|
||||
.instancePk=${item.pk}
|
||||
></ak-user-impersonate-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-tertiary">
|
||||
<pf-tooltip
|
||||
position="top"
|
||||
content=${msg("Temporarily assume the identity of this user")}
|
||||
>
|
||||
<span>${msg("Impersonate")}</span>
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
`
|
||||
: html``}`,
|
||||
];
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import "@goauthentik/components/ak-radio-input";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
import "@goauthentik/components/ak-textarea-input";
|
||||
import "@goauthentik/elements/ak-array-input.js";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import "@goauthentik/elements/forms/Radio";
|
||||
import "@goauthentik/elements/forms/SearchSelect";
|
||||
import "@goauthentik/elements/utils/TimeDeltaHelp";
|
||||
|
||||
import { css } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
import { ClientTypeEnum, OAuth2Provider, ProvidersApi } from "@goauthentik/api";
|
||||
@ -19,6 +31,14 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
|
||||
@state()
|
||||
showClientSecret = true;
|
||||
|
||||
static get styles() {
|
||||
return super.styles.concat(css`
|
||||
ak-array-input {
|
||||
width: 100%;
|
||||
}
|
||||
`);
|
||||
}
|
||||
|
||||
async loadInstance(pk: number): Promise<OAuth2Provider> {
|
||||
const provider = await new ProvidersApi(DEFAULT_CONFIG).providersOauth2Retrieve({
|
||||
id: pk,
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import "@goauthentik/admin/common/ak-crypto-certificate-search";
|
||||
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
|
||||
import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils";
|
||||
import {
|
||||
IRedirectURIInput,
|
||||
akOAuthRedirectURIInput,
|
||||
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderRedirectURI";
|
||||
import { ascii_letters, digits, randomString } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-radio-input";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
import "@goauthentik/components/ak-textarea-input";
|
||||
import "@goauthentik/elements/ak-array-input.js";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
@ -20,7 +25,9 @@ import {
|
||||
ClientTypeEnum,
|
||||
FlowsInstancesListDesignationEnum,
|
||||
IssuerModeEnum,
|
||||
MatchingModeEnum,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
SubModeEnum,
|
||||
ValidationError,
|
||||
} from "@goauthentik/api";
|
||||
@ -95,13 +102,13 @@ export const issuerModeOptions = [
|
||||
|
||||
const redirectUriHelpMessages = [
|
||||
msg(
|
||||
"Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.",
|
||||
"Valid redirect URIs after a successful authorization flow. Also specify any origins here for Implicit flows.",
|
||||
),
|
||||
msg(
|
||||
"If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.",
|
||||
),
|
||||
msg(
|
||||
'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.',
|
||||
'To allow any redirect URI, set the mode to Regex and the value to ".*". Be aware of the possible security implications this can have.',
|
||||
),
|
||||
];
|
||||
|
||||
@ -156,27 +163,36 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="clientId"
|
||||
label=${msg("Client ID")}
|
||||
value="${first(provider?.clientId, randomString(40, ascii_letters + digits))}"
|
||||
value="${provider?.clientId ?? randomString(40, ascii_letters + digits)}"
|
||||
required
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-text-input
|
||||
name="clientSecret"
|
||||
label=${msg("Client Secret")}
|
||||
value="${first(
|
||||
provider?.clientSecret,
|
||||
randomString(128, ascii_letters + digits),
|
||||
)}"
|
||||
value="${provider?.clientSecret ?? randomString(128, ascii_letters + digits)}"
|
||||
?hidden=${!showClientSecret}
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-textarea-input
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Redirect URIs/Origins")}
|
||||
required
|
||||
name="redirectUris"
|
||||
label=${msg("Redirect URIs/Origins (RegEx)")}
|
||||
.value=${provider?.redirectUris}
|
||||
.bighelp=${redirectUriHelp}
|
||||
>
|
||||
</ak-textarea-input>
|
||||
<ak-array-input
|
||||
name="redirectUris"
|
||||
.items=${provider?.redirectUris ?? []}
|
||||
.newItem=${() => ({ matchingMode: MatchingModeEnum.Strict, url: "" })}
|
||||
.row=${(f?: RedirectURI) =>
|
||||
akOAuthRedirectURIInput({
|
||||
".redirectURI": f,
|
||||
"style": "width: 100%",
|
||||
"name": "oauth2-redirect-uri",
|
||||
} as unknown as IRedirectURIInput)}
|
||||
>
|
||||
</ak-array-input>
|
||||
${redirectUriHelp}
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
|
||||
<!-- NOTE: 'null' cast to 'undefined' on signingKey to satisfy Lit requirements -->
|
||||
@ -238,7 +254,7 @@ export function renderForm(
|
||||
name="accessCodeValidity"
|
||||
label=${msg("Access code validity")}
|
||||
required
|
||||
value="${first(provider?.accessCodeValidity, "minutes=1")}"
|
||||
value="${provider?.accessCodeValidity ?? "minutes=1"}"
|
||||
.bighelp=${html`<p class="pf-c-form__helper-text">
|
||||
${msg("Configure how long access codes are valid for.")}
|
||||
</p>
|
||||
@ -248,7 +264,7 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="accessTokenValidity"
|
||||
label=${msg("Access Token validity")}
|
||||
value="${first(provider?.accessTokenValidity, "minutes=5")}"
|
||||
value="${provider?.accessTokenValidity ?? "minutes=5"}"
|
||||
required
|
||||
.bighelp=${html` <p class="pf-c-form__helper-text">
|
||||
${msg("Configure how long access tokens are valid for.")}
|
||||
@ -260,7 +276,7 @@ export function renderForm(
|
||||
<ak-text-input
|
||||
name="refreshTokenValidity"
|
||||
label=${msg("Refresh Token validity")}
|
||||
value="${first(provider?.refreshTokenValidity, "days=30")}"
|
||||
value="${provider?.refreshTokenValidity ?? "days=30"}"
|
||||
?required=${true}
|
||||
.bighelp=${html` <p class="pf-c-form__helper-text">
|
||||
${msg("Configure how long refresh tokens are valid for.")}
|
||||
@ -296,7 +312,7 @@ export function renderForm(
|
||||
<ak-switch-input
|
||||
name="includeClaimsInIdToken"
|
||||
label=${msg("Include claims in id_token")}
|
||||
?checked=${first(provider?.includeClaimsInIdToken, true)}
|
||||
?checked=${provider?.includeClaimsInIdToken ?? true}
|
||||
help=${msg(
|
||||
"Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.",
|
||||
)}
|
||||
|
||||
104
web/src/admin/providers/oauth2/OAuth2ProviderRedirectURI.ts
Normal file
104
web/src/admin/providers/oauth2/OAuth2ProviderRedirectURI.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderRedirectURI";
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { type Spread } from "@goauthentik/elements/types";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { MatchingModeEnum, RedirectURI } from "@goauthentik/api";
|
||||
|
||||
export interface IRedirectURIInput {
|
||||
redirectURI: RedirectURI;
|
||||
}
|
||||
|
||||
@customElement("ak-provider-oauth2-redirect-uri")
|
||||
export class OAuth2ProviderRedirectURI extends AkControlElement<RedirectURI> {
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFInputGroup,
|
||||
PFFormControl,
|
||||
css`
|
||||
.pf-c-input-group select {
|
||||
width: 10em;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
redirectURI: RedirectURI = {
|
||||
matchingMode: MatchingModeEnum.Strict,
|
||||
url: "",
|
||||
};
|
||||
|
||||
@queryAll(".ak-form-control")
|
||||
controls?: HTMLInputElement[];
|
||||
|
||||
json() {
|
||||
return Object.fromEntries(
|
||||
Array.from(this.controls ?? []).map((control) => [control.name, control.value]),
|
||||
) as unknown as RedirectURI;
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
return true;
|
||||
}
|
||||
|
||||
render() {
|
||||
const onChange = () => {
|
||||
this.dispatchEvent(new Event("change", { composed: true, bubbles: true }));
|
||||
};
|
||||
|
||||
return html`<div class="pf-c-input-group">
|
||||
<select
|
||||
name="matchingMode"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
@change=${onChange}
|
||||
>
|
||||
<option
|
||||
value="${MatchingModeEnum.Strict}"
|
||||
?selected=${this.redirectURI.matchingMode === MatchingModeEnum.Strict}
|
||||
>
|
||||
${msg("Strict")}
|
||||
</option>
|
||||
<option
|
||||
value="${MatchingModeEnum.Regex}"
|
||||
?selected=${this.redirectURI.matchingMode === MatchingModeEnum.Regex}
|
||||
>
|
||||
${msg("Regex")}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
@change=${onChange}
|
||||
value="${ifDefined(this.redirectURI.url ?? undefined)}"
|
||||
class="pf-c-form-control ak-form-control"
|
||||
required
|
||||
id="url"
|
||||
placeholder=${msg("URL")}
|
||||
name="url"
|
||||
tabindex="1"
|
||||
/>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function akOAuthRedirectURIInput(properties: IRedirectURIInput) {
|
||||
return html`<ak-provider-oauth2-redirect-uri
|
||||
${spread(properties as unknown as Spread)}
|
||||
></ak-provider-oauth2-redirect-uri>`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-provider-oauth2-redirect-uri": OAuth2ProviderRedirectURI;
|
||||
}
|
||||
}
|
||||
@ -234,7 +234,11 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
</dt>
|
||||
<dd class="pf-c-description-list__description">
|
||||
<div class="pf-c-description-list__text">
|
||||
${this.provider.redirectUris}
|
||||
<ul>
|
||||
${this.provider.redirectUris.map((ru) => {
|
||||
return html`<li>${ru.matchingMode}: ${ru.url}</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@ -392,9 +392,13 @@ export class ProxyProviderViewPage extends AKElement {
|
||||
<dd class="pf-c-description-list__description">
|
||||
<div class="pf-c-description-list__text">
|
||||
<ul class="pf-c-list">
|
||||
${this.provider.redirectUris.split("\n").map((url) => {
|
||||
return html`<li><pre>${url}</pre></li>`;
|
||||
})}
|
||||
<ul>
|
||||
${this.provider.redirectUris.map((ru) => {
|
||||
return html`<li>
|
||||
${ru.matchingMode}: ${ru.url}
|
||||
</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
@ -2,6 +2,7 @@ import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { first } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-number-input";
|
||||
import "@goauthentik/components/ak-switch-input";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
|
||||
@ -80,6 +81,15 @@ export class CaptchaStageForm extends BaseStageForm<CaptchaStage> {
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-switch-input
|
||||
name="interactive"
|
||||
label=${msg("Interactive")}
|
||||
?checked="${this.instance?.interactive}"
|
||||
help=${msg(
|
||||
"Enable this flag if the configured captcha requires User-interaction. Required for reCAPTCHA v2, hCaptcha and Cloudflare Turnstile.",
|
||||
)}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-number-input
|
||||
label=${msg("Score minimum threshold")}
|
||||
required
|
||||
|
||||
@ -20,6 +20,9 @@ export class UserForm extends ModelForm<User, number> {
|
||||
@property({ attribute: false })
|
||||
group?: Group;
|
||||
|
||||
@property()
|
||||
defaultPath: string = "users";
|
||||
|
||||
static get defaultUserAttributes(): { [key: string]: unknown } {
|
||||
return {};
|
||||
}
|
||||
@ -172,7 +175,7 @@ export class UserForm extends ModelForm<User, number> {
|
||||
<ak-form-element-horizontal label=${msg("Path")} ?required=${true} name="path">
|
||||
<input
|
||||
type="text"
|
||||
value="${first(this.instance?.path, "users")}"
|
||||
value="${first(this.instance?.path, this.defaultPath)}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
|
||||
40
web/src/admin/users/UserImpersonateForm.ts
Normal file
40
web/src/admin/users/UserImpersonateForm.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
import { Form } from "@goauthentik/elements/forms/Form";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { CoreApi, ImpersonationRequest } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-user-impersonate-form")
|
||||
export class UserImpersonateForm extends Form<ImpersonationRequest> {
|
||||
@property({ type: Number })
|
||||
instancePk?: number;
|
||||
|
||||
async send(data: ImpersonationRequest): Promise<void> {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: this.instancePk || 0,
|
||||
impersonationRequest: data,
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<ak-text-input
|
||||
name="reason"
|
||||
label=${msg("Reason")}
|
||||
help=${msg("Reason for impersonating the user")}
|
||||
></ak-text-input>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-user-impersonate-form": UserImpersonateForm;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import { AdminInterface } from "@goauthentik/admin/AdminInterface";
|
||||
import "@goauthentik/admin/users/ServiceAccountForm";
|
||||
import "@goauthentik/admin/users/UserActiveForm";
|
||||
import "@goauthentik/admin/users/UserForm";
|
||||
import "@goauthentik/admin/users/UserImpersonateForm";
|
||||
import "@goauthentik/admin/users/UserPasswordForm";
|
||||
import "@goauthentik/admin/users/UserResetEmailForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
@ -266,20 +267,22 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
</ak-forms-modal>
|
||||
${canImpersonate
|
||||
? html`
|
||||
<ak-action-button
|
||||
class="pf-m-tertiary"
|
||||
.apiRequest=${() => {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: item.pk,
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
}}
|
||||
>
|
||||
${msg("Impersonate")}
|
||||
</ak-action-button>
|
||||
<ak-forms-modal size=${PFSize.Medium} id="impersonate-request">
|
||||
<span slot="submit">${msg("Impersonate")}</span>
|
||||
<span slot="header">${msg("Impersonate")} ${item.username}</span>
|
||||
<ak-user-impersonate-form
|
||||
slot="form"
|
||||
.instancePk=${item.pk}
|
||||
></ak-user-impersonate-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-tertiary">
|
||||
<pf-tooltip
|
||||
position="top"
|
||||
content=${msg("Temporarily assume the identity of this user")}
|
||||
>
|
||||
<span>${msg("Impersonate")}</span>
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
`
|
||||
: html``}`,
|
||||
];
|
||||
@ -392,7 +395,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
<ak-forms-modal>
|
||||
<span slot="submit"> ${msg("Create")} </span>
|
||||
<span slot="header"> ${msg("Create User")} </span>
|
||||
<ak-user-form slot="form"> </ak-user-form>
|
||||
<ak-user-form defaultPath=${this.activePath} slot="form"> </ak-user-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
|
||||
</ak-forms-modal>
|
||||
<ak-forms-modal .closeAfterSuccessfulSubmit=${false} .cancelText=${msg("Close")}>
|
||||
@ -414,6 +417,9 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
<ak-treeview
|
||||
.items=${this.userPaths?.paths || []}
|
||||
activePath=${this.activePath}
|
||||
@ak-refresh=${(ev: CustomEvent<{ path: string }>) => {
|
||||
this.activePath = ev.detail.path;
|
||||
}}
|
||||
></ak-treeview>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import "@goauthentik/admin/users/UserActiveForm";
|
||||
import "@goauthentik/admin/users/UserApplicationTable";
|
||||
import "@goauthentik/admin/users/UserChart";
|
||||
import "@goauthentik/admin/users/UserForm";
|
||||
import "@goauthentik/admin/users/UserImpersonateForm";
|
||||
import {
|
||||
renderRecoveryEmailRequest,
|
||||
requestRecoveryLink,
|
||||
@ -208,26 +209,22 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
|
||||
</ak-user-active-form>
|
||||
${canImpersonate
|
||||
? html`
|
||||
<ak-action-button
|
||||
class="pf-m-secondary pf-m-block"
|
||||
id="impersonate-user-button"
|
||||
.apiRequest=${() => {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: user.pk,
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
});
|
||||
}}
|
||||
>
|
||||
<pf-tooltip
|
||||
position="top"
|
||||
content=${msg("Temporarily assume the identity of this user")}
|
||||
>
|
||||
${msg("Impersonate")}
|
||||
</pf-tooltip>
|
||||
</ak-action-button>
|
||||
<ak-forms-modal size=${PFSize.Medium} id="impersonate-request">
|
||||
<span slot="submit">${msg("Impersonate")}</span>
|
||||
<span slot="header">${msg("Impersonate")} ${user.username}</span>
|
||||
<ak-user-impersonate-form
|
||||
slot="form"
|
||||
.instancePk=${user.pk}
|
||||
></ak-user-impersonate-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-secondary pf-m-block">
|
||||
<pf-tooltip
|
||||
position="top"
|
||||
content=${msg("Temporarily assume the identity of this user")}
|
||||
>
|
||||
<span>${msg("Impersonate")}</span>
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
`
|
||||
: nothing}
|
||||
</div> `;
|
||||
|
||||
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2024.10.0";
|
||||
export const VERSION = "2024.10.4";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
||||
@ -10,10 +10,14 @@ export const DOM_PURIFY_STRICT: DOMPurify.Config = {
|
||||
ALLOWED_TAGS: ["#text"],
|
||||
};
|
||||
|
||||
export async function renderStatic(input: TemplateResult): Promise<string> {
|
||||
return await collectResult(render(input));
|
||||
}
|
||||
|
||||
export function purify(input: TemplateResult): TemplateResult {
|
||||
return html`${until(
|
||||
(async () => {
|
||||
const rendered = await collectResult(render(input));
|
||||
const rendered = await renderStatic(input);
|
||||
const purified = DOMPurify.sanitize(rendered);
|
||||
return html`${unsafeHTML(purified)}`;
|
||||
})(),
|
||||
|
||||
@ -8,13 +8,21 @@ import { AKElement } from "./Base";
|
||||
* extracting the value.
|
||||
*
|
||||
*/
|
||||
export class AkControlElement extends AKElement {
|
||||
export class AkControlElement<T = string | string[]> extends AKElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.dataset.akControl = "true";
|
||||
}
|
||||
|
||||
json() {
|
||||
json(): T {
|
||||
throw new Error("Controllers using this protocol must override this method");
|
||||
}
|
||||
|
||||
get toJson(): T {
|
||||
return this.json();
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,6 +89,9 @@ export class TreeViewNode extends AKElement {
|
||||
new CustomEvent(EVENT_REFRESH, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
path: this.fullPath,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
|
||||
173
web/src/elements/ak-array-input.ts
Normal file
173
web/src/elements/ak-array-input.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement";
|
||||
import { bound } from "@goauthentik/elements/decorators/bound";
|
||||
import { type Spread } from "@goauthentik/elements/types";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId.js";
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, css, html, nothing } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
type InputCell<T> = (el: T) => TemplateResult | typeof nothing;
|
||||
|
||||
export interface IArrayInput<T> {
|
||||
row: InputCell<T>;
|
||||
newItem: () => T;
|
||||
items: T[];
|
||||
validate?: boolean;
|
||||
validator?: (_: T[]) => boolean;
|
||||
}
|
||||
|
||||
type Keyed<T> = { key: string; item: T };
|
||||
|
||||
@customElement("ak-array-input")
|
||||
export class ArrayInput<T> extends AkControlElement<T[]> implements IArrayInput<T> {
|
||||
static get styles() {
|
||||
return [
|
||||
PFBase,
|
||||
PFButton,
|
||||
PFInputGroup,
|
||||
PFFormControl,
|
||||
css`
|
||||
select.pf-c-form-control {
|
||||
width: 100px;
|
||||
}
|
||||
.pf-c-input-group {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.ak-plus-button {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: row;
|
||||
}
|
||||
.ak-input-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@property({ type: Boolean })
|
||||
validate = false;
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
validator?: (_: T[]) => boolean;
|
||||
|
||||
@property({ type: Array, attribute: false })
|
||||
row!: InputCell<T>;
|
||||
|
||||
@property({ type: Object, attribute: false })
|
||||
newItem!: () => T;
|
||||
|
||||
_items: Keyed<T>[] = [];
|
||||
|
||||
// This magic creates a semi-reliable key on which Lit's `repeat` directive can control its
|
||||
// interaction. Without it, we get undefined behavior in the re-rendering of the array.
|
||||
@property({ type: Array, attribute: false })
|
||||
set items(items: T[]) {
|
||||
const olditems = new Map(
|
||||
(this._items ?? []).map((key, item) => [JSON.stringify(item), key]),
|
||||
);
|
||||
const newitems = items.map((item) => ({
|
||||
item,
|
||||
key: olditems.get(JSON.stringify(item))?.key ?? randomId(),
|
||||
}));
|
||||
this._items = newitems;
|
||||
}
|
||||
|
||||
get items() {
|
||||
return this._items.map(({ item }) => item);
|
||||
}
|
||||
|
||||
@queryAll("div.ak-input-group")
|
||||
inputGroups?: HTMLDivElement[];
|
||||
|
||||
json() {
|
||||
if (!this.inputGroups) {
|
||||
throw new Error("Could not find input group collection in ak-array-input");
|
||||
}
|
||||
return this.items;
|
||||
}
|
||||
|
||||
get isValid() {
|
||||
if (!this.validate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oneIsValid = (g: HTMLDivElement) =>
|
||||
g.querySelector<HTMLInputElement & AkControlElement<T>>("[name]")?.isValid ?? true;
|
||||
const allAreValid = Array.from(this.inputGroups ?? []).every(oneIsValid);
|
||||
return allAreValid && (this.validator ? this.validator(this.items) : true);
|
||||
}
|
||||
|
||||
itemsFromDom(): T[] {
|
||||
return Array.from(this.inputGroups ?? [])
|
||||
.map(
|
||||
(group) =>
|
||||
group.querySelector<HTMLInputElement & AkControlElement<T>>("[name]")?.json() ??
|
||||
null,
|
||||
)
|
||||
.filter((i) => i !== null);
|
||||
}
|
||||
|
||||
sendChange() {
|
||||
this.dispatchEvent(new Event("change", { composed: true, bubbles: true }));
|
||||
}
|
||||
|
||||
@bound
|
||||
onChange() {
|
||||
this.items = this.itemsFromDom();
|
||||
this.sendChange();
|
||||
}
|
||||
|
||||
@bound
|
||||
addNewGroup() {
|
||||
this.items = [...this.itemsFromDom(), this.newItem()];
|
||||
this.sendChange();
|
||||
}
|
||||
|
||||
renderDeleteButton(idx: number) {
|
||||
const deleteOneGroup = () => {
|
||||
this.items = [...this.items.slice(0, idx), ...this.items.slice(idx + 1)];
|
||||
this.sendChange();
|
||||
};
|
||||
|
||||
return html`<button class="pf-c-button pf-m-control" type="button" @click=${deleteOneGroup}>
|
||||
<i class="fas fa-minus" aria-hidden="true"></i>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html` <div class="pf-l-stack">
|
||||
${repeat(
|
||||
this._items,
|
||||
(item: Keyed<T>) => item.key,
|
||||
(item: Keyed<T>, idx) =>
|
||||
html` <div class="ak-input-group" @change=${() => this.onChange()}>
|
||||
${this.row(item.item)}${this.renderDeleteButton(idx)}
|
||||
</div>`,
|
||||
)}
|
||||
<button class="pf-c-button pf-m-link" type="button" @click=${this.addNewGroup}>
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> ${msg("Add entry")}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function akArrayInput<T>(properties: IArrayInput<T>) {
|
||||
return html`<ak-array-input ${spread(properties as unknown as Spread)}></ak-array-input>`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-array-input": ArrayInput<unknown>;
|
||||
}
|
||||
}
|
||||
@ -42,18 +42,19 @@ const debug: LocaleRow = [
|
||||
|
||||
// prettier-ignore
|
||||
const LOCALE_TABLE: LocaleRow[] = [
|
||||
["de", /^de([_-]|$)/i, () => msg("German"), async () => await import("@goauthentik/locales/de")],
|
||||
["en", /^en([_-]|$)/i, () => msg("English"), async () => await import("@goauthentik/locales/en")],
|
||||
["es", /^es([_-]|$)/i, () => msg("Spanish"), async () => await import("@goauthentik/locales/es")],
|
||||
["de", /^de([_-]|$)/i, () => msg("German"), async () => await import("@goauthentik/locales/de")],
|
||||
["fr", /^fr([_-]|$)/i, () => msg("French"), async () => await import("@goauthentik/locales/fr")],
|
||||
["it", /^it([_-]|$)/i, () => msg("Italian"), async () => await import("@goauthentik/locales/it")],
|
||||
["ko", /^ko([_-]|$)/i, () => msg("Korean"), async () => await import("@goauthentik/locales/ko")],
|
||||
["nl", /^nl([_-]|$)/i, () => msg("Dutch"), async () => await import("@goauthentik/locales/nl")],
|
||||
["pl", /^pl([_-]|$)/i, () => msg("Polish"), async () => await import("@goauthentik/locales/pl")],
|
||||
["ru", /^ru([_-]|$)/i, () => msg("Russian"), async () => await import("@goauthentik/locales/ru")],
|
||||
["tr", /^tr([_-]|$)/i, () => msg("Turkish"), async () => await import("@goauthentik/locales/tr")],
|
||||
["zh-Hant", /^zh[_-](HK|Hant)/i, () => msg("Chinese (traditional)"), async () => await import("@goauthentik/locales/zh-Hant")],
|
||||
["zh_TW", /^zh[_-]TW$/i, () => msg("Taiwanese Mandarin"), async () => await import("@goauthentik/locales/zh_TW")],
|
||||
["zh-Hans", /^zh(\b|_)/i, () => msg("Chinese (simplified)"), async () => await import("@goauthentik/locales/zh-Hans")],
|
||||
["zh-Hant", /^zh[_-](HK|Hant)/i, () => msg("Chinese (traditional)"), async () => await import("@goauthentik/locales/zh-Hant")],
|
||||
debug
|
||||
];
|
||||
|
||||
|
||||
@ -35,7 +35,7 @@ export interface KeyUnknown {
|
||||
// Literally the only field `assignValue()` cares about.
|
||||
type HTMLNamedElement = Pick<HTMLInputElement, "name">;
|
||||
|
||||
type AkControlElement = HTMLInputElement & { json: () => string | string[] };
|
||||
export type AkControlElement<T = string | string[]> = HTMLInputElement & { json: () => T };
|
||||
|
||||
/**
|
||||
* Recursively assign `value` into `json` while interpreting the dot-path of `element.name`
|
||||
|
||||
@ -2,7 +2,7 @@ import { convertToSlug } from "@goauthentik/common/utils";
|
||||
import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { FormGroup } from "@goauthentik/elements/forms/FormGroup";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, css } from "lit";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
@ -33,7 +33,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
* where the field isn't available for the user to view unless they explicitly request to be able
|
||||
* to see the content; otherwise, a dead password field is shown. There are 10 uses of this
|
||||
* feature.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
const isAkControl = (el: unknown): boolean =>
|
||||
@ -86,7 +86,7 @@ export class HorizontalFormElement extends AKElement {
|
||||
writeOnlyActivated = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
errorMessages: string[] = [];
|
||||
errorMessages: string[] | string[][] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
slugMode = false;
|
||||
@ -183,6 +183,16 @@ export class HorizontalFormElement extends AKElement {
|
||||
</p>`
|
||||
: html``}
|
||||
${this.errorMessages.map((message) => {
|
||||
if (message instanceof Object) {
|
||||
return html`${Object.entries(message).map(([field, errMsg]) => {
|
||||
return html`<p
|
||||
class="pf-c-form__helper-text pf-m-error"
|
||||
aria-live="polite"
|
||||
>
|
||||
${msg(str`${field}: ${errMsg}`)}
|
||||
</p>`;
|
||||
})}`;
|
||||
}
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
${message}
|
||||
</p>`;
|
||||
|
||||
@ -4,7 +4,6 @@ import { groupBy } from "@goauthentik/common/utils";
|
||||
import { AkControlElement } from "@goauthentik/elements/AkControlElement.js";
|
||||
import { PreventFormSubmit } from "@goauthentik/elements/forms/helpers";
|
||||
import type { GroupedOptions, SelectGroup, SelectOption } from "@goauthentik/elements/types.js";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@ -32,10 +31,7 @@ export interface ISearchSelectBase<T> {
|
||||
emptyOption: string;
|
||||
}
|
||||
|
||||
export class SearchSelectBase<T>
|
||||
extends CustomEmitterElement(AkControlElement)
|
||||
implements ISearchSelectBase<T>
|
||||
{
|
||||
export class SearchSelectBase<T> extends AkControlElement<string> implements ISearchSelectBase<T> {
|
||||
static get styles() {
|
||||
return [PFBase];
|
||||
}
|
||||
@ -54,7 +50,7 @@ export class SearchSelectBase<T>
|
||||
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
value!: (element: T | undefined) => unknown;
|
||||
value!: (element: T | undefined) => string;
|
||||
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
@ -105,7 +101,7 @@ export class SearchSelectBase<T>
|
||||
@state()
|
||||
error?: APIErrorTypes;
|
||||
|
||||
public toForm(): unknown {
|
||||
public toForm(): string {
|
||||
if (!this.objects) {
|
||||
throw new PreventFormSubmit(msg("Loading options..."));
|
||||
}
|
||||
@ -116,6 +112,16 @@ export class SearchSelectBase<T>
|
||||
return this.toForm();
|
||||
}
|
||||
|
||||
protected dispatchChangeEvent(value: T | undefined) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ak-change", {
|
||||
composed: true,
|
||||
bubbles: true,
|
||||
detail: { value },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async updateData() {
|
||||
if (this.isFetchingData) {
|
||||
return Promise.resolve();
|
||||
@ -127,7 +133,7 @@ export class SearchSelectBase<T>
|
||||
objects.forEach((obj) => {
|
||||
if (this.selected && this.selected(obj, objects || [])) {
|
||||
this.selectedObject = obj;
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
});
|
||||
this.objects = objects;
|
||||
@ -165,7 +171,7 @@ export class SearchSelectBase<T>
|
||||
|
||||
this.query = value;
|
||||
this.updateData()?.then(() => {
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
});
|
||||
}
|
||||
|
||||
@ -173,7 +179,7 @@ export class SearchSelectBase<T>
|
||||
const value = (event.target as SearchSelectView).value;
|
||||
if (value === undefined) {
|
||||
this.selectedObject = undefined;
|
||||
this.dispatchCustomEvent("ak-change", { value: undefined });
|
||||
this.dispatchChangeEvent(undefined);
|
||||
return;
|
||||
}
|
||||
const selected = (this.objects ?? []).find((obj) => `${this.value(obj)}` === value);
|
||||
@ -181,7 +187,7 @@ export class SearchSelectBase<T>
|
||||
console.warn(`ak-search-select: No corresponding object found for value (${value}`);
|
||||
}
|
||||
this.selectedObject = selected;
|
||||
this.dispatchCustomEvent("ak-change", { value: this.selectedObject });
|
||||
this.dispatchChangeEvent(this.selectedObject);
|
||||
}
|
||||
|
||||
private getGroupedItems(): GroupedOptions {
|
||||
|
||||
@ -7,7 +7,7 @@ export interface ISearchSelectApi<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | undefined) => unknown;
|
||||
value: (element: T | undefined) => string;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy?: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ export interface ISearchSelect<T> extends ISearchSelectBase<T> {
|
||||
fetchObjects: (query?: string) => Promise<T[]>;
|
||||
renderElement: (element: T) => string;
|
||||
renderDescription?: (element: T) => string | TemplateResult;
|
||||
value: (element: T | undefined) => unknown;
|
||||
value: (element: T | undefined) => string;
|
||||
selected?: (element: T, elements: T[]) => boolean;
|
||||
groupBy: (items: T[]) => [string, T[]][];
|
||||
}
|
||||
@ -69,7 +69,7 @@ export class SearchSelect<T> extends SearchSelectBase<T> implements ISearchSelec
|
||||
// A function which returns the currently selected object's primary key, used for serialization
|
||||
// into forms.
|
||||
@property({ attribute: false })
|
||||
value!: (element: T | undefined) => unknown;
|
||||
value!: (element: T | undefined) => string;
|
||||
|
||||
// A function passed to this object that determines an object in the collection under search
|
||||
// should be automatically selected. Only used when the search itself is responsible for
|
||||
|
||||
@ -92,7 +92,7 @@ export const GroupedAndEz = () => {
|
||||
const config: ISearchSelectApi<Sample> = {
|
||||
fetchObjects: getSamples,
|
||||
renderElement: (sample: Sample) => sample.name,
|
||||
value: (sample: Sample | undefined) => sample?.pk,
|
||||
value: (sample: Sample | undefined) => sample?.pk ?? "",
|
||||
groupBy: (samples: Sample[]) =>
|
||||
groupBy(samples, (sample: Sample) => sample.season[0] ?? ""),
|
||||
};
|
||||
|
||||
96
web/src/elements/stories/ak-array-input.stories.ts
Normal file
96
web/src/elements/stories/ak-array-input.stories.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
|
||||
import { FooterLinkInput } from "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
|
||||
import "@goauthentik/elements/messages/MessageContainer";
|
||||
import { Meta, StoryObj, WebComponentsRenderer } from "@storybook/web-components";
|
||||
import { DecoratorFunction } from "storybook/internal/types";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import { FooterLink } from "@goauthentik/api";
|
||||
|
||||
import "../ak-array-input.js";
|
||||
import { IArrayInput } from "../ak-array-input.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Decorator = DecoratorFunction<WebComponentsRenderer, any>;
|
||||
|
||||
const metadata: Meta<IArrayInput<unknown>> = {
|
||||
title: "Elements / Array Input",
|
||||
component: "ak-array-input",
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A table input object, in which multiple rows of related inputs can be grouped.",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(story: Decorator) => {
|
||||
window.setTimeout(() => {
|
||||
const menu = document.getElementById("ak-array-input");
|
||||
if (!menu) {
|
||||
throw new Error("Test was not initialized correctly.");
|
||||
}
|
||||
const messages = document.getElementById("reported-value");
|
||||
menu.addEventListener("change", (event: Event) => {
|
||||
if (!event?.target) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as FooterLinkInput;
|
||||
messages!.innerText = `${JSON.stringify(target.json(), null, 2)}\n\nValid: ${target.isValid ? "Yes" : "No"}`;
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return html`<div
|
||||
style="background: #fff; padding: 2em; position: relative"
|
||||
id="the-main-event"
|
||||
>
|
||||
<style>
|
||||
li {
|
||||
display: block;
|
||||
}
|
||||
p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
#the-answer-block {
|
||||
padding-top: 3em;
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
<p>Story:</p>
|
||||
${
|
||||
// @ts-expect-error The types for web components are not well-defined in Storybook yet }
|
||||
story()
|
||||
}
|
||||
<div style="margin-top: 2rem">
|
||||
<p>Reported value:</p>
|
||||
<pre id="reported-value"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default metadata;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
const items: FooterLink[] = [
|
||||
{ name: "authentik", href: "https://goauthentik.io" },
|
||||
{ name: "authentik docs", href: "https://docs.goauthentik.io/docs/" },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
render: () =>
|
||||
html` <ak-array-input
|
||||
id="ak-array-input"
|
||||
.items=${items}
|
||||
.newItem=${() => ({ name: "", href: "" })}
|
||||
.row=${(f?: FooterLink) =>
|
||||
html`<ak-admin-settings-footer-link name="footerLink" .footerLink=${f}>
|
||||
</ak-admin-settings-footer-link>`}
|
||||
validate
|
||||
></ak-array-input>`,
|
||||
};
|
||||
55
web/src/elements/tests/ak-array-input.test.ts
Normal file
55
web/src/elements/tests/ak-array-input.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import "@goauthentik/admin/admin-settings/AdminSettingsFooterLinks.js";
|
||||
import { render } from "@goauthentik/elements/tests/utils.js";
|
||||
import { $, expect } from "@wdio/globals";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import { FooterLink } from "@goauthentik/api";
|
||||
|
||||
import "../ak-array-input.js";
|
||||
|
||||
const sampleItems: FooterLink[] = [
|
||||
{ name: "authentik", href: "https://goauthentik.io" },
|
||||
{ name: "authentik docs", href: "https://docs.goauthentik.io/docs/" },
|
||||
];
|
||||
|
||||
describe("ak-array-input", () => {
|
||||
afterEach(async () => {
|
||||
await browser.execute(async () => {
|
||||
await document.body.querySelector("ak-array-input")?.remove();
|
||||
if (document.body["_$litPart$"]) {
|
||||
// @ts-expect-error expression of type '"_$litPart$"' is added by Lit
|
||||
await delete document.body["_$litPart$"];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const component = (items: FooterLink[] = []) =>
|
||||
render(
|
||||
html` <ak-array-input
|
||||
id="ak-array-input"
|
||||
.items=${items}
|
||||
.newItem=${() => ({ name: "", href: "" })}
|
||||
.row=${(f?: FooterLink) =>
|
||||
html`<ak-admin-settings-footer-link name="footerLink" .footerLink=${f}>
|
||||
</ak-admin-settings-footer-link>`}
|
||||
validate
|
||||
></ak-array-input>`,
|
||||
);
|
||||
|
||||
it("should render an empty control", async () => {
|
||||
await component();
|
||||
const link = await $("ak-array-input");
|
||||
await browser.pause(500);
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(true);
|
||||
await expect(await link.getProperty("toJson")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should render a populated component", async () => {
|
||||
await component(sampleItems);
|
||||
const link = await $("ak-array-input");
|
||||
await browser.pause(500);
|
||||
await expect(await link.getProperty("isValid")).toStrictEqual(true);
|
||||
await expect(await link.getProperty("toJson")).toEqual(sampleItems);
|
||||
});
|
||||
});
|
||||
@ -107,7 +107,7 @@ export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
|
||||
?loading="${this.authenticating}"
|
||||
header=${this.authenticating
|
||||
? msg("Authenticating...")
|
||||
: this.errorMessage || msg("Failed to authenticate")}
|
||||
: this.errorMessage || msg("Loading")}
|
||||
icon="fa-times"
|
||||
>
|
||||
</ak-empty-state>
|
||||
|
||||
@ -10,7 +10,7 @@ import "../../../stories/flow-interface";
|
||||
import "./CaptchaStage";
|
||||
|
||||
export default {
|
||||
title: "Flow / Stages / CaptchaStage",
|
||||
title: "Flow / Stages / Captcha",
|
||||
};
|
||||
|
||||
export const LoadingNoChallenge = () => {
|
||||
@ -25,92 +25,60 @@ export const LoadingNoChallenge = () => {
|
||||
</ak-storybook-interface>`;
|
||||
};
|
||||
|
||||
export const ChallengeGoogleReCaptcha: StoryObj = {
|
||||
render: ({ theme, challenge }) => {
|
||||
return html`<ak-storybook-interface theme=${theme}>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<div class="pf-c-login__main">
|
||||
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
|
||||
</div>
|
||||
</div></div
|
||||
></ak-storybook-interface>`;
|
||||
},
|
||||
args: {
|
||||
theme: "automatic",
|
||||
challenge: {
|
||||
pendingUser: "foo",
|
||||
pendingUserAvatar: "https://picsum.photos/64",
|
||||
jsUrl: "https://www.google.com/recaptcha/api.js",
|
||||
siteKey: "6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI",
|
||||
} as CaptchaChallenge,
|
||||
},
|
||||
argTypes: {
|
||||
theme: {
|
||||
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
|
||||
control: {
|
||||
type: "select",
|
||||
function captchaFactory(challenge: CaptchaChallenge): StoryObj {
|
||||
return {
|
||||
render: ({ theme, challenge }) => {
|
||||
return html`<ak-storybook-interface theme=${theme}>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<div class="pf-c-login__main">
|
||||
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
|
||||
</div>
|
||||
</div></div
|
||||
></ak-storybook-interface>`;
|
||||
},
|
||||
args: {
|
||||
theme: "automatic",
|
||||
challenge: challenge,
|
||||
},
|
||||
argTypes: {
|
||||
theme: {
|
||||
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
|
||||
control: {
|
||||
type: "select",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const ChallengeHCaptcha: StoryObj = {
|
||||
render: ({ theme, challenge }) => {
|
||||
return html`<ak-storybook-interface theme=${theme}>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<div class="pf-c-login__main">
|
||||
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
|
||||
</div>
|
||||
</div></div
|
||||
></ak-storybook-interface>`;
|
||||
},
|
||||
args: {
|
||||
theme: "automatic",
|
||||
challenge: {
|
||||
pendingUser: "foo",
|
||||
pendingUserAvatar: "https://picsum.photos/64",
|
||||
jsUrl: "https://js.hcaptcha.com/1/api.js",
|
||||
siteKey: "10000000-ffff-ffff-ffff-000000000001",
|
||||
} as CaptchaChallenge,
|
||||
},
|
||||
argTypes: {
|
||||
theme: {
|
||||
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
|
||||
control: {
|
||||
type: "select",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export const ChallengeHCaptcha = captchaFactory({
|
||||
pendingUser: "foo",
|
||||
pendingUserAvatar: "https://picsum.photos/64",
|
||||
jsUrl: "https://js.hcaptcha.com/1/api.js",
|
||||
siteKey: "10000000-ffff-ffff-ffff-000000000001",
|
||||
interactive: true,
|
||||
} as CaptchaChallenge);
|
||||
|
||||
export const ChallengeTurnstile: StoryObj = {
|
||||
render: ({ theme, challenge }) => {
|
||||
return html`<ak-storybook-interface theme=${theme}>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<div class="pf-c-login__main">
|
||||
<ak-stage-captcha .challenge=${challenge}></ak-stage-captcha>
|
||||
</div>
|
||||
</div></div
|
||||
></ak-storybook-interface>`;
|
||||
},
|
||||
args: {
|
||||
theme: "automatic",
|
||||
challenge: {
|
||||
pendingUser: "foo",
|
||||
pendingUserAvatar: "https://picsum.photos/64",
|
||||
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
siteKey: "1x00000000000000000000BB",
|
||||
} as CaptchaChallenge,
|
||||
},
|
||||
argTypes: {
|
||||
theme: {
|
||||
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
|
||||
control: {
|
||||
type: "select",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
export const ChallengeTurnstileVisible = captchaFactory({
|
||||
pendingUser: "foo",
|
||||
pendingUserAvatar: "https://picsum.photos/64",
|
||||
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
siteKey: "1x00000000000000000000AA",
|
||||
interactive: true,
|
||||
} as CaptchaChallenge);
|
||||
export const ChallengeTurnstileInvisible = captchaFactory({
|
||||
pendingUser: "foo",
|
||||
pendingUserAvatar: "https://picsum.photos/64",
|
||||
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
siteKey: "1x00000000000000000000BB",
|
||||
interactive: true,
|
||||
} as CaptchaChallenge);
|
||||
export const ChallengeTurnstileForce = captchaFactory({
|
||||
pendingUser: "foo",
|
||||
pendingUserAvatar: "https://picsum.photos/64",
|
||||
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
siteKey: "3x00000000000000000000FF",
|
||||
interactive: true,
|
||||
} as CaptchaChallenge);
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
///<reference types="@hcaptcha/types"/>
|
||||
import { renderStatic } from "@goauthentik/common/purify";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId";
|
||||
import "@goauthentik/flow/FormStatic";
|
||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||
import type { TurnstileObject } from "turnstile-types";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, PropertyValues, html } from "lit";
|
||||
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
|
||||
@ -24,12 +25,22 @@ interface TurnstileWindow extends Window {
|
||||
}
|
||||
type TokenHandler = (token: string) => void;
|
||||
|
||||
const captchaContainerID = "captcha-container";
|
||||
|
||||
@customElement("ak-stage-captcha")
|
||||
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton];
|
||||
return [
|
||||
PFBase,
|
||||
PFLogin,
|
||||
PFForm,
|
||||
PFFormControl,
|
||||
PFTitle,
|
||||
css`
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 73px; /* tmp */
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
handlers = [this.handleGReCaptcha, this.handleHCaptcha, this.handleTurnstile];
|
||||
@ -38,14 +49,17 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
error?: string;
|
||||
|
||||
@state()
|
||||
captchaInteractive: boolean = true;
|
||||
captchaFrame: HTMLIFrameElement;
|
||||
|
||||
@state()
|
||||
captchaContainer: HTMLDivElement;
|
||||
captchaDocumentContainer: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
scriptElement?: HTMLScriptElement;
|
||||
|
||||
@property({ type: Boolean })
|
||||
embedded = false;
|
||||
|
||||
@property()
|
||||
onTokenChange: TokenHandler = (token: string) => {
|
||||
this.host.submit({ component: "ak-stage-captcha", token });
|
||||
@ -53,8 +67,70 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.captchaContainer = document.createElement("div");
|
||||
this.captchaContainer.id = captchaContainerID;
|
||||
this.captchaFrame = document.createElement("iframe");
|
||||
this.captchaFrame.src = "about:blank";
|
||||
this.captchaFrame.id = `ak-captcha-${randomId()}`;
|
||||
|
||||
this.captchaDocumentContainer = document.createElement("div");
|
||||
this.captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
|
||||
this.messageCallback = this.messageCallback.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this.messageCallback);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this.messageCallback);
|
||||
if (!this.challenge.interactive) {
|
||||
document.body.removeChild(this.captchaDocumentContainer);
|
||||
}
|
||||
}
|
||||
|
||||
messageCallback(
|
||||
ev: MessageEvent<{
|
||||
source?: string;
|
||||
context?: string;
|
||||
message: string;
|
||||
token: string;
|
||||
}>,
|
||||
) {
|
||||
const msg = ev.data;
|
||||
if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") {
|
||||
return;
|
||||
}
|
||||
if (msg.message !== "captcha") {
|
||||
return;
|
||||
}
|
||||
this.onTokenChange(msg.token);
|
||||
}
|
||||
|
||||
async renderFrame(captchaElement: TemplateResult) {
|
||||
this.captchaFrame.contentWindow?.document.open();
|
||||
this.captchaFrame.contentWindow?.document.write(
|
||||
await renderStatic(
|
||||
html`<!doctype html>
|
||||
<html>
|
||||
<body style="display:flex;flex-direction:row;justify-content:center;">
|
||||
${captchaElement}
|
||||
<script src=${this.challenge.jsUrl}></script>
|
||||
<script>
|
||||
function callback(token) {
|
||||
window.parent.postMessage({
|
||||
message: "captcha",
|
||||
source: "goauthentik.io",
|
||||
context: "flow-executor",
|
||||
token: token,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
),
|
||||
);
|
||||
this.captchaFrame.contentWindow?.document.close();
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues<this>) {
|
||||
@ -64,15 +140,15 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
this.scriptElement.async = true;
|
||||
this.scriptElement.defer = true;
|
||||
this.scriptElement.dataset.akCaptchaScript = "true";
|
||||
this.scriptElement.onload = () => {
|
||||
this.scriptElement.onload = async () => {
|
||||
console.debug("authentik/stages/captcha: script loaded");
|
||||
let found = false;
|
||||
let lastError = undefined;
|
||||
this.handlers.forEach((handler) => {
|
||||
this.handlers.forEach(async (handler) => {
|
||||
let handlerFound = false;
|
||||
try {
|
||||
console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`);
|
||||
handlerFound = handler.apply(this);
|
||||
handlerFound = await handler.apply(this);
|
||||
if (handlerFound) {
|
||||
console.debug(
|
||||
`authentik/stages/captcha[${handler.name}]: handler succeeded`,
|
||||
@ -96,51 +172,79 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
.querySelectorAll("[data-ak-captcha-script=true]")
|
||||
.forEach((el) => el.remove());
|
||||
document.head.appendChild(this.scriptElement);
|
||||
if (!this.challenge.interactive) {
|
||||
document.body.appendChild(this.captchaDocumentContainer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleGReCaptcha(): boolean {
|
||||
async handleGReCaptcha(): Promise<boolean> {
|
||||
if (!Object.hasOwn(window, "grecaptcha")) {
|
||||
return false;
|
||||
}
|
||||
this.captchaInteractive = false;
|
||||
document.body.appendChild(this.captchaContainer);
|
||||
grecaptcha.ready(() => {
|
||||
const captchaId = grecaptcha.render(this.captchaContainer, {
|
||||
if (this.challenge.interactive) {
|
||||
this.renderFrame(
|
||||
html`<div
|
||||
class="g-recaptcha"
|
||||
data-sitekey="${this.challenge.siteKey}"
|
||||
data-callback="callback"
|
||||
></div>`,
|
||||
);
|
||||
} else {
|
||||
grecaptcha.ready(() => {
|
||||
const captchaId = grecaptcha.render(this.captchaDocumentContainer, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
size: "invisible",
|
||||
});
|
||||
grecaptcha.execute(captchaId);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleHCaptcha(): Promise<boolean> {
|
||||
if (!Object.hasOwn(window, "hcaptcha")) {
|
||||
return false;
|
||||
}
|
||||
if (this.challenge.interactive) {
|
||||
this.renderFrame(
|
||||
html`<div
|
||||
class="h-captcha"
|
||||
data-sitekey="${this.challenge.siteKey}"
|
||||
data-theme="${this.activeTheme ? this.activeTheme : "light"}"
|
||||
data-callback="callback"
|
||||
></div> `,
|
||||
);
|
||||
} else {
|
||||
const captchaId = hcaptcha.render(this.captchaDocumentContainer, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
size: "invisible",
|
||||
});
|
||||
grecaptcha.execute(captchaId);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
handleHCaptcha(): boolean {
|
||||
if (!Object.hasOwn(window, "hcaptcha")) {
|
||||
return false;
|
||||
hcaptcha.execute(captchaId);
|
||||
}
|
||||
this.captchaInteractive = false;
|
||||
document.body.appendChild(this.captchaContainer);
|
||||
const captchaId = hcaptcha.render(this.captchaContainer, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
size: "invisible",
|
||||
});
|
||||
hcaptcha.execute(captchaId);
|
||||
return true;
|
||||
}
|
||||
|
||||
handleTurnstile(): boolean {
|
||||
async handleTurnstile(): Promise<boolean> {
|
||||
if (!Object.hasOwn(window, "turnstile")) {
|
||||
return false;
|
||||
}
|
||||
this.captchaInteractive = false;
|
||||
document.body.appendChild(this.captchaContainer);
|
||||
(window as unknown as TurnstileWindow).turnstile.render(`#${captchaContainerID}`, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
});
|
||||
if (this.challenge.interactive) {
|
||||
this.renderFrame(
|
||||
html`<div
|
||||
class="cf-turnstile"
|
||||
data-sitekey="${this.challenge.siteKey}"
|
||||
data-callback="callback"
|
||||
></div>`,
|
||||
);
|
||||
} else {
|
||||
(window as unknown as TurnstileWindow).turnstile.render(this.captchaDocumentContainer, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -148,13 +252,19 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
if (this.error) {
|
||||
return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`;
|
||||
}
|
||||
if (this.captchaInteractive) {
|
||||
return html`${this.captchaContainer}`;
|
||||
if (this.challenge.interactive) {
|
||||
return html`${this.captchaFrame}`;
|
||||
}
|
||||
return html`<ak-empty-state loading header=${msg("Verifying...")}></ak-empty-state>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.embedded) {
|
||||
if (!this.challenge.interactive) {
|
||||
return html``;
|
||||
}
|
||||
return this.renderBody();
|
||||
}
|
||||
if (!this.challenge) {
|
||||
return html`<ak-empty-state loading> </ak-empty-state>`;
|
||||
}
|
||||
|
||||
@ -0,0 +1,87 @@
|
||||
import type { StoryObj } from "@storybook/web-components";
|
||||
|
||||
import { html } from "lit";
|
||||
|
||||
import "@patternfly/patternfly/components/Login/login.css";
|
||||
|
||||
import { FlowDesignationEnum, IdentificationChallenge, UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
import "../../../stories/flow-interface";
|
||||
import "./IdentificationStage";
|
||||
|
||||
export default {
|
||||
title: "Flow / Stages / Identification",
|
||||
};
|
||||
|
||||
export const LoadingNoChallenge = () => {
|
||||
return html`<ak-storybook-interface theme=${UiThemeEnum.Dark}>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<div class="pf-c-login__main">
|
||||
<ak-stage-identification></ak-stage-identification>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ak-storybook-interface>`;
|
||||
};
|
||||
|
||||
function identificationFactory(challenge: IdentificationChallenge): StoryObj {
|
||||
return {
|
||||
render: ({ theme, challenge }) => {
|
||||
return html`<ak-storybook-interface theme=${theme}>
|
||||
<div class="pf-c-login">
|
||||
<div class="pf-c-login__container">
|
||||
<div class="pf-c-login__main">
|
||||
<ak-stage-identification
|
||||
.challenge=${challenge}
|
||||
></ak-stage-identification>
|
||||
</div>
|
||||
</div></div
|
||||
></ak-storybook-interface>`;
|
||||
},
|
||||
args: {
|
||||
theme: "automatic",
|
||||
challenge: challenge,
|
||||
},
|
||||
argTypes: {
|
||||
theme: {
|
||||
options: [UiThemeEnum.Automatic, UiThemeEnum.Light, UiThemeEnum.Dark],
|
||||
control: {
|
||||
type: "select",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ChallengeDefault = identificationFactory({
|
||||
userFields: ["username"],
|
||||
passwordFields: false,
|
||||
flowDesignation: FlowDesignationEnum.Authentication,
|
||||
primaryAction: "Login",
|
||||
showSourceLabels: false,
|
||||
// jsUrl: "https://js.hcaptcha.com/1/api.js",
|
||||
// siteKey: "10000000-ffff-ffff-ffff-000000000001",
|
||||
// interactive: true,
|
||||
});
|
||||
|
||||
// https://developers.cloudflare.com/turnstile/troubleshooting/testing/
|
||||
export const ChallengeCaptchaTurnstileVisible = identificationFactory({
|
||||
userFields: ["username"],
|
||||
passwordFields: false,
|
||||
flowDesignation: FlowDesignationEnum.Authentication,
|
||||
primaryAction: "Login",
|
||||
showSourceLabels: false,
|
||||
flowInfo: {
|
||||
layout: "stacked",
|
||||
cancelUrl: "",
|
||||
title: "Foo",
|
||||
},
|
||||
captchaStage: {
|
||||
pendingUser: "",
|
||||
pendingUserAvatar: "",
|
||||
jsUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
siteKey: "1x00000000000000000000AA",
|
||||
interactive: true,
|
||||
},
|
||||
});
|
||||
@ -282,11 +282,11 @@ export class IdentificationStage extends BaseStage<
|
||||
? html`
|
||||
<input name="captchaToken" type="hidden" .value="${this.captchaToken}" />
|
||||
<ak-stage-captcha
|
||||
style="visibility: hidden; position:absolute;"
|
||||
.challenge=${this.challenge.captchaStage}
|
||||
.onTokenChange=${(token: string) => {
|
||||
this.captchaToken = token;
|
||||
}}
|
||||
embedded
|
||||
></ak-stage-captcha>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
@ -15,6 +15,7 @@ export const targetLocales = [
|
||||
`en`,
|
||||
`es`,
|
||||
`fr`,
|
||||
`it`,
|
||||
`ko`,
|
||||
`nl`,
|
||||
`pl`,
|
||||
@ -36,6 +37,7 @@ export const allLocales = [
|
||||
`en`,
|
||||
`es`,
|
||||
`fr`,
|
||||
`it`,
|
||||
`ko`,
|
||||
`nl`,
|
||||
`pl`,
|
||||
|
||||
Reference in New Issue
Block a user