diff --git a/web/src/admin/applications/ApplicationListPage.ts b/web/src/admin/applications/ApplicationListPage.ts index adb2424d4..db1e400ea 100644 --- a/web/src/admin/applications/ApplicationListPage.ts +++ b/web/src/admin/applications/ApplicationListPage.ts @@ -1,5 +1,4 @@ import "@goauthentik/admin/applications/ApplicationForm"; -import "@goauthentik/admin/applications/wizard/ApplicationWizard"; import { PFSize } from "@goauthentik/app/elements/Spinner"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { uiConfig } from "@goauthentik/common/ui/config"; @@ -89,17 +88,20 @@ export class ApplicationListPage extends TablePage { renderSidebarAfter(): TemplateResult { // Rendering the wizard with .open here, as if we set the attribute in // renderObjectCreate() it'll open two wizards, since that function gets called twice - return html` -
-
-
- -
+ >*/ + + return html`
+
+
+
-
`; +
+
`; } renderToolbarSelected(): TemplateResult { diff --git a/web/src/admin/applications/wizard/oauth/ak-application-wizard-authentication-by-oauth.ts b/web/src/admin/applications/wizard/oauth/ak-application-wizard-authentication-by-oauth.ts new file mode 100644 index 000000000..a4cb35ac9 --- /dev/null +++ b/web/src/admin/applications/wizard/oauth/ak-application-wizard-authentication-by-oauth.ts @@ -0,0 +1,312 @@ +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search"; +import { + clientTypeOptions, + issuerModeOptions, + redirectUriHelp, + subjectModeOptions, +} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { ascii_letters, digits, first, randomString } 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 "@goauthentik/components/ak-textarea-input"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; + +import { msg } from "@lit/localize"; +import { customElement, state } from "@lit/reactive-element/decorators.js"; +import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + ClientTypeEnum, + FlowsInstancesListDesignationEnum, + PropertymappingsApi, + SourcesApi, +} from "@goauthentik/api"; +import type { + OAuth2Provider, + PaginatedOAuthSourceList, + PaginatedScopeMappingList, +} from "@goauthentik/api"; + +import ApplicationWizardPageBase from "../ApplicationWizardPageBase"; + +@customElement("ak-application-wizard-authentication-by-oauth") +export class ApplicationWizardAuthenticationByOauth extends ApplicationWizardPageBase { + @state() + showClientSecret = false; + + @state() + propertyMappings?: PaginatedScopeMappingList; + + @state() + oauthSources?: PaginatedOAuthSourceList; + + constructor() { + super(); + new PropertymappingsApi(DEFAULT_CONFIG) + .propertymappingsScopeList({ + ordering: "scope_name", + }) + .then((propertyMappings: PaginatedScopeMappingList) => { + this.propertyMappings = propertyMappings; + }); + + new SourcesApi(DEFAULT_CONFIG) + .sourcesOauthList({ + ordering: "name", + hasJwks: true, + }) + .then((oauthSources: PaginatedOAuthSourceList) => { + this.oauthSources = oauthSources; + }); + } + + 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 OAuth2Provider | undefined; + + // prettier-ignore + return html`
+ + + + +

+ ${msg("Flow used when a user access this provider and is not authenticated.")} +

+
+ + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + + ${msg("Protocol settings")} +
+ ) => { + this.showClientSecret = ev.detail !== ClientTypeEnum.Public; + }} + .options=${clientTypeOptions} + > + + + + + + + + + + + +

${msg("Key used to sign the tokens.")}

+
+
+
+ + + ${msg("Advanced protocol settings")} +
+ + ${msg("Configure how long access codes are valid for.")} +

+ `} + > +
+ + ${msg("Configure how long access tokens are valid for.")} +

+ `} + > +
+ + + ${msg("Configure how long refresh tokens are valid for.")} +

