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:
Ken Sternberg
2024-11-22 10:01:43 -08:00
205 changed files with 18438 additions and 8284 deletions

View 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;
}
}

View File

@ -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")}

View File

@ -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>`,
};

View File

@ -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);
});
});

View File

@ -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 {

View File

@ -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,

View File

@ -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``}`,
];

View File

@ -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,

View File

@ -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.",
)}

View 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;
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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
/>

View 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;
}
}

View File

@ -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>

View File

@ -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> `;

View File

@ -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 = ";";

View File

@ -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)}`;
})(),

View File

@ -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;
}
}

View File

@ -89,6 +89,9 @@ export class TreeViewNode extends AKElement {
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
detail: {
path: this.fullPath,
},
}),
);
}}

View 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>&nbsp; ${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>;
}
}

View File

@ -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
];

View File

@ -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`

View File

@ -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>`;

View File

@ -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 {

View File

@ -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[]][];
}

View File

@ -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

View File

@ -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] ?? ""),
};

View 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>`,
};

View 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);
});
});

View File

@ -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>

View File

@ -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);

View File

@ -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>`;
}

View File

@ -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,
},
});

View File

@ -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}

View File

@ -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`,