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(() => {