+ `} + > +
+ + +

+ ${msg( + "Select which scopes can be used by the client. The client still has to specify the scope to access the data." + )} +

+

+ ${msg("Hold control/command to select multiple items.")} +

+
+ + + + label=${msg("Include claims in id_token")} + ?checked=${first(provider?.includeClaimsInIdToken, true)} + help=${msg( + "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint." + )}> + + +
+
+ + + ${msg("Machine-to-Machine authentication settings")} +
+ + +

+ ${msg( + "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider." + )} +

+

+ ${msg("Hold control/command to select multiple items.")} +

+
+
+
+
`; + } +} + +export default ApplicationWizardAuthenticationByOauth; diff --git a/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts b/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts index e121bdc21..aab881b46 100644 --- a/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts +++ b/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts @@ -7,11 +7,15 @@ import AkApplicationWizardApplicationDetails from "../ak-application-wizard-appl import "../ak-application-wizard-authentication-method-choice"; import "../ak-application-wizard-context"; import "../ldap/ak-application-wizard-authentication-by-ldap"; +import "../oauth/ak-application-wizard-authentication-by-oauth"; import "./ak-application-context-display-for-test"; import { dummyAuthenticationFlowsSearch, + dummyAuthorizationFlowsSearch, dummyCoreGroupsSearch, dummyCryptoCertsSearch, + dummyHasJwks, + dummyPropertyMappings, dummyProviderTypesList, } from "./samples"; @@ -50,6 +54,26 @@ const metadata: Meta = { status: 200, response: dummyAuthenticationFlowsSearch, }, + { + url: "/api/v3/flows/instances/?designation=authorization&ordering=slug", + method: "GET", + status: 200, + response: dummyAuthorizationFlowsSearch, + }, + { + url: "/api/v3/propertymappings/scope/?ordering=scope_name", + method: "GET", + status: 200, + response: dummyPropertyMappings, + }, + { + url: "/api/v3/sources/oauth/?has_jwks=true&ordering=name", + method: "GET", + status: 200, + response: dummyHasJwks, + }, + + ], }, }; @@ -60,9 +84,8 @@ function injectTheme() { if (!document.body.classList.contains(LIGHT)) { document.body.classList.add(LIGHT); } - }) + }); } - export default metadata; @@ -78,14 +101,14 @@ const container = (testItem: TemplateResult) => { } ${testItem} -
`; -} + `; +}; export const PageOne = () => { return container( html` - -
+ +
`, ); @@ -95,7 +118,7 @@ export const PageTwo = () => { return container( html` -
+
`, ); @@ -105,7 +128,18 @@ export const PageThreeLdap = () => { return container( html` -
+
+ +
`, + ); +}; + + +export const PageThreeOauth2 = () => { + return container( + html` + +
`, ); diff --git a/web/src/admin/applications/wizard/stories/samples.ts b/web/src/admin/applications/wizard/stories/samples.ts index feb549b00..d79b515c5 100644 --- a/web/src/admin/applications/wizard/stories/samples.ts +++ b/web/src/admin/applications/wizard/stories/samples.ts @@ -82,6 +82,58 @@ export const dummyAuthenticationFlowsSearch = { ], }; +export const dummyAuthorizationFlowsSearch = { + pagination: { + next: 0, + previous: 0, + count: 2, + current: 1, + total_pages: 1, + start_index: 1, + end_index: 2, + }, + results: [ + { + pk: "9e01f011-8b3f-43d6-bedf-c29be5f3a428", + policybindingmodel_ptr_id: "14179ef8-2726-4027-9e2f-dc99185199bf", + name: "Authorize Application", + slug: "default-provider-authorization-explicit-consent", + title: "Redirecting to %(app)s", + designation: "authorization", + background: "/static/dist/assets/images/flow_background.jpg", + stages: ["ed5f015f-82b9-450f-addf-1e9d21d8dda3"], + policies: [], + cache_count: 0, + policy_engine_mode: "any", + compatibility_mode: false, + export_url: + "/api/v3/flows/instances/default-provider-authorization-explicit-consent/export/", + layout: "stacked", + denied_action: "message_continue", + authentication: "require_authenticated", + }, + { + pk: "06f11ee3-cbe3-456d-81df-fae4c0a62951", + policybindingmodel_ptr_id: "686e6539-8b9f-473e-9f54-e05cc207dd2a", + name: "Authorize Application", + slug: "default-provider-authorization-implicit-consent", + title: "Redirecting to %(app)s", + designation: "authorization", + background: "/static/dist/assets/images/flow_background.jpg", + stages: [], + policies: [], + cache_count: 0, + policy_engine_mode: "any", + compatibility_mode: false, + export_url: + "/api/v3/flows/instances/default-provider-authorization-implicit-consent/export/", + layout: "stacked", + denied_action: "message_continue", + authentication: "require_authenticated", + }, + ], +}; + export const dummyCoreGroupsSearch = { pagination: { next: 0, @@ -121,6 +173,84 @@ export const dummyCoreGroupsSearch = { ], }; +export const dummyPropertyMappings = { + pagination: { + next: 0, + previous: 0, + count: 4, + current: 1, + total_pages: 1, + start_index: 1, + end_index: 4, + }, + results: [ + { + pk: "30d87af7-9d9d-4292-873e-a52145ba4bcb", + managed: "goauthentik.io/providers/proxy/scope-proxy", + name: "authentik default OAuth Mapping: Proxy outpost", + expression: + '# This mapping is used by the authentik proxy. It passes extra user attributes,\n# which are used for example for the HTTP-Basic Authentication mapping.\nreturn {\n "ak_proxy": {\n "user_attributes": request.user.group_attributes(request),\n "is_superuser": request.user.is_superuser,\n }\n}', + component: "ak-property-mapping-scope-form", + verbose_name: "Scope Mapping", + verbose_name_plural: "Scope Mappings", + meta_model_name: "authentik_providers_oauth2.scopemapping", + scope_name: "ak_proxy", + description: "authentik Proxy - User information", + }, + { + pk: "3e3751ed-a24c-4f47-a051-e2e05b5cd306", + managed: "goauthentik.io/providers/oauth2/scope-email", + name: "authentik default OAuth Mapping: OpenID 'email'", + expression: 'return {\n "email": request.user.email,\n "email_verified": True\n}', + component: "ak-property-mapping-scope-form", + verbose_name: "Scope Mapping", + verbose_name_plural: "Scope Mappings", + meta_model_name: "authentik_providers_oauth2.scopemapping", + scope_name: "email", + description: "Email address", + }, + { + pk: "81c5e330-d8a0-45cd-9cad-e6a49a9c428f", + managed: "goauthentik.io/providers/oauth2/scope-openid", + name: "authentik default OAuth Mapping: OpenID 'openid'", + expression: + "# This scope is required by the OpenID-spec, and must as such exist in authentik.\n# The scope by itself does not grant any information\nreturn {}", + component: "ak-property-mapping-scope-form", + verbose_name: "Scope Mapping", + verbose_name_plural: "Scope Mappings", + meta_model_name: "authentik_providers_oauth2.scopemapping", + scope_name: "openid", + description: "", + }, + { + pk: "7ad9cd6f-bcc8-425d-b7c2-c7c4592a1b36", + managed: "goauthentik.io/providers/oauth2/scope-profile", + name: "authentik default OAuth Mapping: OpenID 'profile'", + expression: + 'return {\n # Because authentik only saves the user\'s full name, and has no concept of first and last names,\n # the full name is used as given name.\n # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")`\n "name": request.user.name,\n "given_name": request.user.name,\n "preferred_username": request.user.username,\n "nickname": request.user.username,\n # groups is not part of the official userinfo schema, but is a quasi-standard\n "groups": [group.name for group in request.user.ak_groups.all()],\n}', + component: "ak-property-mapping-scope-form", + verbose_name: "Scope Mapping", + verbose_name_plural: "Scope Mappings", + meta_model_name: "authentik_providers_oauth2.scopemapping", + scope_name: "profile", + description: "General Profile Information", + }, + ], +}; + +export const dummyHasJwks = { + pagination: { + next: 0, + previous: 0, + count: 0, + current: 1, + total_pages: 1, + start_index: 0, + end_index: 0, + }, + results: [], +}; + // prettier-ignore export const dummyProviderTypesList = [ ["LDAP Provider", "ldapprovider", diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index 8e9bdbb02..d77884d5c 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -1,6 +1,10 @@ +import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/components/ak-text-input"; +import "@goauthentik/components/ak-textarea-input"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -9,15 +13,12 @@ import "@goauthentik/elements/forms/SearchSelect"; import "@goauthentik/elements/utils/TimeDeltaHelp"; import { msg } from "@lit/localize"; -import { TemplateResult, html } from "lit"; +import { TemplateResult, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { - CertificateKeyPair, ClientTypeEnum, - CryptoApi, - CryptoCertificatekeypairsListRequest, FlowsInstancesListDesignationEnum, IssuerModeEnum, OAuth2Provider, @@ -29,6 +30,91 @@ import { SubModeEnum, } from "@goauthentik/api"; +export const clientTypeOptions = [ + { + label: msg("Confidential"), + value: ClientTypeEnum.Confidential, + default: true, + description: html`${msg( + "Confidential clients are capable of maintaining the confidentiality of their credentials such as client secrets", + )}`, + }, + { + label: msg("Public"), + value: ClientTypeEnum.Public, + description: html`${msg( + "Public clients are incapable of maintaining the confidentiality and should use methods like PKCE. ", + )}`, + }, +]; + +export const subjectModeOptions = [ + { + label: msg("Based on the User's hashed ID"), + value: SubModeEnum.HashedUserId, + default: true, + }, + { + label: msg("Based on the User's ID"), + value: SubModeEnum.UserId, + }, + { + label: msg("Based on the User's UUID"), + value: SubModeEnum.UserUuid, + }, + { + label: msg("Based on the User's username"), + value: SubModeEnum.UserUsername, + }, + { + label: msg("Based on the User's Email"), + value: SubModeEnum.UserEmail, + description: html`${msg("This is recommended over the UPN mode.")}`, + }, + { + label: msg("Based on the User's UPN"), + value: SubModeEnum.UserUpn, + description: html`${msg( + "Requires the user to have a 'upn' attribute set, and falls back to hashed user ID. Use this mode only if you have different UPN and Mail domains.", + )}`, + }, +]; + +export const issuerModeOptions = [ + { + label: msg("Each provider has a different issuer, based on the application slug"), + value: IssuerModeEnum.PerProvider, + default: true, + }, + { + label: msg("Same identifier is used for all providers"), + value: IssuerModeEnum.Global, + }, +]; + +const redirectUriHelpMessages = [ + msg( + "Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.", + ), + msg( + "If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.", + ), + msg( + 'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.', + ), +]; + +export const redirectUriHelp = html`${redirectUriHelpMessages.map( + (m) => html`

${m}

`, +)}`; + +/** + * Form page for OAuth2 Authentication Method + * + * @element ak-provider-oauth2-form + * + */ + @customElement("ak-provider-oauth2-form") export class OAuth2ProviderFormPage extends ModelForm { propertyMappings?: PaginatedScopeMappingList; @@ -79,22 +165,23 @@ export class OAuth2ProviderFormPage extends ModelForm { } renderForm(): TemplateResult { + const provider = this.instance; + return html`
- - - + +

@@ -102,13 +189,13 @@ export class OAuth2ProviderFormPage extends ModelForm {

@@ -119,129 +206,52 @@ export class OAuth2ProviderFormPage extends ModelForm { ${msg("Protocol settings")}

- ) => { + this.showClientSecret = ev.detail !== ClientTypeEnum.Public; + }} + .options=${clientTypeOptions} > - ) => { - if (ev.detail === ClientTypeEnum.Public) { - this.showClientSecret = false; - } else { - this.showClientSecret = true; - } - }} - .options=${[ - { - label: msg("Confidential"), - value: ClientTypeEnum.Confidential, - default: true, - description: html`${msg( - "Confidential clients are capable of maintaining the confidentiality of their credentials such as client secrets", - )}`, - }, - { - label: msg("Public"), - value: ClientTypeEnum.Public, - description: html`${msg( - "Public clients are incapable of maintaining the confidentiality and should use methods like PKCE. ", - )}`, - }, - ]} - .value=${this.instance?.clientType} - > - - - + - - - + - - - + - -

- ${msg( - "Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.", - )} -

-

- ${msg( - "If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.", - )} -

-

- ${msg( - 'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.', - )} -

-
+ + - => { - const args: CryptoCertificatekeypairsListRequest = { - ordering: "name", - hasKey: true, - includeDetails: false, - }; - if (query !== undefined) { - args.search = query; - } - const certificates = await new CryptoApi( - DEFAULT_CONFIG, - ).cryptoCertificatekeypairsList(args); - return certificates.results; - }} - .renderElement=${(item: CertificateKeyPair): string => { - return item.name; - }} - .value=${(item: CertificateKeyPair | undefined): string | undefined => { - return item?.pk; - }} - .selected=${( - item: CertificateKeyPair, - items: CertificateKeyPair[], - ): boolean => { - let selected = this.instance?.signingKey === item.pk; - if (!this.instance && items.length === 1) { - selected = true; - } - return selected; - }} - ?blankable=${true} + - +

${msg("Key used to sign the tokens.")}

@@ -250,69 +260,53 @@ ${this.instance?.redirectUris} ${msg("Advanced protocol settings")}
- + ${msg("Configure how long access codes are valid for.")} +

+ `} > - -

- ${msg("Configure how long access codes are valid for.")} -

- -
- + + ${msg("Configure how long access tokens are valid for.")} +

+ `} > - -

- ${msg("Configure how long access tokens are valid for.")} -

- -
- + + + ${msg("Configure how long refresh tokens are valid for.")} +

+ `} > - -

- ${msg("Configure how long refresh tokens are valid for.")} -

- -
+ - - - - - - ${msg("Include claims in id_token")} - -

- ${msg( - "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.", - )} -

-
- + + label=${msg("Include claims in id_token")} + ?checked=${first(provider?.includeClaimsInIdToken, true)} + help=${msg( + "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.", + )}> + - - -

- ${msg( - "Configure how the issuer field of the ID Token should be filled.", - )} -

-
+
@@ -441,7 +366,7 @@ ${this.instance?.redirectUris} - ${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 @@ -60,6 +23,11 @@ export class AkFileInput extends AKElement { @property({ type: String }) label = ""; + /* + * The message to show next to the "current icon". + * + * @attr + */ @property({ type: String }) current = msg("Currently set to:"); @@ -73,13 +41,19 @@ export class AkFileInput extends AKElement { 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, - }); + const currentMsg = + this.value && this.current + ? html`

${this.current} ${this.value}

` + : nothing; + + return html` + + ${currentMsg} + ${this.help.trim() ? html`

${this.help}

` : nothing} +
`; } } diff --git a/web/src/components/ak-number-input.ts b/web/src/components/ak-number-input.ts index 8b35e7c9d..c955baad3 100644 --- a/web/src/components/ak-number-input.ts +++ b/web/src/components/ak-number-input.ts @@ -4,38 +4,6 @@ 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 @@ -55,7 +23,7 @@ export class AkNumberInput extends AKElement { @property({ type: String }) label = ""; - @property({ type: Number }) + @property({ type: Number, reflect: true }) value = 0; @property({ type: Boolean }) @@ -65,12 +33,18 @@ export class AkNumberInput extends AKElement { help = ""; render() { - return akNumber({ - name: this.name, - label: this.label, - value: this.value, - required: this.required, - help: this.help.trim() !== "" ? this.help : undefined, - }); + return html` + + ${this.help ? html`

${this.help}

` : nothing} +
`; } } diff --git a/web/src/components/ak-radio-input.ts b/web/src/components/ak-radio-input.ts index 8ab39c03a..e54dec59a 100644 --- a/web/src/components/ak-radio-input.ts +++ b/web/src/components/ak-radio-input.ts @@ -4,35 +4,6 @@ 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 @@ -65,14 +36,16 @@ export class AkRadioInput extends AKElement { 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, - }); + return html` + + ${this.help.trim() + ? html`

${this.help}

` + : nothing} +
`; } } diff --git a/web/src/components/ak-switch-input.ts b/web/src/components/ak-switch-input.ts index 6fe2fe850..33eb0434c 100644 --- a/web/src/components/ak-switch-input.ts +++ b/web/src/components/ak-switch-input.ts @@ -3,44 +3,6 @@ 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 @@ -73,12 +35,21 @@ export class AkSwitchInput extends AKElement { checkbox!: HTMLInputElement; render() { - return akSwitch({ - name: this.name, - label: this.label, - checked: this.checked, - required: this.required, - help: this.help.trim() !== "" ? this.help : undefined, - }); + const doCheck = this.checked ? this.checked : undefined; + + return html` + + ${this.help.trim() ? html`

${this.help}

` : nothing} +
`; } } + +export default AkSwitchInput; diff --git a/web/src/components/ak-text-input.ts b/web/src/components/ak-text-input.ts index 50d85fe39..a8019bf34 100644 --- a/web/src/components/ak-text-input.ts +++ b/web/src/components/ak-text-input.ts @@ -1,41 +1,9 @@ import { AKElement } from "@goauthentik/elements/Base"; -import { html, nothing } from "lit"; +import { TemplateResult, 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 @@ -55,7 +23,7 @@ export class AkTextInput extends AKElement { @property({ type: String }) label = ""; - @property({ type: String }) + @property({ type: String, reflect: true }) value = ""; @property({ type: Boolean }) @@ -64,13 +32,33 @@ export class AkTextInput extends AKElement { @property({ type: String }) help = ""; + @property({ type: Boolean }) + hidden = false; + + @property({ type: Object }) + bighelp!: TemplateResult | TemplateResult[]; + + renderHelp() { + return [ + this.help ? html`

${this.help}

` : nothing, + this.bighelp ? this.bighelp : nothing, + ]; + } + render() { - return akText({ - name: this.name, - label: this.label, - value: this.value, - required: this.required, - help: this.help.trim() !== "" ? this.help : undefined, - }); + return html` + + ${this.renderHelp()} + `; } } diff --git a/web/src/components/ak-textarea-input.ts b/web/src/components/ak-textarea-input.ts index 3c8e51942..456769815 100644 --- a/web/src/components/ak-textarea-input.ts +++ b/web/src/components/ak-textarea-input.ts @@ -1,40 +1,8 @@ import { AKElement } from "@goauthentik/elements/Base"; -import { html, nothing } from "lit"; +import { TemplateResult, 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 @@ -63,13 +31,26 @@ export class AkTextareaInput extends AKElement { @property({ type: String }) help = ""; + @property({ type: Object }) + bighelp!: TemplateResult | TemplateResult[]; + + renderHelp() { + return [ + this.help ? html`

${this.help}

` : nothing, + this.bighelp ? this.bighelp : nothing, + ]; + } + render() { - return akTextarea({ - name: this.name, - label: this.label, - value: this.value, - required: this.required, - help: this.help.trim() !== "" ? this.help : undefined, - }); + return html` + + ${this.renderHelp()} + `; } } diff --git a/web/src/elements/utils/eventEmitter.ts b/web/src/elements/utils/eventEmitter.ts index a0e082e25..9a4d421ef 100644 --- a/web/src/elements/utils/eventEmitter.ts +++ b/web/src/elements/utils/eventEmitter.ts @@ -11,15 +11,19 @@ export function CustomEmitterElement>(supercla return class EmmiterElementHandler extends superclass { // eslint-disable-next-line @typescript-eslint/no-explicit-any dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) { + const fullDetail = + typeof detail === "object" + ? { + target: this, + ...detail, + } + : detail; this.dispatchEvent( new CustomEvent(eventName, { composed: true, bubbles: true, ...options, - detail: { - target: this, - ...detail, - }, + detail: fullDetail, }), ); }