From b158074d7873ab0eb128771b7a13238972e753f1 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Tue, 1 Aug 2023 16:18:58 -0700 Subject: [PATCH] Now, it's starting to look like a complete package. The LDAP method is working, but there is a bug: the radio is sending the wrong value !?!?!?. Track that down, dammit. The search wrappers now resend their events as standard `input` events, and that actually seems to work well; the browser is decorating it with the right target, with the right `name` attribute, and since we have good definitions of the `value` as a string (the real value of any search object is its UUID4), that works quite well. Added search wrappers for CoreGroup and CryptoCertificate (CertificateKeyPairs), and the latter has flags for "use the first one if it's the only one" and "allow the display of keyless certificates." Not sure why `state()` is blocking the transmission of typing information from the typed element to the context handler, but it's a bug in the typechecker, and it's not a problem so far. --- .../wizard/ApplicationWizardPageBase.ts | 5 +- ...-application-wizard-application-details.ts | 21 +- ...ion-wizard-authentication-method-choice.ts | 40 +++- ...pplication-wizard-authentication-method.ts | 29 +++ .../ak-application-wizard-context-name.ts | 6 +- .../wizard/ak-application-wizard-context.ts | 8 +- .../wizard/ak-application-wizard.ts | 4 +- ...plication-wizard-authentication-by-ldap.ts | 210 ++++++++++++++++++ ...es.ts => ak-application-wizard.stories.ts} | 66 +++--- .../applications/wizard/stories/samples.ts | 147 ++++++++++++ web/src/admin/common/ak-core-group-search.ts | 104 +++++++++ .../common/ak-crypto-certificate-search.ts | 129 +++++++++++ .../admin/common/ak-flow-search/FlowSearch.ts | 1 + web/src/components/ak-number-input.ts | 76 +++++++ .../components/ak-wizard/ak-wizard.stories.ts | 82 ------- web/src/components/ak-wizard/ak-wizard.ts | 99 --------- web/src/elements/forms/SearchSelect.ts | 3 +- 17 files changed, 791 insertions(+), 239 deletions(-) create mode 100644 web/src/admin/applications/wizard/ak-application-wizard-authentication-method.ts create mode 100644 web/src/admin/applications/wizard/ldap/ak-application-wizard-authentication-by-ldap.ts rename web/src/admin/applications/wizard/stories/{ak-application-wizard-application-details.stories.ts => ak-application-wizard.stories.ts} (57%) create mode 100644 web/src/admin/applications/wizard/stories/samples.ts create mode 100644 web/src/admin/common/ak-core-group-search.ts create mode 100644 web/src/admin/common/ak-crypto-certificate-search.ts create mode 100644 web/src/components/ak-number-input.ts delete mode 100644 web/src/components/ak-wizard/ak-wizard.stories.ts delete mode 100644 web/src/components/ak-wizard/ak-wizard.ts diff --git a/web/src/admin/applications/wizard/ApplicationWizardPageBase.ts b/web/src/admin/applications/wizard/ApplicationWizardPageBase.ts index c21c3a357..fbf4efa71 100644 --- a/web/src/admin/applications/wizard/ApplicationWizardPageBase.ts +++ b/web/src/admin/applications/wizard/ApplicationWizardPageBase.ts @@ -7,16 +7,17 @@ import { state } from "@lit/reactive-element/decorators/state.js"; import { styles as AwadStyles } from "./ak-application-wizard-application-details.css"; import type { WizardState } from "./ak-application-wizard-context"; -import applicationWizardContext from "./ak-application-wizard-context-name"; +import { applicationWizardContext } from "./ak-application-wizard-context-name"; export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) { static get styles() { return AwadStyles; } + // @ts-expect-error @consume({ context: applicationWizardContext, subscribe: true }) @state() - private wizard!: WizardState; + public wizard!: WizardState; dispatchWizardUpdate(update: Partial) { this.dispatchCustomEvent("ak-wizard-update", { diff --git a/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts b/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts index c7d0f4866..31cf2823c 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts @@ -17,11 +17,16 @@ import ApplicationWizardPageBase from "./ApplicationWizardPageBase"; @customElement("ak-application-wizard-application-details") export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBase { handleChange(ev: Event) { - const value = ev.target.type === "checkbox" ? ev.target.checked : ev.target.value; + if (!ev.target) { + console.warn(`Received event with no target: ${ev}`); + return; + } + const target = ev.target as HTMLInputElement; + const value = target.type === "checkbox" ? target.checked : target.value; this.dispatchWizardUpdate({ application: { ...this.wizard.application, - [ev.target.name]: value, + [target.name]: value, }, }); } @@ -30,24 +35,24 @@ export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBa return html`
diff --git a/web/src/admin/applications/wizard/ak-application-wizard-authentication-method-choice.ts b/web/src/admin/applications/wizard/ak-application-wizard-authentication-method-choice.ts index f544bb5d8..481fdb5cd 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard-authentication-method-choice.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard-authentication-method-choice.ts @@ -17,6 +17,18 @@ import type { TypeCreate } from "@goauthentik/api"; import ApplicationWizardPageBase from "./ApplicationWizardPageBase"; +// The provider description that comes from the server is fairly specific and not internationalized. +// We provide alternative descriptions that use the phrase 'authentication method' instead, and make +// it available to i18n. +// +// prettier-ignore +const alternativeDescription = new Map([ + ["oauth2provider", msg("Modern applications, APIs and Single-page applications.")], + ["samlprovider", msg("XML-based SSO standard. Use this if your application only supports SAML.")], + ["proxyprovider", msg("Legacy applications which don't natively support SSO.")], + ["ldapprovider", msg("Provide an LDAP interface for applications and users to authenticate against.")] +]); + @customElement("ak-application-wizard-authentication-method-choice") export class ApplicationWizardAuthenticationMethodChoice extends ApplicationWizardPageBase { @state() @@ -26,21 +38,25 @@ export class ApplicationWizardAuthenticationMethodChoice extends ApplicationWiza super(); this.handleChoice = this.handleChoice.bind(this); this.renderProvider = this.renderProvider.bind(this); + // If the provider doesn't supply a model to which to send our initialization, the user will + // have to use the older provider path. new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((types) => { - this.providerTypes = types; + this.providerTypes = types.filter(({ modelName }) => modelName.trim() !== ""); }); } - handleChoice(ev: Event) { - this.dispatchWizardUpdate({ providerType: ev.target.value }); + handleChoice(ev: InputEvent) { + const target = ev.target as HTMLInputElement; + + this.dispatchWizardUpdate({ providerType: target.value }); } - renderProvider(type: Provider) { - // Special case; the SAML-by-import method is handled differently - // prettier-ignore - const model = /^SAML/.test(type.name) && type.modelName === "" - ? "samlimporter" - : type.modelName; + renderProvider(type: TypeCreate) { + const description = alternativeDescription.has(type.modelName) + ? alternativeDescription.get(type.modelName) + : type.description; + + const label = type.name.replace(/\s+Provider/, ""); return html`
- - ${type.description} + + ${description}
`; } diff --git a/web/src/admin/applications/wizard/ak-application-wizard-authentication-method.ts b/web/src/admin/applications/wizard/ak-application-wizard-authentication-method.ts new file mode 100644 index 000000000..64ef82454 --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-authentication-method.ts @@ -0,0 +1,29 @@ +import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; +import { TemplateResult, html } from "lit"; + +import ApplicationWizardPageBase from "./ApplicationWizardPageBase"; + +// prettier-ignore +const handlers = new Map TemplateResult>([ + ["ldapprovider", () => html``], + ["oauth2provider", () => html`

