diff --git a/web/src/admin/applications/ApplicationForm.ts b/web/src/admin/applications/ApplicationForm.ts index 9a8f3e8ce..2bbb03f30 100644 --- a/web/src/admin/applications/ApplicationForm.ts +++ b/web/src/admin/applications/ApplicationForm.ts @@ -1,7 +1,7 @@ import "@goauthentik/admin/applications/ProviderSelectModal"; import { iconHelperText } from "@goauthentik/admin/helperText"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; -import { first, groupBy } from "@goauthentik/common/utils"; +import { first } from "@goauthentik/common/utils"; import { rootInterface } from "@goauthentik/elements/Base"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -22,14 +22,39 @@ import { CoreApi, PolicyEngineMode, Provider, - ProvidersAllListRequest, - ProvidersApi, } from "@goauthentik/api"; -import { akText } from "./renderers"; +import "./renderers/ak-backchannel-input"; +import "./renderers/ak-file-input"; +import "./renderers/ak-provider-search-input"; +import "./renderers/ak-radio-input"; +import "./renderers/ak-switch-input"; +import "./renderers/ak-text-input"; +import "./renderers/ak-textarea-input"; + +const policyOptions = [ + { + label: "any", + value: PolicyEngineMode.Any, + default: true, + description: html`${msg("Any policy must match to grant access")}`, + }, + { + label: "all", + value: PolicyEngineMode.All, + description: html`${msg("All policies must match to grant access")}`, + }, +]; @customElement("ak-application-form") export class ApplicationForm extends ModelForm { + constructor() { + super(); + this.handleConfirmBackchannelProviders = this.handleConfirmBackchannelProviders.bind(this); + this.makeRemoveBackchannelProviderHandler = + this.makeRemoveBackchannelProviderHandler.bind(this); + } + async loadInstance(pk: string): Promise { const app = await new CoreApi(DEFAULT_CONFIG).coreApplicationsRetrieve({ slug: pk, @@ -90,200 +115,131 @@ export class ApplicationForm extends ModelForm { return app; } + handleConfirmBackchannelProviders({ items }: { items: Provider[] }) { + this.backchannelProviders = items; + this.requestUpdate(); + return Promise.resolve(); + } + + makeRemoveBackchannelProviderHandler(provider: Provider) { + return () => { + const idx = this.backchannelProviders.indexOf(provider); + this.backchannelProviders.splice(idx, 1); + this.requestUpdate(); + }; + } + + handleClearIcon(ev: Event) { + ev.stopPropagation(); + if (!(ev instanceof InputEvent) || !ev.target) { + return; + } + this.clearIcon = !!(ev.target as HTMLInputElement).checked; + } + renderForm(): TemplateResult { return html`
- ${akText({ - name: "name", - value: this.instance?.name, - label: msg("Name"), - required: true, - help: msg("Application's display Name."), - })} - ${akText({ - name: "slug", - value: this.instance?.slug, - label: msg("Slug"), - required: true, - help: msg("Internal application name, used in URLs."), - })} - ${akText({ - name: "group", - value: this.instance?.group, - label: msg("Group"), - help: msg( - "Optionally enter a group name. Applications with identical groups are shown grouped together." - ), - })} - + + + + -
- { - this.backchannelProviders = items; - this.requestUpdate(); - return Promise.resolve(); - }} - > - - -
- - ${this.backchannelProviders.map((provider) => { - return html` { - const idx = this.backchannelProviders.indexOf(provider); - this.backchannelProviders.splice(idx, 1); - this.requestUpdate(); - }} - > - ${provider.name} - `; - })} - -
-
-

- ${msg( - "Select backchannel providers which augment the functionality of the main provider." - )} -

-
- - + - - - + .options=${policyOptions} + .value=${this.instance?.policyEngineMode} + > ${msg("UI settings")}
- - -

- ${msg( - "If left empty, authentik will try to extract the launch URL based on the selected provider." - )} -

-
- - -

- ${msg( - "If checked, the launch URL will open in a new browser tab or window from the user's application library." - )} -

-
- ${ - rootInterface()?.config?.capabilities.includes( - CapabilitiesEnum.CanSaveMedia - ) - ? html` - - ${this.instance?.metaIcon - ? html` -

- ${msg("Currently set to:")} - ${this.instance?.metaIcon} -

- ` - : html``} -
- ${this.instance?.metaIcon - ? html` - - -

- ${msg("Delete currently set icon.")} -

-
- ` - : html``}` - : html` - -

${iconHelperText}

-
` - } - - - - - - + + + + ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ? html` + ${this.instance?.metaIcon + ? html` + + ` + : html``}` + : html` + `} + +
`; diff --git a/web/src/admin/applications/renderers.ts b/web/src/admin/applications/renderers.ts deleted file mode 100644 index 93452f6db..000000000 --- a/web/src/admin/applications/renderers.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { msg } from "@lit/localize"; -import { html } from "lit"; -import { ifDefined } from "lit/directives/if-defined.js"; - -type AkTextInput = { - // The name of the field, snake-to-camel'd if necessary. - name: string; - // The label of the field. - label: string; - value?: any; - required: boolean; - // The help message, shown at the bottom. - help?: string; -}; - -const akTextDefaults = { - required: false, -}; - -export function akText(args: AkTextInput) { - const { name, label, value, required, help } = { - ...akTextDefaults, - ...args - } - - return html` - -

${help}

-
`; -} diff --git a/web/src/admin/applications/renderers/ak-backchannel-input.ts b/web/src/admin/applications/renderers/ak-backchannel-input.ts new file mode 100644 index 000000000..d66773b52 --- /dev/null +++ b/web/src/admin/applications/renderers/ak-backchannel-input.ts @@ -0,0 +1,108 @@ +import "@goauthentik/admin/applications/ProviderSelectModal"; +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"; +import { map } from "lit/directives/map.js"; + +import { Provider } from "@goauthentik/api"; + +type AkBackchannelProvidersArgs = { + // The name of the field, snake-to-camel'd if necessary. + name: string; + // The label of the field. + label: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value?: any; + // The help message, shown at the bottom. + help?: string; + + providers: Provider[]; + confirm: ({ items }: { items: Provider[] }) => Promise; + remove: (provider: Provider) => () => void; +}; + +const akBackchannelProvidersDefaults = { + required: false, +}; + +export function akBackchannelProvidersInput(args: AkBackchannelProvidersArgs) { + const { name, label, help, providers, confirm, remove } = { + ...akBackchannelProvidersDefaults, + ...args, + }; + + const renderOneChip = (provider: Provider) => + html`${provider.name}`; + + return html` + +
+ + + +
+ ${map(providers, renderOneChip)} +
+
+ ${help ? html`

${help}

` : nothing} +
+ `; +} + +@customElement("ak-backchannel-providers-input") +export class AkBackchannelProvidersInput 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. + // + // This field is so highly specialized that it would make more sense if we put the API and the + // fetcher here. + // + // 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: Array }) + providers: Provider[] = []; + + @property({ attribute: false, type: Object }) + confirm!: ({ items }: { items: Provider[] }) => Promise; + + @property({ attribute: false, type: Object }) + remover!: (provider: Provider) => () => void; + + @property({ type: String }) + value = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + render() { + return akBackchannelProvidersInput({ + name: this.name, + label: this.label, + help: this.help.trim() !== "" ? this.help : undefined, + providers: this.providers, + confirm: this.confirm, + remove: this.remover, + }); + } +} diff --git a/web/src/admin/applications/renderers/ak-file-input.ts b/web/src/admin/applications/renderers/ak-file-input.ts new file mode 100644 index 000000000..957818bd3 --- /dev/null +++ b/web/src/admin/applications/renderers/ak-file-input.ts @@ -0,0 +1,85 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { msg } from "@lit/localize"; +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +type AkFileArgs = { + // The name of the field, snake-to-camel'd if necessary. + name: string; + // The label of the field. + label: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value?: any; + required: boolean; + // The message to show next to the "current icon". + current: string; + // The help message, shown at the bottom. + help?: string; +}; + +const akFileDefaults = { + name: "", + required: false, + current: msg("Currently set to:"), +}; + +export function akFile(args: AkFileArgs) { + const { name, label, required, value, help, current } = { + ...akFileDefaults, + ...args, + }; + + const currentMsg = + value && current + ? html`

${current} ${value}

` + : nothing; + + return html` + + ${currentMsg} ${help ? html`

${help}

` : nothing} +
`; +} + +@customElement("ak-file-input") +export class AkFileInput 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: String }) + current = msg("Currently set to:"); + + @property({ type: String }) + value = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + render() { + return akFile({ + name: this.name, + label: this.label, + value: this.value, + current: this.current, + required: this.required, + help: this.help.trim() !== "" ? this.help : undefined, + }); + } +} diff --git a/web/src/admin/applications/renderers/ak-provider-search-input.ts b/web/src/admin/applications/renderers/ak-provider-search-input.ts new file mode 100644 index 000000000..552cb0764 --- /dev/null +++ b/web/src/admin/applications/renderers/ak-provider-search-input.ts @@ -0,0 +1,80 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { groupBy } from "@goauthentik/common/utils"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/api"; + +const renderElement = (item: Provider) => item.name; +const renderValue = (item: Provider | undefined) => item?.pk; +const doGroupBy = (items: Provider[]) => groupBy(items, (item) => item.verboseName); + +async function fetch(query?: string) { + const args: ProvidersAllListRequest = { + ordering: "name", + }; + if (query !== undefined) { + args.search = query; + } + const items = await new ProvidersApi(DEFAULT_CONFIG).providersAllList(args); + return items.results; +} + +@customElement("ak-provider-search-input") +export class AkProviderInput 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?: number; + + @property({ type: Boolean }) + required = false; + + @property({ type: Boolean }) + blankable = false; + + @property({ type: String }) + help = ""; + + constructor() { + super(); + this.selected = this.selected.bind(this); + } + + selected(item: Provider) { + return this.value !== undefined && this.value === item.pk; + } + + render() { + return html` + + + ${this.help ? html`

${this.help}

` : nothing} +
`; + } +} diff --git a/web/src/admin/applications/renderers/ak-radio-input.ts b/web/src/admin/applications/renderers/ak-radio-input.ts new file mode 100644 index 000000000..57cfa22c6 --- /dev/null +++ b/web/src/admin/applications/renderers/ak-radio-input.ts @@ -0,0 +1,77 @@ +import { AKElement } from "@goauthentik/elements/Base"; +import { RadioOption } from "@goauthentik/elements/forms/Radio"; + +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +type AkRadioArgs = { + // The name of the field, snake-to-camel'd if necessary. + name: string; + // The label of the field. + label: string; + value?: T; + required?: boolean; + options: RadioOption[]; + // The help message, shown at the bottom. + help?: string; +}; + +const akRadioDefaults = { + required: false, + options: [], +}; + +export function akRadioInput(args: AkRadioArgs) { + const { name, label, help, required, options, value } = { + ...akRadioDefaults, + ...args, + }; + + return html` + + ${help ? html`

${help}

` : nothing} +
`; +} + +@customElement("ak-radio-input") +export class AkRadioInput 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: String }) + help = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: Object }) + value!: T; + + @property({ type: Array }) + options: RadioOption[] = []; + + render() { + return akRadioInput({ + name: this.name, + label: this.label, + value: this.value, + options: this.options, + required: this.required, + help: this.help.trim() !== "" ? this.help : undefined, + }); + } +} diff --git a/web/src/admin/applications/renderers/ak-switch-input.ts b/web/src/admin/applications/renderers/ak-switch-input.ts new file mode 100644 index 000000000..6fe2fe850 --- /dev/null +++ b/web/src/admin/applications/renderers/ak-switch-input.ts @@ -0,0 +1,84 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { html, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; + +type AkSwitchArgs = { + // The name of the field, snake-to-camel'd if necessary. + name: string; + // The label of the field. + label: string; + checked: boolean; + required: boolean; + // The help message, shown at the bottom. + help?: string; +}; + +const akSwitchDefaults = { + checked: false, + required: false, +}; + +export function akSwitch(args: AkSwitchArgs) { + const { name, label, checked, required, help } = { + ...akSwitchDefaults, + ...args, + }; + + const doCheck = checked ? checked : undefined; + + return html` + + ${help ? html`

${help}

` : nothing} +
`; +} + +@customElement("ak-switch-input") +export class AkSwitchInput 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: Boolean }) + checked: boolean = false; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + @query("input.pf-c-switch__input[type=checkbox]") + checkbox!: HTMLInputElement; + + render() { + return akSwitch({ + name: this.name, + label: this.label, + checked: this.checked, + required: this.required, + help: this.help.trim() !== "" ? this.help : undefined, + }); + } +} diff --git a/web/src/admin/applications/renderers/ak-text-input.ts b/web/src/admin/applications/renderers/ak-text-input.ts new file mode 100644 index 000000000..50d85fe39 --- /dev/null +++ b/web/src/admin/applications/renderers/ak-text-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 AkTextArgs = { + // The name of the field, snake-to-camel'd if necessary. + name: string; + // The label of the field. + label: string; + value?: string; + required: boolean; + // The help message, shown at the bottom. + help?: string; +}; + +const akTextDefaults = { + required: false, +}; + +export function akText(args: AkTextArgs) { + const { name, label, value, required, help } = { + ...akTextDefaults, + ...args, + }; + + return html` + + ${help ? html`

${help}

` : nothing} +
`; +} + +@customElement("ak-text-input") +export class AkTextInput 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: String }) + value = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + render() { + return akText({ + name: this.name, + label: this.label, + value: this.value, + required: this.required, + help: this.help.trim() !== "" ? this.help : undefined, + }); + } +} diff --git a/web/src/admin/applications/renderers/ak-textarea-input.ts b/web/src/admin/applications/renderers/ak-textarea-input.ts new file mode 100644 index 000000000..3c8e51942 --- /dev/null +++ b/web/src/admin/applications/renderers/ak-textarea-input.ts @@ -0,0 +1,75 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { html, nothing } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +type AkTextareaArgs = { + // The name of the field, snake-to-camel'd if necessary. + name: string; + // The label of the field. + label: string; + value?: string; + required: boolean; + // The help message, shown at the bottom. + help?: string; +}; + +const akTextareaDefaults = { + required: false, +}; + +export function akTextarea(args: AkTextareaArgs) { + const { name, label, value, required, help } = { + ...akTextareaDefaults, + ...args, + }; + + // ` + ${help ? html`

${help}

` : nothing} + `; +} + +@customElement("ak-textarea-input") +export class AkTextareaInput 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: String }) + value = ""; + + @property({ type: Boolean }) + required = false; + + @property({ type: String }) + help = ""; + + render() { + return akTextarea({ + name: this.name, + label: this.label, + value: this.value, + required: this.required, + help: this.help.trim() !== "" ? this.help : undefined, + }); + } +} diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index d148baaef..e2fbacc74 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -68,11 +68,9 @@ export interface KeyUnknown { * Consider refactoring serializeForm() so that the conversions are on * the input types, rather than here. (i.e. "Polymorphism is better than * switch.") - * - * + * + * */ - - @customElement("ak-form") export abstract class Form extends AKElement { diff --git a/web/src/elements/forms/HorizontalFormElement.ts b/web/src/elements/forms/HorizontalFormElement.ts index 4ee1b2bf7..9d00327da 100644 --- a/web/src/elements/forms/HorizontalFormElement.ts +++ b/web/src/elements/forms/HorizontalFormElement.ts @@ -16,7 +16,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; * Horizontal Form Element Container. * * This element provides the interface between elements of our forms and the - * form itself. + * form itself. * @custom-element ak-form-element-horizontal */ @@ -79,7 +79,7 @@ export class HorizontalFormElement extends AKElement { /* If this property changes, we want to make sure the parent control is "opened" so * that users can see the change.[1] - */ + */ @property({ type: Boolean }) set invalid(v: boolean) { this._invalid = v;