Under construction

`], + ["proxyprovider", () => html`

Under construction

`], + ["radiusprovider", () => html`

Under construction

`], + ["samlprovider", () => html`

Under construction

`], + ["scimprovider", () => html`

Under construction

`], +]); + +@customElement("ak-application-wizard-authentication-method") +export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBase { + render() { + const handler = handlers.get(this.wizard.providerType); + if (!handler) { + throw new Error( + "Unrecognized authentication method in ak-application-wizard-authentication-method", + ); + } + return handler(); + } +} + +export default ApplicationWizardApplicationDetails; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts b/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts index b6be30adb..47e1d356d 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts @@ -1,5 +1,5 @@ -import {createContext} from '@lit-labs/context'; +import { createContext } from "@lit-labs/context"; -export const ApplicationWizardContext = createContext(Symbol('ak-application-wizard-context')); +export const applicationWizardContext = createContext(Symbol("ak-application-wizard-context")); -export default ApplicationWizardContext; +export default applicationWizardContext; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-context.ts b/web/src/admin/applications/wizard/ak-application-wizard-context.ts index 72593d6b0..0dba5171b 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard-context.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard-context.ts @@ -27,12 +27,14 @@ type OneOfProvider = | Partial | Partial; -export type WizardState = { +export interface WizardState { step: number; providerType: string; application: Partial; provider: OneOfProvider; -}; +} + +type WizardStateEvent = WizardState & { target?: HTMLInputElement }; @customElement("ak-application-wizard-context") export class AkApplicationWizardContext extends CustomListenerElement(LitElement) { @@ -63,7 +65,7 @@ export class AkApplicationWizardContext extends CustomListenerElement(LitElement super.disconnectedCallback(); } - handleUpdate(event: CustomEvent) { + handleUpdate(event: CustomEvent) { delete event.detail.target; this.wizardState = event.detail; } diff --git a/web/src/admin/applications/wizard/ak-application-wizard.ts b/web/src/admin/applications/wizard/ak-application-wizard.ts index 4aa5a9ae8..586e3e315 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard.ts @@ -22,6 +22,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; +/* const steps = [ { name: msg("Application Details"), @@ -43,7 +44,8 @@ const steps = [ view: () => html``, }, -]; + ]; + */ @customElement("ak-application-wizard") export class ApplicationWizard extends AKElement { diff --git a/web/src/admin/applications/wizard/ldap/ak-application-wizard-authentication-by-ldap.ts b/web/src/admin/applications/wizard/ldap/ak-application-wizard-authentication-by-ldap.ts new file mode 100644 index 000000000..5ec73a65d --- /dev/null +++ b/web/src/admin/applications/wizard/ldap/ak-application-wizard-authentication-by-ldap.ts @@ -0,0 +1,210 @@ +import "@goauthentik/admin/common/ak-core-group-search"; +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search"; +import { first } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-number-input"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/components/ak-switch-input"; +import "@goauthentik/components/ak-text-input"; +import { rootInterface } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; + +import { msg } from "@lit/localize"; +import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; +import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { FlowsInstancesListDesignationEnum, LDAPAPIAccessMode } from "@goauthentik/api"; +import type { LDAPProvider } from "@goauthentik/api"; + +import ApplicationWizardPageBase from "../ApplicationWizardPageBase"; + +const bindModeOptions = [ + { + label: msg("Cached binding"), + value: LDAPAPIAccessMode.Cached, + default: true, + description: html`${msg( + "Flow is executed and session is cached in memory. Flow is executed when session expires", + )}`, + }, + { + label: msg("Direct binding"), + value: LDAPAPIAccessMode.Direct, + description: html`${msg( + "Always execute the configured bind flow to authenticate the user", + )}`, + }, +]; + +const searchModeOptions = [ + { + label: msg("Cached querying"), + value: LDAPAPIAccessMode.Cached, + default: true, + description: html`${msg( + "The outpost holds all users and groups in-memory and will refresh every 5 Minutes", + )}`, + }, + { + label: msg("Direct querying"), + value: LDAPAPIAccessMode.Direct, + description: html`${msg( + "Always returns the latest data, but slower than cached querying", + )}`, + }, +]; + +const groupHelp = msg( + "Users in the selected group can do search queries. If no group is selected, no LDAP Searches are allowed.", +); + +const mfaSupportHelp = msg( + "When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.", +); + +@customElement("ak-application-wizard-authentication-by-ldap") +export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBase { + handleChange(ev: InputEvent) { + if (!ev.target) { + console.warn(`Received event with no target: ${ev}`); + return; + } + const target = ev.target as HTMLInputElement; + const value = target.type === "checkbox" ? target.checked : target.value; + this.dispatchWizardUpdate({ + provider: { + ...this.wizard.provider, + [target.name]: value, + }, + }); + } + + render() { + const provider = this.wizard.provider as LDAPProvider | undefined; + + // prettier-ignore + return html` + + + + +

${msg("Flow used for users to authenticate.")}

+
+ + + +

${groupHelp}

+
+ + + + + + + + + + + + ${msg("Protocol settings")} +
+ + + + + + +

+ ${msg( + "The certificate for the above configured Base DN. As a fallback, the provider uses a self-signed certificate." + )} +

+
+ + + + + + +
+
+ `; + } +} + +export default ApplicationWizardApplicationDetails; diff --git a/web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts b/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts similarity index 57% rename from web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts rename to web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts index 9e0142842..187e4aeb3 100644 --- a/web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts +++ b/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts @@ -6,32 +6,14 @@ import "../ak-application-wizard-application-details"; import AkApplicationWizardApplicationDetails from "../ak-application-wizard-application-details"; import "../ak-application-wizard-authentication-method-choice"; import "../ak-application-wizard-context"; +import "../ldap/ak-application-wizard-authentication-by-ldap"; import "./ak-application-context-display-for-test"; - -// prettier-ignore -const providerTypes = [ - ["LDAP Provider", "ldapprovider", - "Allow applications to authenticate against authentik's users using LDAP.", - ], - ["OAuth2/OpenID Provider", "oauth2provider", - "OAuth2 Provider for generic OAuth and OpenID Connect Applications.", - ], - ["Proxy Provider", "proxyprovider", - "Protect applications that don't support any of the other\n Protocols by using a Reverse-Proxy.", - ], - ["Radius Provider", "radiusprovider", - "Allow applications to authenticate against authentik's users using Radius.", - ], - ["SAML Provider", "samlprovider", - "SAML 2.0 Endpoint for applications which support SAML.", - ], - ["SCIM Provider", "scimprovider", - "SCIM 2.0 provider to create users and groups in external applications", - ], - ["SAML Provider from Metadata", "", - "Create a SAML Provider by importing its Metadata.", - ], -].map(([name, model_name, description]) => ({ name, description, model_name })); +import { + dummyAuthenticationFlowsSearch, + dummyCoreGroupsSearch, + dummyCryptoCertsSearch, + dummyProviderTypesList, +} from "./samples"; const metadata: Meta = { title: "Elements / Application Wizard / Page 1", @@ -47,7 +29,26 @@ const metadata: Meta = { url: "/api/v3/providers/all/types/", method: "GET", status: 200, - response: providerTypes, + response: dummyProviderTypesList, + }, + { + url: "/api/v3/core/groups/?ordering=name", + method: "GET", + status: 200, + response: dummyCoreGroupsSearch, + }, + + { + url: "/api/v3/crypto/certificatekeypairs/?has_key=true&include_details=false&ordering=name", + method: "GET", + status: 200, + response: dummyCryptoCertsSearch, + }, + { + url: "/api/v3/flows/instances/?designation=authentication&ordering=slug", + method: "GET", + status: 200, + response: dummyAuthenticationFlowsSearch, }, ], }, @@ -73,7 +74,7 @@ export const PageOne = () => { html` - ` + `, ); }; @@ -82,6 +83,15 @@ export const PageTwo = () => { html` - ` + `, + ); +}; + +export const PageThreeLdap = () => { + return container( + html` + + + `, ); }; diff --git a/web/src/admin/applications/wizard/stories/samples.ts b/web/src/admin/applications/wizard/stories/samples.ts new file mode 100644 index 000000000..feb549b00 --- /dev/null +++ b/web/src/admin/applications/wizard/stories/samples.ts @@ -0,0 +1,147 @@ +export const dummyCryptoCertsSearch = { + pagination: { + next: 0, + previous: 0, + count: 1, + current: 1, + total_pages: 1, + start_index: 1, + end_index: 1, + }, + results: [ + { + pk: "63efd1b8-6c39-4f65-8157-9a406cb37447", + name: "authentik Self-signed Certificate", + fingerprint_sha256: null, + fingerprint_sha1: null, + cert_expiry: null, + cert_subject: null, + private_key_available: true, + private_key_type: null, + certificate_download_url: + "/api/v3/crypto/certificatekeypairs/63efd1b8-6c39-4f65-8157-9a406cb37447/view_certificate/?download", + private_key_download_url: + "/api/v3/crypto/certificatekeypairs/63efd1b8-6c39-4f65-8157-9a406cb37447/view_private_key/?download", + managed: null, + }, + ], +}; + +export const dummyAuthenticationFlowsSearch = { + pagination: { + next: 0, + previous: 0, + count: 2, + current: 1, + total_pages: 1, + start_index: 1, + end_index: 2, + }, + results: [ + { + pk: "2594b1a0-f234-4965-8b93-a8631a55bd5c", + policybindingmodel_ptr_id: "0bc529a6-dcd0-4ba8-8fef-5702348832f9", + name: "Welcome to authentik!", + slug: "default-authentication-flow", + title: "Welcome to authentik!", + designation: "authentication", + background: "/static/dist/assets/images/flow_background.jpg", + stages: [ + "bad9fbce-fb86-4ba4-8124-e7a1d8c147f3", + "1da1f272-a76e-4112-be95-f02421fca1d4", + "945cd956-6670-4dfa-ab3a-2a72dd3051a7", + "0fc1fc5c-b928-4d99-a892-9ae48de089f5", + ], + policies: [], + cache_count: 0, + policy_engine_mode: "any", + compatibility_mode: false, + export_url: "/api/v3/flows/instances/default-authentication-flow/export/", + layout: "stacked", + denied_action: "message_continue", + authentication: "none", + }, + { + pk: "3526dbd1-b50e-4553-bada-fbe7b3c2f660", + policybindingmodel_ptr_id: "cde67954-b78a-4fe9-830e-c2aba07a724a", + name: "Welcome to authentik!", + slug: "default-source-authentication", + title: "Welcome to authentik!", + designation: "authentication", + background: "/static/dist/assets/images/flow_background.jpg", + stages: ["3713b252-cee3-4acb-a02f-083f26459fff"], + policies: ["f42a4c7f-6586-4b14-9325-a832127ba295"], + cache_count: 0, + policy_engine_mode: "any", + compatibility_mode: false, + export_url: "/api/v3/flows/instances/default-source-authentication/export/", + layout: "stacked", + denied_action: "message_continue", + authentication: "require_unauthenticated", + }, + ], +}; + +export const dummyCoreGroupsSearch = { + pagination: { + next: 0, + previous: 0, + count: 1, + current: 1, + total_pages: 1, + start_index: 1, + end_index: 1, + }, + results: [ + { + pk: "67543d37-0ee2-4a4c-b020-9e735a8b5178", + num_pk: 13734, + name: "authentik Admins", + is_superuser: true, + parent: null, + users: [1], + attributes: {}, + users_obj: [ + { + pk: 1, + username: "akadmin", + name: "authentik Default Admin", + is_active: true, + last_login: "2023-07-03T16:08:11.196942Z", + email: "ken@goauthentik.io", + attributes: { + settings: { + locale: "en", + }, + }, + uid: "6dedc98b3fdd0f9afdc705e9d577d61127d89f1d91ea2f90f0b9a353615fb8f2", + }, + ], + }, + ], +}; + +// prettier-ignore +export const dummyProviderTypesList = [ + ["LDAP Provider", "ldapprovider", + "Allow applications to authenticate against authentik's users using LDAP.", + ], + ["OAuth2/OpenID Provider", "oauth2provider", + "OAuth2 Provider for generic OAuth and OpenID Connect Applications.", + ], + ["Proxy Provider", "proxyprovider", + "Protect applications that don't support any of the other\n Protocols by using a Reverse-Proxy.", + ], + ["Radius Provider", "radiusprovider", + "Allow applications to authenticate against authentik's users using Radius.", + ], + ["SAML Provider", "samlprovider", + "SAML 2.0 Endpoint for applications which support SAML.", + ], + ["SCIM Provider", "scimprovider", + "SCIM 2.0 provider to create users and groups in external applications", + ], + ["SAML Provider from Metadata", "", + "Create a SAML Provider by importing its Metadata.", + ], +].map(([name, model_name, description]) => ({ name, description, model_name })); diff --git a/web/src/admin/common/ak-core-group-search.ts b/web/src/admin/common/ak-core-group-search.ts new file mode 100644 index 000000000..768b81c51 --- /dev/null +++ b/web/src/admin/common/ak-core-group-search.ts @@ -0,0 +1,104 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { AKElement } from "@goauthentik/elements/Base"; +import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect"; +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { property, query } from "lit/decorators.js"; + +import { CoreApi, CoreGroupsListRequest, Group } from "@goauthentik/api"; + +async function fetchObjects(query?: string): Promise { + const args: CoreGroupsListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(args); + return groups.results; +} + +const renderElement = (group: Group): string => group.name; + +const renderValue = (group: Group | undefined): string | undefined => group?.pk; + +/** + * Core Group Search + * + * @element ak-core-group-search + * + * A wrapper around SearchSelect for the 8 search of groups used throughout our code + * base. This is one of those "If it's not error-free, at least it's localized to + * one place" issues. + * + */ + +@customElement("ak-core-group-search") +export class CoreGroupSearch extends CustomListenerElement(AKElement) { + /** + * The current group known to the caller. + * + * @attr + */ + @property({ type: String, reflect: true }) + group?: string; + + @query("ak-search-select") + search!: SearchSelect; + + @property({ type: String }) + name: string | null | undefined; + + selectedGroup?: Group; + + constructor() { + super(); + this.selected = this.selected.bind(this); + this.handleSearchUpdate = this.handleSearchUpdate.bind(this); + this.addCustomListener("ak-change", this.handleSearchUpdate); + } + + get value() { + return this.selectedGroup ? renderValue(this.selectedGroup) : undefined; + } + + connectedCallback() { + super.connectedCallback(); + const horizontalContainer = this.closest("ak-form-element-horizontal[name]"); + if (!horizontalContainer) { + throw new Error("This search can only be used in a named ak-form-element-horizontal"); + } + const name = horizontalContainer.getAttribute("name"); + const myName = this.getAttribute("name"); + if (name !== null && name !== myName) { + this.setAttribute("name", name); + } + } + + handleSearchUpdate(ev: CustomEvent) { + ev.stopPropagation(); + this.selectedGroup = ev.detail.value; + this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + } + + selected(group: Group) { + return this.group === group.pk; + } + + render() { + return html` + + + `; + } +} + +export default CoreGroupSearch; diff --git a/web/src/admin/common/ak-crypto-certificate-search.ts b/web/src/admin/common/ak-crypto-certificate-search.ts new file mode 100644 index 000000000..3612b722b --- /dev/null +++ b/web/src/admin/common/ak-crypto-certificate-search.ts @@ -0,0 +1,129 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { AKElement } from "@goauthentik/elements/Base"; +import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect"; +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { property, query } from "lit/decorators.js"; + +import { + CertificateKeyPair, + CryptoApi, + CryptoCertificatekeypairsListRequest, +} from "@goauthentik/api"; + +const renderElement = (item: CertificateKeyPair): string => item.name; + +const renderValue = (item: CertificateKeyPair | undefined): string | undefined => item?.pk; + +/** + * Cryptographic Certificate Search + * + * @element ak-crypto-certificate-search + * + * A wrapper around SearchSelect for the many searches of cryptographic key-pairs used throughout our + * code base. This is another one of those "If it's not error-free, at least it's localized to one + * place" issues. + * + */ + +@customElement("ak-crypto-certificate-search") +export class CryptoCertificateSearch extends CustomListenerElement(AKElement) { + @property({ type: String, reflect: true }) + certificate?: string; + + @query("ak-search-select") + search!: SearchSelect; + + @property({ type: String }) + name: string | null | undefined; + + /** + * Set to `true` if you want to find pairs that don't have a valid key. Of our 14 searches, 11 + * require the key, 3 do not (as of 2023-08-01). + * + * @attr + */ + @property({ type: Boolean, attribute: "nokey" }) + noKey = false; + + /** + * Set this to true if, should there be only one certificate available, you want the system to + * use it by default. + * + * @attr + */ + @property({ type: Boolean, attribute: "singleton" }) + singleton = false; + + selectedKeypair?: CertificateKeyPair; + + constructor() { + super(); + this.selected = this.selected.bind(this); + this.fetchObjects = this.fetchObjects.bind(this); + this.handleSearchUpdate = this.handleSearchUpdate.bind(this); + this.addCustomListener("ak-change", this.handleSearchUpdate); + } + + get value() { + return this.selectedKeypair ? renderValue(this.selectedKeypair) : undefined; + } + + connectedCallback() { + super.connectedCallback(); + const horizontalContainer = this.closest("ak-form-element-horizontal[name]"); + if (!horizontalContainer) { + throw new Error("This search can only be used in a named ak-form-element-horizontal"); + } + const name = horizontalContainer.getAttribute("name"); + const myName = this.getAttribute("name"); + if (name !== null && name !== myName) { + this.setAttribute("name", name); + } + } + + handleSearchUpdate(ev: CustomEvent) { + ev.stopPropagation(); + this.selectedKeypair = ev.detail.value; + this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + } + + async fetchObjects(query?: string): Promise { + const args: CryptoCertificatekeypairsListRequest = { + ordering: "name", + hasKey: !this.noKey, + includeDetails: false, + }; + if (query !== undefined) { + args.search = query; + } + const certificates = await new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList( + args, + ); + return certificates.results; + } + + selected(item: CertificateKeyPair, items: CertificateKeyPair[]) { + return ( + (this.singleton && !this.certificate && items.length === 1) || + (!!this.certificate && this.certificate === item.pk) + ); + } + + render() { + return html` + + + `; + } +} + +export default CryptoCertificateSearch; diff --git a/web/src/admin/common/ak-flow-search/FlowSearch.ts b/web/src/admin/common/ak-flow-search/FlowSearch.ts index 484df6d6e..6b4c04427 100644 --- a/web/src/admin/common/ak-flow-search/FlowSearch.ts +++ b/web/src/admin/common/ak-flow-search/FlowSearch.ts @@ -79,6 +79,7 @@ export class FlowSearch extends CustomListenerElement(AKElement) handleSearchUpdate(ev: CustomEvent) { ev.stopPropagation(); this.selectedFlow = ev.detail.value; + this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); } async fetchObjects(query?: string): Promise { diff --git a/web/src/components/ak-number-input.ts b/web/src/components/ak-number-input.ts new file mode 100644 index 000000000..8b35e7c9d --- /dev/null +++ b/web/src/components/ak-number-input.ts @@ -0,0 +1,76 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +type AkNumberArgs = { + // The name of the field, snake-to-camel'd if necessary. + name: string; + // The label of the field. + label: string; + value?: number; + required: boolean; + // The help message, shown at the bottom. + help?: string; +}; + +const akNumberDefaults = { + required: false, +}; + +export function akNumber(args: AkNumberArgs) { + const { name, label, value, required, help } = { + ...akNumberDefaults, + ...args, + }; + + return html` + + ${help ? html`

${help}

` : nothing} +
`; +} + +@customElement("ak-number-input") +export class AkNumberInput extends AKElement { + // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but + // we're not actually using that and, for the meantime, we need the form handlers to be able to + // find the children of this component. + // + // TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the + // visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in + // general. + protected createRenderRoot() { + return this; + } + + @property({ type: String }) + name!: string; + + @property({ type: String }) + label = ""; + + @property({ type: Number }) + value = 0; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + render() { + return akNumber({ + name: this.name, + label: this.label, + value: this.value, + required: this.required, + help: this.help.trim() !== "" ? this.help : undefined, + }); + } +} diff --git a/web/src/components/ak-wizard/ak-wizard.stories.ts b/web/src/components/ak-wizard/ak-wizard.stories.ts deleted file mode 100644 index 7d81d2eeb..000000000 --- a/web/src/components/ak-wizard/ak-wizard.stories.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Meta } from "@storybook/web-components"; - -import { TemplateResult, html } from "lit"; - -import "./ak-wizard"; -import AkWizard from "./ak-wizard"; - -const metadata: Meta = { - title: "Components / Wizard", - component: "ak-wizard", - parameters: { - docs: { - description: { - component: "A Wizard for wrapping multiple steps", - }, - }, - }, -}; - -export default metadata; - -const container = (testItem: TemplateResult) => - html`
- - - ${testItem} -

Messages received from the button:

-
    -
    `; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const displayMessage = (result: any) => { - const doc = new DOMParser().parseFromString( - `
  • Event: ${ - "result" in result.detail ? result.detail.result : result.detail.error - }
  • `, - "text/xml", - ); - const target = document.querySelector("#action-button-message-pad"); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - target!.appendChild(doc.firstChild!); -}; - -window.addEventListener("ak-button-success", displayMessage); -window.addEventListener("ak-button-failure", displayMessage); - -export const ButtonWithSuccess = () => { - const run = () => - new Promise(function (resolve) { - setTimeout(function () { - resolve("Success!"); - }, 3000); - }); - - return container( - html`3 Seconds`, - ); -}; - -export const ButtonWithError = () => { - const run = () => - new Promise((resolve, reject) => { - setTimeout(() => { - reject(new Error("This is the error message.")); - }, 3000); - }); - - return container( - html` 3 Seconds`, - ); -}; diff --git a/web/src/components/ak-wizard/ak-wizard.ts b/web/src/components/ak-wizard/ak-wizard.ts deleted file mode 100644 index ca915c4fb..000000000 --- a/web/src/components/ak-wizard/ak-wizard.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { ModalButton } from "@goauthentik/elements/buttons/ModalButton"; - -import { msg } from "@lit/localize"; -import { property } from "@lit/reactive-element/decorators/property.js"; -import { state } from "@lit/reactive-element/decorators/state.js"; -import { html, nothing } from "lit"; -import type { TemplateResult } from "lit"; - -/** - * @class AkWizard - * - * @element ak-wizard - * - * The ak-wizard element exists to guide users through a complex task by dividing it into sections - * and granting them successive access to future sections. Our wizard has four "zones": The header, - * the breadcrumb toolbar, the navigation controls, and the content of the panel. - * - */ - -type WizardStep = { - name: string; - constructor: () => TemplateResult; -}; - -export class AkWizard extends ModalButton { - @property({ type: Boolean }) - required = false; - - @property() - wizardtitle?: string; - - @property() - description?: string; - - constructor() { - super(); - this.handleClose = this.handleClose.bind(this); - } - - handleClose() { - this.open = false; - } - - renderModalInner() { - return html`
    - ${this.renderWizardHeader()} -
    -
    ${this.renderWizardNavigation()}
    -
    -
    `; - } - - renderWizardHeader() { - const renderCancelButton = () => - html``; - - return html`
    - ${this.required ? nothing : renderCancelButton()} -

    ${this.wizardtitle}

    -

    ${this.description}

    -
    `; - } - - renderWizardNavigation() { - const currentIdx = this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0; - - const renderNavStep = (step: string, idx: number) => { - return html` -
  • - -
  • - `; - }; - - return html` `; - } -} diff --git a/web/src/elements/forms/SearchSelect.ts b/web/src/elements/forms/SearchSelect.ts index 02d1f1fc2..0aba008ee 100644 --- a/web/src/elements/forms/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect.ts @@ -1,5 +1,6 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils"; +import { adaptCSS } from "@goauthentik/common/utils"; import { AKElement } from "@goauthentik/elements/Base"; import { PreventFormSubmit } from "@goauthentik/elements/forms/Form"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; @@ -75,7 +76,7 @@ export class SearchSelect extends CustomEmitterElement(AKElement) { constructor() { super(); if (!document.adoptedStyleSheets.includes(PFDropdown)) { - document.adoptedStyleSheets = [...document.adoptedStyleSheets, PFDropdown]; + document.adoptedStyleSheets = adaptCSS([...document.adoptedStyleSheets, PFDropdown]); } this.dropdownContainer = document.createElement("div"); this.observer = new IntersectionObserver(() => {