diff --git a/web/src/admin/applications/ApplicationListPage.ts b/web/src/admin/applications/ApplicationListPage.ts index 928e48b9b..fd8306c3f 100644 --- a/web/src/admin/applications/ApplicationListPage.ts +++ b/web/src/admin/applications/ApplicationListPage.ts @@ -89,17 +89,15 @@ export class ApplicationListPage extends TablePage { ]; } + renderSectionBefore(): TemplateResult { + return html``; + } + 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 - /* Re-enable the wizard later: - */ - - return html`
+ return html`
diff --git a/web/src/admin/applications/wizard/BasePanel.ts b/web/src/admin/applications/wizard/BasePanel.ts index a395fc4b3..1b2db5bd4 100644 --- a/web/src/admin/applications/wizard/BasePanel.ts +++ b/web/src/admin/applications/wizard/BasePanel.ts @@ -1,5 +1,7 @@ import { WizardPanel } from "@goauthentik/components/ak-wizard-main/types"; import { AKElement } from "@goauthentik/elements/Base"; +import { KeyUnknown, serializeForm } from "@goauthentik/elements/forms/Form"; +import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { consume } from "@lit-labs/context"; @@ -10,6 +12,15 @@ import { styles as AwadStyles } from "./BasePanel.css"; import { applicationWizardContext } from "./ContextIdentity"; import type { ApplicationWizardState, ApplicationWizardStateUpdate } from "./types"; +/** + * Application Wizard Base Panel + * + * All of the displays in our system inherit from this object, which supplies the basic CSS for all + * the inputs we display, as well as the values and validity state for the form currently being + * displayed. + * + */ + export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) implements WizardPanel @@ -18,15 +29,41 @@ export class ApplicationWizardPageBase return AwadStyles; } - @query("form") - form!: HTMLFormElement; - - rendered = false; - @consume({ context: applicationWizardContext }) public wizard!: ApplicationWizardState; - // This used to be more complex; now it just establishes the event name. + @query("form") + form!: HTMLFormElement; + + /** + * Provide access to the values on the current form. Child implementations use this to craft the + * update that will be sent using `dispatchWizardUpdate` below. + */ + get formValues(): KeyUnknown | undefined { + const elements = [ + ...Array.from( + this.form.querySelectorAll("ak-form-element-horizontal"), + ), + ...Array.from(this.form.querySelectorAll("[data-ak-control=true]")), + ]; + return serializeForm(elements as unknown as NodeListOf); + } + + /** + * Provide access to the validity of the current form. Child implementations use this to craft + * the update that will be sent using `dispatchWizardUpdate` below. + */ + get valid() { + return this.form.checkValidity(); + } + + rendered = false; + + /** + * Provide a single source of truth for the token used to notify the orchestrator that an event + * happens. The token `ak-wizard-update` is used by the Wizard framework's reactive controller + * to route "data on the current step has changed" events to the orchestrator. + */ dispatchWizardUpdate(update: ApplicationWizardStateUpdate) { this.dispatchCustomEvent("ak-wizard-update", update); } diff --git a/web/src/admin/applications/wizard/ContextIdentity.ts b/web/src/admin/applications/wizard/ContextIdentity.ts index f03f147a6..629b65cd8 100644 --- a/web/src/admin/applications/wizard/ContextIdentity.ts +++ b/web/src/admin/applications/wizard/ContextIdentity.ts @@ -5,5 +5,3 @@ import { ApplicationWizardState } from "./types"; export const applicationWizardContext = createContext( Symbol("ak-application-wizard-state-context"), ); - -export default applicationWizardContext; diff --git a/web/src/admin/applications/wizard/ak-application-wizard.ts b/web/src/admin/applications/wizard/ak-application-wizard.ts index d15570f13..50cf750d7 100644 --- a/web/src/admin/applications/wizard/ak-application-wizard.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard.ts @@ -1,4 +1,3 @@ -import { merge } from "@goauthentik/common/merge"; import { AkWizard } from "@goauthentik/components/ak-wizard-main/AkWizard"; import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; @@ -6,7 +5,7 @@ import { ContextProvider } from "@lit-labs/context"; import { msg } from "@lit/localize"; import { customElement, state } from "lit/decorators.js"; -import applicationWizardContext from "./ContextIdentity"; +import { applicationWizardContext } from "./ContextIdentity"; import { newSteps } from "./steps"; import { ApplicationStep, @@ -15,10 +14,11 @@ import { OneOfProvider, } from "./types"; -const freshWizardState = () => ({ +const freshWizardState = (): ApplicationWizardState => ({ providerModel: "", app: {}, provider: {}, + errors: {}, }); @customElement("ak-application-wizard") @@ -56,28 +56,6 @@ export class ApplicationWizard extends CustomListenerElement( */ providerCache: Map = new Map(); - maybeProviderSwap(providerModel: string | undefined): boolean { - if ( - providerModel === undefined || - typeof providerModel !== "string" || - providerModel === this.wizardState.providerModel - ) { - return false; - } - - this.providerCache.set(this.wizardState.providerModel, this.wizardState.provider); - const prevProvider = this.providerCache.get(providerModel); - this.wizardState.provider = prevProvider ?? { - name: `Provider for ${this.wizardState.app.name}`, - }; - const method = this.steps.find(({ id }) => id === "provider-details"); - if (!method) { - throw new Error("Could not find Authentication Method page?"); - } - method.disabled = false; - return true; - } - // And this is where all the special cases go... handleUpdate(detail: ApplicationWizardStateUpdate) { if (detail.status === "submitted") { @@ -87,17 +65,26 @@ export class ApplicationWizard extends CustomListenerElement( } this.step.valid = this.step.valid || detail.status === "valid"; - const update = detail.update; + if (!update) { return; } - if (this.maybeProviderSwap(update.providerModel)) { - this.requestUpdate(); + // When the providerModel enum changes, retrieve the customer's prior work for *this* wizard + // session (and only this wizard session) or provide an empty model with a default provider + // name. + if (update.providerModel && update.providerModel !== this.wizardState.providerModel) { + const requestedProvider = this.providerCache.get(update.providerModel) ?? { + name: `Provider for ${this.wizardState.app.name}`, + }; + if (this.wizardState.providerModel) { + this.providerCache.set(this.wizardState.providerModel, this.wizardState.provider); + } + update.provider = requestedProvider; } - this.wizardState = merge(this.wizardState, update) as ApplicationWizardState; + this.wizardState = update as ApplicationWizardState; this.wizardStateProvider.setValue(this.wizardState); this.requestUpdate(); } diff --git a/web/src/admin/applications/wizard/ak-wizard-title.ts b/web/src/admin/applications/wizard/ak-wizard-title.ts new file mode 100644 index 000000000..cd2157a69 --- /dev/null +++ b/web/src/admin/applications/wizard/ak-wizard-title.ts @@ -0,0 +1,30 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { css, html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import PFContent from "@patternfly/patternfly/components/Content/content.css"; +import PFTitle from "@patternfly/patternfly/components/Title/title.css"; + +@customElement("ak-wizard-title") +export class AkWizardTitle extends AKElement { + static get styles() { + return [ + PFContent, + PFTitle, + css` + .ak-bottom-spacing { + padding-bottom: var(--pf-global--spacer--lg); + } + `, + ]; + } + + render() { + return html`
+

+
`; + } +} + +export default AkWizardTitle; diff --git a/web/src/admin/applications/wizard/application/ak-application-wizard-application-details.ts b/web/src/admin/applications/wizard/application/ak-application-wizard-application-details.ts index 13e439d76..c89c555a0 100644 --- a/web/src/admin/applications/wizard/application/ak-application-wizard-application-details.ts +++ b/web/src/admin/applications/wizard/application/ak-application-wizard-application-details.ts @@ -5,7 +5,6 @@ import "@goauthentik/components/ak-slug-input"; import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-text-input"; import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { msg } from "@lit/localize"; @@ -17,28 +16,20 @@ import BasePanel from "../BasePanel"; @customElement("ak-application-wizard-application-details") export class ApplicationWizardApplicationDetails extends BasePanel { - handleChange(ev: Event) { - if (!ev.target) { - console.warn(`Received event with no target: ${ev}`); - return; + handleChange(_ev: Event) { + const formValues = this.formValues; + if (!formValues) { + throw new Error("No application values on form?"); } - - const target = ev.target as HTMLInputElement; - const value = target.type === "checkbox" ? target.checked : target.value; this.dispatchWizardUpdate({ update: { - app: { - [target.name]: value, - }, + ...this.wizard, + app: formValues, }, - status: this.form.checkValidity() ? "valid" : "invalid", + status: this.valid ? "valid" : "invalid", }); } - validator() { - return this.form.reportValidity(); - } - render(): TemplateResult { return html`
${msg("UI Settings")} @@ -82,6 +77,7 @@ export class ApplicationWizardApplicationDetails extends BasePanel { help=${msg( "If left empty, authentik will try to extract the launch URL based on the selected provider.", )} + .errorMessages=${this.wizard.errors.app?.metaLaunchUrl ?? []} > TemplateResult; type ModelConverter = (provider: OneOfProvider) => ModelRequest; +/** + * There's an internal key and an API key because "Proxy" has three different subtypes. + */ +// prettier-ignore type ProviderType = [ - string, - string, - string, - ProviderRenderer, - ProviderModelEnumType, - ModelConverter, + string, // internal key used by the wizard to distinguish between providers + string, // Name of the provider + string, // Description + ProviderRenderer, // Function that returns the provider's wizard panel as a TemplateResult + ProviderModelEnumType, // key used by the API to distinguish between providers + ModelConverter, // Handler that takes a generic provider and returns one specifically typed to its panel ]; export type LocalTypeCreate = TypeCreate & { diff --git a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts index 593406d66..e13e11eca 100644 --- a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts +++ b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts @@ -25,19 +25,15 @@ export class ApplicationWizardAuthenticationMethodChoice extends BasePanel { handleChoice(ev: InputEvent) { const target = ev.target as HTMLInputElement; this.dispatchWizardUpdate({ - update: { providerModel: target.value }, - status: this.validator() ? "valid" : "invalid", + update: { + ...this.wizard, + providerModel: target.value, + errors: {}, + }, + status: this.valid ? "valid" : "invalid", }); } - validator() { - const radios = Array.from(this.form.querySelectorAll('input[type="radio"]')); - const chosen = radios.find( - (radio: Element) => radio instanceof HTMLInputElement && radio.checked, - ); - return !!chosen; - } - renderProvider(type: LocalTypeCreate) { const method = this.wizard.providerModel; diff --git a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts index aa21bd073..2258aa7b2 100644 --- a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts +++ b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts @@ -18,12 +18,14 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css"; import { - ApplicationRequest, + type ApplicationRequest, CoreApi, - TransactionApplicationRequest, - TransactionApplicationResponse, + type ModelRequest, + type TransactionApplicationRequest, + type TransactionApplicationResponse, + ValidationError, + ValidationErrorFromJSON, } from "@goauthentik/api"; -import type { ModelRequest } from "@goauthentik/api"; import BasePanel from "../BasePanel"; import providerModelsList from "../auth-method-choice/ak-application-wizard-authentication-method-choice.choices"; @@ -88,7 +90,7 @@ export class ApplicationWizardCommitApplication extends BasePanel { commitState: State = idleState; @state() - errors: string[] = []; + errors?: ValidationError; response?: TransactionApplicationResponse; @@ -121,27 +123,10 @@ export class ApplicationWizardCommitApplication extends BasePanel { } } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - decodeErrors(body: Record) { - const spaceify = (src: Record) => - Object.values(src).map((msg) => `\u00a0\u00a0\u00a0\u00a0${msg}`); - - let errs: string[] = []; - if (body["app"] !== undefined) { - errs = [...errs, msg("In the Application:"), ...spaceify(body["app"])]; - } - if (body["provider"] !== undefined) { - errs = [...errs, msg("In the Provider:"), ...spaceify(body["provider"])]; - } - console.log(body, errs); - return errs; - } - async send( data: TransactionApplicationRequest, ): Promise { - this.errors = []; - + this.errors = undefined; new CoreApi(DEFAULT_CONFIG) .coreTransactionalApplicationsUpdate({ transactionApplicationRequest: data, @@ -153,18 +138,57 @@ export class ApplicationWizardCommitApplication extends BasePanel { this.commitState = successState; }) // eslint-disable-next-line @typescript-eslint/no-explicit-any - .catch((resolution: any) => { - resolution.response.json().then( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (body: Record) => { - this.errors = this.decodeErrors(body); + .catch(async (resolution: any) => { + const errors = (this.errors = ValidationErrorFromJSON( + await resolution.response.json(), + )); + this.dispatchWizardUpdate({ + update: { + ...this.wizard, + errors, }, - ); + status: "failed", + }); this.commitState = errorState; }); } - render(): TemplateResult { + renderErrors(errors?: ValidationError) { + if (!errors) { + return nothing; + } + + const navTo = (step: number) => () => + this.dispatchCustomEvent("ak-wizard-nav", { + command: "goto", + step, + }); + + if (errors.app) { + return html`

${msg("There was an error in the application.")}

+

${msg("Review the application.")}

`; + } + if (errors.provider) { + return html`

${msg("There was an error in the provider.")}

+

${msg("Review the provider.")}

`; + } + if (errors.detail) { + return html`

${msg("There was an error")}: ${errors.detail}

`; + } + if ((errors?.nonFieldErrors ?? []).length > 0) { + return html`

$(msg("There was an error")}:

+
    + ${(errors.nonFieldErrors ?? []).map((e: string) => html`
  • ${e}
  • `)} +
`; + } + return html`

+ ${msg( + "There was an error creating the application, but no error message was sent. Please review the server logs.", + )} +

`; + } + + render() { const icon = classMap( this.commitState.icon.reduce((acc, icon) => ({ ...acc, [icon]: true }), {}), ); @@ -184,13 +208,7 @@ export class ApplicationWizardCommitApplication extends BasePanel { > ${this.commitState.label} - ${this.errors.length > 0 - ? html`
    - ${this.errors.map( - (msg) => html`
  • ${msg}
  • `, - )} -
` - : nothing} + ${this.renderErrors(this.errors)}
diff --git a/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts b/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts index ce60213e1..a8044c981 100644 --- a/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts +++ b/web/src/admin/applications/wizard/methods/BaseProviderPanel.ts @@ -1,26 +1,19 @@ import BasePanel from "../BasePanel"; export class ApplicationWizardProviderPageBase extends BasePanel { - handleChange(ev: InputEvent) { - if (!ev.target) { - console.warn(`Received event with no target: ${ev}`); - return; + handleChange(_ev: InputEvent) { + const formValues = this.formValues; + if (!formValues) { + throw new Error("No provider values on form?"); } - const target = ev.target as HTMLInputElement; - const value = target.type === "checkbox" ? target.checked : target.value; this.dispatchWizardUpdate({ update: { - provider: { - [target.name]: value, - }, + ...this.wizard, + provider: formValues, }, - status: this.form.checkValidity() ? "valid" : "invalid", + status: this.valid ? "valid" : "invalid", }); } - - validator() { - return this.form.reportValidity(); - } } export default ApplicationWizardProviderPageBase; diff --git a/web/src/admin/applications/wizard/methods/ldap/ak-application-wizard-authentication-by-ldap.ts b/web/src/admin/applications/wizard/methods/ldap/ak-application-wizard-authentication-by-ldap.ts index 2349af45b..a99384171 100644 --- a/web/src/admin/applications/wizard/methods/ldap/ak-application-wizard-authentication-by-ldap.ts +++ b/web/src/admin/applications/wizard/methods/ldap/ak-application-wizard-authentication-by-ldap.ts @@ -1,3 +1,4 @@ +import "@goauthentik/admin/applications/wizard/ak-wizard-title"; 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"; @@ -34,112 +35,132 @@ import { export class ApplicationWizardApplicationDetails extends BaseProviderPanel { render() { const provider = this.wizard.provider as LDAPProvider | undefined; + const errors = this.wizard.errors.provider; - return html` - - - - ${msg("Configure LDAP Provider")} + + -

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

-
+ help=${msg("Method's display Name.")} + > - - + +

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

+
+ + -

${groupHelp}

-
+ .errorMessages=${errors?.searchGroup ?? []} + > + +

${groupHelp}

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

${cryptoCertificateHelp}

- +
- + + + +

${cryptoCertificateHelp}

+
- + - -
- - `; + + + +
+
+ `; } } diff --git a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts index 62893a6af..d9e23e4a2 100644 --- a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts +++ b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts @@ -1,3 +1,4 @@ +import "@goauthentik/admin/applications/wizard/ak-wizard-title"; import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search"; import { @@ -27,10 +28,10 @@ import { PropertymappingsApi, SourcesApi, } from "@goauthentik/api"; -import type { - OAuth2Provider, - PaginatedOAuthSourceList, - PaginatedScopeMappingList, +import { + type OAuth2Provider, + type PaginatedOAuthSourceList, + type PaginatedScopeMappingList, } from "@goauthentik/api"; import BaseProviderPanel from "../BaseProviderPanel"; @@ -38,7 +39,7 @@ import BaseProviderPanel from "../BaseProviderPanel"; @customElement("ak-application-wizard-authentication-by-oauth") export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel { @state() - showClientSecret = false; + showClientSecret = true; @state() propertyMappings?: PaginatedScopeMappingList; @@ -68,234 +69,254 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel { render() { const provider = this.wizard.provider as OAuth2Provider | undefined; + const errors = this.wizard.errors.provider; - return html`
- - - - ${msg("Configure OAuth2/OpenId Provider")} + + -

- ${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.value !== ClientTypeEnum.Public; - }} - .options=${clientTypeOptions} - > - - - +

+ ${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.value !== 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("Key used to sign the tokens.")}

- `} - > -
+ +
+
- - ${msg("Configure how long access tokens are valid for.")} + + ${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("Configure how long refresh tokens are valid for.")} +

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

- `} - > -
+
- - + ${this.oauthSources?.results.map((source) => { + const selected = (provider?.jwksSources || []).some((su) => { + return su == source.pk; }); - } - return html``; - })} - -

- ${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.")} -

-
- - - - - - -
-
- - - ${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.")} -

-
-
-
- `; + return html``; + })} + +

+ ${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.")} +

+
+
+
+ `; } } diff --git a/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts b/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts index 4b32fe2d7..90d0f7580 100644 --- a/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts +++ b/web/src/admin/applications/wizard/methods/proxy/AuthenticationByProxyPage.ts @@ -1,3 +1,4 @@ +import "@goauthentik/admin/applications/wizard/ak-wizard-title"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-switch-input"; @@ -61,11 +62,11 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel { return nothing; } - renderProxyMode() { - return html`

This space intentionally left blank

`; + renderProxyMode(): TemplateResult { + throw new Error("Must be implemented in a child class."); } - renderHttpBasic(): TemplateResult { + renderHttpBasic() { return html``; } + scopeMappingConfiguration(provider?: ProxyProvider) { + const propertyMappings = this.propertyMappings?.results ?? []; + + const defaultScopes = () => + propertyMappings + .filter((scope) => !(scope?.managed ?? "").startsWith("goauthentik.io/providers")) + .map((pm) => pm.pk); + + const configuredScopes = (providerMappings: string[]) => + propertyMappings.map((scope) => scope.pk).filter((pk) => providerMappings.includes(pk)); + + const scopeValues = provider?.propertyMappings + ? configuredScopes(provider?.propertyMappings ?? []) + : defaultScopes(); + + const scopePairs = propertyMappings.map((scope) => [scope.pk, scope.name]); + + return { scopePairs, scopeValues }; + } + render() { - return html`
- ${this.renderModeDescription()} - + const errors = this.wizard.errors.provider; + const { scopePairs, scopeValues } = this.scopeMappingConfiguration(this.instance); - - ${msg("Configure Proxy Provider")} + + ${this.renderModeDescription()} + -

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

-
+ .errorMessages=${errors?.name ?? []} + label=${msg("Name")} + > - - -

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

-
+ + +

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

+
- ${this.renderProxyMode()} + + +

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

+
- + ${this.renderProxyMode()} - - ${msg("Advanced protocol settings")} -
- - - + - - + ${this.oauthSources?.results.map((source) => { + const selected = (this.instance?.jwksSources || []).some( (su) => { - return su == scope.pk; + return su == source.pk; }, ); - return html``; })} - -

- ${msg("Additional scope mappings, which are passed to the proxy.")} -

-

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

-
- - + +

${msg( - "Regular expressions for which authentication is not required. Each new line is interpreted as a new expression.", + "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", )}

- ${msg( - "When using proxy or forward auth (single application) mode, the requested URL Path is checked against the regular expressions. When using forward auth (domain mode), the full requested URL including scheme and host is matched against the regular expressions.", - )} -

`} - > -
-
-
- - ${msg("Authentication settings")} -
- - - { - const el = ev.target as HTMLInputElement; - this.showHttpBasic = el.checked; - }} - label=${msg("Send HTTP-Basic Authentication")} - help=${msg( - "Send a custom HTTP-Basic Authentication header based on values from authentik.", - )} - > - - ${this.showHttpBasic ? this.renderHttpBasic() : html``} - - - -

- ${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.")} -

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

+ +
+ + `; } } diff --git a/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-forward-domain-proxy.ts b/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-forward-domain-proxy.ts index e794cd7d4..63510ad11 100644 --- a/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-forward-domain-proxy.ts +++ b/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-forward-domain-proxy.ts @@ -5,6 +5,8 @@ import { customElement } from "@lit/reactive-element/decorators.js"; import { html } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; +import { ProxyProvider } from "@goauthentik/api"; + import AkTypeProxyApplicationWizardPage from "./AuthenticationByProxyPage"; @customElement("ak-application-wizard-authentication-for-forward-proxy-domain") @@ -28,11 +30,15 @@ export class AkForwardDomainProxyApplicationWizardPage extends AkTypeProxyApplic } renderProxyMode() { + const provider = this.wizard.provider as ProxyProvider | undefined; + const errors = this.wizard.errors.provider; + return html` diff --git a/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-single-forward-proxy.ts b/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-single-forward-proxy.ts index 0840c698f..5680d1e59 100644 --- a/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-single-forward-proxy.ts +++ b/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-single-forward-proxy.ts @@ -5,6 +5,8 @@ import { customElement } from "@lit/reactive-element/decorators.js"; import { html } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; +import { ProxyProvider } from "@goauthentik/api"; + import AkTypeProxyApplicationWizardPage from "./AuthenticationByProxyPage"; @customElement("ak-application-wizard-authentication-for-single-forward-proxy") @@ -21,11 +23,15 @@ export class AkForwardSingleProxyApplicationWizardPage extends AkTypeProxyApplic } renderProxyMode() { + const provider = this.wizard.provider as ProxyProvider | undefined; + const errors = this.wizard.errors.provider; + return html` - - - - - ${msg("Configure Radius Provider")} +
+ -

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

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

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

+ + + + ${msg("Protocol settings")} +
+ + -
-
- `; + >
+
+
+ `; } } diff --git a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts index f25c995ef..68041c7c1 100644 --- a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts +++ b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts @@ -1,7 +1,10 @@ +import "@goauthentik/admin/applications/wizard/ak-wizard-title"; +import "@goauthentik/admin/applications/wizard/ak-wizard-title"; 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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/components/ak-multi-select"; import "@goauthentik/components/ak-number-input"; import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-switch-input"; @@ -10,7 +13,7 @@ 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 { customElement, state } from "@lit/reactive-element/decorators.js"; import { html } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -27,9 +30,11 @@ import { signatureAlgorithmOptions, spBindingOptions, } from "./SamlProviderOptions"; +import "./saml-property-mappings-search"; @customElement("ak-application-wizard-authentication-by-saml-configuration") export class ApplicationWizardProviderSamlConfiguration extends BaseProviderPanel { + @state() propertyMappings?: PaginatedSAMLPropertyMappingList; constructor() { @@ -43,207 +48,229 @@ export class ApplicationWizardProviderSamlConfiguration extends BaseProviderPane }); } + propertyMappingConfiguration(provider?: SAMLProvider) { + const propertyMappings = this.propertyMappings?.results ?? []; + + const configuredMappings = (providerMappings: string[]) => + propertyMappings.map((pm) => pm.pk).filter((pmpk) => providerMappings.includes(pmpk)); + + const managedMappings = () => + propertyMappings + .filter((pm) => (pm?.managed ?? "").startsWith("goauthentik.io/providers/saml")) + .map((pm) => pm.pk); + + const pmValues = provider?.propertyMappings + ? configuredMappings(provider?.propertyMappings ?? []) + : managedMappings(); + + const propertyPairs = propertyMappings.map((pm) => [pm.pk, pm.name]); + + return { pmValues, propertyPairs }; + } + render() { const provider = this.wizard.provider as SAMLProvider | undefined; + const errors = this.wizard.errors.provider; - return html`
- + const { pmValues, propertyPairs } = this.propertyMappingConfiguration(provider); - - ${msg("Configure SAML Provider")} + + -

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

-
+ label=${msg("Name")} + .errorMessages=${errors?.name ?? []} + > - - -

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

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

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

+ - -
-
+ + +

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

+
- - ${msg("Advanced protocol settings")} -
- - -

- ${msg( - "Certificate used to sign outgoing Responses going to the Service Provider.", + + ${msg("Protocol settings")} +

+ + + + + - + > + - - -

- ${msg( - "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.", - )} -

-
+ +
+ - - -

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

-
+ + ${msg("Advanced protocol settings")} +
+ + +

+ ${msg( + "Certificate used to sign outgoing Responses going to the Service Provider.", + )} +

+
- - + +

+ ${msg( + "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.", + )} +

+
+ + + ${msg("Property mappings used for user mapping.")} +

+

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

`} + >
+ + -

- ${msg( - "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be respected.", + > + +

+ ${msg( + "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be respected.", + )} +

+
+ + - + .errorMessages=${errors?.assertionValidNotBefore ?? []} + > - + - + - + + - - - - - -
-
- `; + + +
+
+ `; } } diff --git a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-import.ts b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-import.ts deleted file mode 100644 index 924aead76..000000000 --- a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-import.ts +++ /dev/null @@ -1,81 +0,0 @@ -import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default"; -import "@goauthentik/components/ak-file-input"; -import { AkFileInput } from "@goauthentik/components/ak-file-input"; -import "@goauthentik/components/ak-text-input"; -import "@goauthentik/elements/forms/HorizontalFormElement"; - -import { msg } from "@lit/localize"; -import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; -import { html } from "lit"; -import { query } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; - -import { - FlowsInstancesListDesignationEnum, - ProvidersSamlImportMetadataCreateRequest, -} from "@goauthentik/api"; - -import BaseProviderPanel from "../BaseProviderPanel"; - -@customElement("ak-application-wizard-authentication-by-saml-import") -export class ApplicationWizardProviderSamlImport extends BaseProviderPanel { - @query('ak-file-input[name="metadata"]') - fileInput!: AkFileInput; - - handleChange(ev: InputEvent) { - if (!ev.target) { - console.warn(`Received event with no target: ${ev}`); - return; - } - const target = ev.target as HTMLInputElement; - if (target.type === "file") { - const file = this.fileInput.files?.[0] ?? null; - if (file) { - this.dispatchWizardUpdate({ - update: { - provider: { - file, - }, - }, - status: this.form.checkValidity() ? "valid" : "invalid", - }); - } - return; - } - super.handleChange(ev); - } - - render() { - const provider = this.wizard.provider as - | ProvidersSamlImportMetadataCreateRequest - | undefined; - - return html`
- - - - -

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

-
- - -
`; - } -} - -export default ApplicationWizardProviderSamlImport; diff --git a/web/src/admin/applications/wizard/methods/scim/ak-application-wizard-authentication-by-scim.ts b/web/src/admin/applications/wizard/methods/scim/ak-application-wizard-authentication-by-scim.ts index 493c740d1..832e7348a 100644 --- a/web/src/admin/applications/wizard/methods/scim/ak-application-wizard-authentication-by-scim.ts +++ b/web/src/admin/applications/wizard/methods/scim/ak-application-wizard-authentication-by-scim.ts @@ -1,7 +1,10 @@ +import "@goauthentik/admin/applications/wizard/ak-wizard-title"; +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 { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-multi-select"; import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-text-input"; import "@goauthentik/elements/forms/FormGroup"; @@ -12,14 +15,7 @@ import { customElement, state } from "@lit/reactive-element/decorators.js"; import { html } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; -import { - CoreApi, - CoreGroupsListRequest, - type Group, - PaginatedSCIMMappingList, - PropertymappingsApi, - type SCIMProvider, -} from "@goauthentik/api"; +import { PaginatedSCIMMappingList, PropertymappingsApi, type SCIMProvider } from "@goauthentik/api"; import BaseProviderPanel from "../BaseProviderPanel"; @@ -31,158 +27,129 @@ export class ApplicationWizardAuthenticationBySCIM extends BaseProviderPanel { constructor() { super(); new PropertymappingsApi(DEFAULT_CONFIG) - .propertymappingsScopeList({ - ordering: "scope_name", + .propertymappingsScimList({ + ordering: "managed", }) .then((propertyMappings: PaginatedSCIMMappingList) => { this.propertyMappings = propertyMappings; }); } + propertyMappingConfiguration(provider?: SCIMProvider) { + const propertyMappings = this.propertyMappings?.results ?? []; + + const configuredMappings = (providerMappings: string[]) => + propertyMappings.map((pm) => pm.pk).filter((pmpk) => providerMappings.includes(pmpk)); + + const managedMappings = (key: string) => + propertyMappings + .filter((pm) => pm.managed === `goauthentik.io/providers/scim/${key}`) + .map((pm) => pm.pk); + + const pmUserValues = provider?.propertyMappings + ? configuredMappings(provider?.propertyMappings ?? []) + : managedMappings("user"); + + const pmGroupValues = provider?.propertyMappingsGroup + ? configuredMappings(provider?.propertyMappingsGroup ?? []) + : managedMappings("group"); + + const propertyPairs = propertyMappings.map((pm) => [pm.pk, pm.name]); + + return { pmUserValues, pmGroupValues, propertyPairs }; + } + render() { const provider = this.wizard.provider as SCIMProvider | undefined; + const errors = this.wizard.errors.provider; - return html`
- - - ${msg("Protocol settings")} -
- - - - -
-
- - ${msg("User filtering")} -
- - - => { - const args: CoreGroupsListRequest = { - ordering: "name", - }; - if (query !== undefined) { - args.search = query; - } - const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList( - args, - ); - return groups.results; - }} - .renderElement=${(group: Group): string => { - return group.name; - }} - .value=${(group: Group | undefined): string | undefined => { - return group ? group.pk : undefined; - }} - .selected=${(group: Group): boolean => { - return group.pk === provider?.filterGroup; - }} - ?blankable=${true} + const { pmUserValues, pmGroupValues, propertyPairs } = + this.propertyMappingConfiguration(provider); + + return html`${msg("Configure SCIM Provider")} + + + + ${msg("Protocol settings")} +
+ - -

- ${msg("Only sync users within the selected group.")} -

- -
-
- - ${msg("Attribute mapping")} -
- - -

- ${msg("Property mappings used to user mapping.")} -

-

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

-
- - -

- ${msg("Property mappings used to group creation.")} -

-

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

-
-
-
- `; + + + +
+
+ + ${msg("User filtering")} +
+ + + +

+ ${msg("Only sync users within the selected group.")} +

+
+
+
+ + ${msg("Attribute mapping")} +
+ + ${msg("Property mappings used for user mapping.")} +

+

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

`} + >
+ + ${msg("Property mappings used for group creation.")} +

+

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

`} + >
+
+
+ `; } } diff --git a/web/src/admin/applications/wizard/steps.ts b/web/src/admin/applications/wizard/steps.ts index 451367bf8..fc427ed08 100644 --- a/web/src/admin/applications/wizard/steps.ts +++ b/web/src/admin/applications/wizard/steps.ts @@ -15,6 +15,12 @@ import "./commit/ak-application-wizard-commit-application"; import "./methods/ak-application-wizard-authentication-method"; import { ApplicationStep as ApplicationStepType } from "./types"; +/** + * In the current implementation, all of the child forms have access to the wizard's + * global context, into which all data is written, and which is updated by events + * flowing into the top-level orchestrator. + */ + class ApplicationStep implements ApplicationStepType { id = "application"; label = "Application Details"; diff --git a/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts b/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts index fa418199e..c21eb4199 100644 --- a/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts +++ b/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts @@ -3,7 +3,7 @@ import { customElement } from "@lit/reactive-element/decorators/custom-element.j import { state } from "@lit/reactive-element/decorators/state.js"; import { LitElement, html } from "lit"; -import applicationWizardContext from "../ContextIdentity"; +import { applicationWizardContext } from "../ContextIdentity"; import type { ApplicationWizardState } from "../types"; @customElement("ak-application-context-display-for-test") diff --git a/web/src/admin/applications/wizard/types.ts b/web/src/admin/applications/wizard/types.ts index 0ebe7aa8a..a6e86cac1 100644 --- a/web/src/admin/applications/wizard/types.ts +++ b/web/src/admin/applications/wizard/types.ts @@ -9,6 +9,7 @@ import { type RadiusProviderRequest, type SAMLProviderRequest, type SCIMProviderRequest, + type ValidationError, } from "@goauthentik/api"; export type OneOfProvider = @@ -24,12 +25,13 @@ export interface ApplicationWizardState { providerModel: string; app: Partial; provider: OneOfProvider; + errors: ValidationError; } type StatusType = "invalid" | "valid" | "submitted" | "failed"; export type ApplicationWizardStateUpdate = { - update?: Partial; + update?: ApplicationWizardState; status?: StatusType; }; diff --git a/web/src/common/merge.ts b/web/src/common/merge.ts deleted file mode 100644 index 4e60e856c..000000000 --- a/web/src/common/merge.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** Taken from: https://github.com/zellwk/javascript/tree/master - * - * We have added some typescript annotations, but this is such a rich feature with deep nesting - * we'll just have to watch it closely for any issues. So far there don't seem to be any. - * - */ - -function objectType(value: T) { - return Object.prototype.toString.call(value); -} - -// Creates a deep clone for each value -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function cloneDescriptorValue(value: any) { - // Arrays - if (objectType(value) === "[object Array]") { - const array = []; - for (let v of value) { - v = cloneDescriptorValue(v); - array.push(v); - } - return array; - } - - // Objects - if (objectType(value) === "[object Object]") { - const obj = {}; - const props = Object.keys(value); - for (const prop of props) { - const descriptor = Object.getOwnPropertyDescriptor(value, prop); - if (!descriptor) { - continue; - } - - if (descriptor.value) { - descriptor.value = cloneDescriptorValue(descriptor.value); - } - Object.defineProperty(obj, prop, descriptor); - } - return obj; - } - - // Other Types of Objects - if (objectType(value) === "[object Date]") { - return new Date(value.getTime()); - } - - if (objectType(value) === "[object Map]") { - const map = new Map(); - for (const entry of value) { - map.set(entry[0], cloneDescriptorValue(entry[1])); - } - return map; - } - - if (objectType(value) === "[object Set]") { - const set = new Set(); - for (const entry of value.entries()) { - set.add(cloneDescriptorValue(entry[0])); - } - return set; - } - - // Types we don't need to clone or cannot clone. - // Examples: - // - Primitives don't need to clone - // - Functions cannot clone - return value; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function _merge(output: Record, input: Record) { - const props = Object.keys(input); - - for (const prop of props) { - // Prevents Prototype Pollution - if (prop === "__proto__") continue; - - const descriptor = Object.getOwnPropertyDescriptor(input, prop); - if (!descriptor) { - continue; - } - - const value = descriptor.value; - if (value) descriptor.value = cloneDescriptorValue(value); - - // If don't have prop => Define property - // [ken@goauthentik] Using `hasOwn` is preferable over - // the basic identity test, according to Typescript. - if (!Object.hasOwn(output, prop)) { - Object.defineProperty(output, prop, descriptor); - continue; - } - - // If have prop, but type is not object => Overwrite by redefining property - if (typeof output[prop] !== "object") { - Object.defineProperty(output, prop, descriptor); - continue; - } - - // If have prop, but type is Object => Concat the arrays together. - if (objectType(descriptor.value) === "[object Array]") { - output[prop] = output[prop].concat(descriptor.value); - continue; - } - - // If have prop, but type is Object => Merge. - _merge(output[prop], descriptor.value); - } -} - -export function merge(...sources: Array) { - const result = {}; - for (const source of sources) { - _merge(result, source); - } - return result; -} - -export default merge; diff --git a/web/src/common/utils.ts b/web/src/common/utils.ts index 1ae395b4e..2b88f43dd 100644 --- a/web/src/common/utils.ts +++ b/web/src/common/utils.ts @@ -54,6 +54,13 @@ export function camelToSnake(key: string): string { return result.split(" ").join("_").toLowerCase(); } +const capitalize = (key: string) => (key.length === 0 ? "" : key[0].toUpperCase() + key.slice(1)); + +export function snakeToCamel(key: string) { + const [start, ...rest] = key.split("_"); + return [start, ...rest.map(capitalize)].join(""); +} + export function groupBy(objects: T[], callback: (obj: T) => string): Array<[string, T[]]> { const m = new Map(); objects.forEach((obj) => { diff --git a/web/src/components/HorizontalLightComponent.ts b/web/src/components/HorizontalLightComponent.ts new file mode 100644 index 000000000..7d34c833d --- /dev/null +++ b/web/src/components/HorizontalLightComponent.ts @@ -0,0 +1,72 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { TemplateResult, html, nothing } from "lit"; +import { property } from "lit/decorators.js"; + +type HelpType = TemplateResult | typeof nothing; + +export class HorizontalLightComponent 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 }) + required = false; + + @property({ type: String }) + help = ""; + + @property({ type: Object }) + bighelp?: TemplateResult | TemplateResult[]; + + @property({ type: Boolean }) + hidden = false; + + @property({ type: Boolean }) + invalid = false; + + @property({ attribute: false }) + errorMessages: string[] = []; + + renderControl() { + throw new Error("Must be implemented in a subclass"); + } + + renderHelp(): HelpType[] { + const bigHelp: HelpType[] = Array.isArray(this.bighelp) + ? this.bighelp + : [this.bighelp ?? nothing]; + return [ + this.help ? html`

${this.help}

` : nothing, + ...bigHelp, + ]; + } + + render() { + // prettier-ignore + return html` + ${this.renderControl()} + ${this.renderHelp()} + `; + } +} diff --git a/web/src/components/ak-multi-select.ts b/web/src/components/ak-multi-select.ts new file mode 100644 index 000000000..9efddd079 --- /dev/null +++ b/web/src/components/ak-multi-select.ts @@ -0,0 +1,150 @@ +import "@goauthentik/app/elements/forms/HorizontalFormElement"; +import { AKElement } from "@goauthentik/elements/Base"; + +import { TemplateResult, css, 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 { Ref, createRef, ref } from "lit/directives/ref.js"; + +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +type Pair = [string, string]; + +const selectStyles = css` + select[multiple] { + min-height: 15rem; + } +`; + +/** + * Horizontal layout control with a multi-select. + * + * @part select - The select itself, to override the height specified above. + */ +@customElement("ak-multi-select") +export class AkMultiSelect extends AKElement { + constructor() { + super(); + this.dataset.akControl = "true"; + } + + static get styles() { + return [PFBase, PFForm, PFFormControl, selectStyles]; + } + + /** + * The [name] attribute, which is also distributed to the layout manager and the input control. + */ + @property({ type: String }) + name!: string; + + /** + * The text label to display on the control + */ + @property({ type: String }) + label = ""; + + /** + * The values to be displayed in the select. The format is [Value, Label], where the label is + * what will be displayed. + */ + @property({ attribute: false }) + options: Pair[] = []; + + /** + * If true, at least one object must be selected + */ + @property({ type: Boolean }) + required = false; + + /** + * Supporting a simple help string + */ + @property({ type: String }) + help = ""; + + /** + * For more complex help instructions, provide a template result. + */ + @property({ type: Object }) + bighelp!: TemplateResult | TemplateResult[]; + + /** + * An array of strings representing the objects currently selected. + */ + @property({ type: Array }) + values: string[] = []; + + /** + * Helper accessor for older code + */ + get value() { + return this.values; + } + + /** + * One of two criteria (the other being the data-ak-control flag) that specifies this as a + * control that produces values of specific interest to our REST API. This is our modern + * accessor name. + */ + json() { + return this.values; + } + + renderHelp() { + return [ + this.help ? html`

${this.help}

` : nothing, + this.bighelp ? this.bighelp : nothing, + ]; + } + + handleChange(ev: Event) { + if (ev.type === "change") { + this.values = Array.from(this.selectRef.value!.querySelectorAll("option")) + .filter((option) => option.selected) + .map((option) => option.value); + this.dispatchEvent( + new CustomEvent("ak-select", { + detail: this.values, + composed: true, + bubbles: true, + }), + ); + } + } + + selectRef: Ref = createRef(); + + render() { + return html`
+ + + ${this.renderHelp()} + +
`; + } +} + +export default AkMultiSelect; diff --git a/web/src/components/ak-number-input.ts b/web/src/components/ak-number-input.ts index dcfef1541..65fc10b0e 100644 --- a/web/src/components/ak-number-input.ts +++ b/web/src/components/ak-number-input.ts @@ -1,51 +1,21 @@ -import { AKElement } from "@goauthentik/elements/Base"; - -import { html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @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 = ""; - +export class AkNumberInput extends HorizontalLightComponent { @property({ type: Number, reflect: true }) value = 0; - @property({ type: Boolean }) - required = false; - - @property({ type: String }) - help = ""; - - render() { - 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 c65b8f1ae..b4899cfc5 100644 --- a/web/src/components/ak-radio-input.ts +++ b/web/src/components/ak-radio-input.ts @@ -1,35 +1,13 @@ -import { AKElement } from "@goauthentik/elements/Base"; import { RadioOption } from "@goauthentik/elements/forms/Radio"; import "@goauthentik/elements/forms/Radio"; import { html, nothing } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @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; - +export class AkRadioInput extends HorizontalLightComponent { @property({ type: Object }) value!: T; @@ -37,24 +15,25 @@ export class AkRadioInput extends AKElement { options: RadioOption[] = []; handleInput(ev: CustomEvent) { - this.value = ev.detail.value; + if ("detail" in ev) { + this.value = ev.detail.value; + } } - render() { - return html` - ${this.help.trim() ? html`

${this.help}

` - : nothing} -
`; + : nothing}`; } } diff --git a/web/src/components/ak-slug-input.ts b/web/src/components/ak-slug-input.ts index b4fac3380..161a00c87 100644 --- a/web/src/components/ak-slug-input.ts +++ b/web/src/components/ak-slug-input.ts @@ -1,44 +1,16 @@ import { convertToSlug } from "@goauthentik/common/utils"; -import { AKElement } from "@goauthentik/elements/Base"; -import { TemplateResult, html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property, query } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @customElement("ak-slug-input") -export class AkSlugInput 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 = ""; - +export class AkSlugInput extends HorizontalLightComponent { @property({ type: String, reflect: true }) value = ""; - @property({ type: Boolean }) - required = false; - - @property({ type: String }) - help = ""; - - @property({ type: Boolean }) - hidden = false; - - @property({ type: Object }) - bighelp!: TemplateResult | TemplateResult[]; - @property({ type: String }) source = ""; @@ -59,13 +31,6 @@ export class AkSlugInput extends AKElement { this.input.addEventListener("input", this.handleTouch); } - renderHelp() { - return [ - this.help ? html`

${this.help}

` : nothing, - this.bighelp ? this.bighelp : nothing, - ]; - } - // Do not stop propagation of this event; it must be sent up the tree so that a parent // component, such as a custom forms manager, may receive it. handleTouch(ev: Event) { @@ -150,21 +115,13 @@ export class AkSlugInput extends AKElement { super.disconnectedCallback(); } - render() { - return html` - - ${this.renderHelp()} - `; + />`; } } diff --git a/web/src/components/ak-text-input.ts b/web/src/components/ak-text-input.ts index 2e7a9dd63..545ff9018 100644 --- a/web/src/components/ak-text-input.ts +++ b/web/src/components/ak-text-input.ts @@ -1,65 +1,21 @@ -import { AKElement } from "@goauthentik/elements/Base"; - -import { TemplateResult, html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @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 = ""; - +export class AkTextInput extends HorizontalLightComponent { @property({ type: String, reflect: true }) value = ""; - @property({ type: Boolean }) - required = false; - - @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 html` - - ${this.renderHelp()} - `; + />`; } } diff --git a/web/src/components/ak-textarea-input.ts b/web/src/components/ak-textarea-input.ts index 95b138550..9ca2efc4f 100644 --- a/web/src/components/ak-textarea-input.ts +++ b/web/src/components/ak-textarea-input.ts @@ -1,57 +1,22 @@ -import { AKElement } from "@goauthentik/elements/Base"; - -import { TemplateResult, html, nothing } from "lit"; +import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; +import { HorizontalLightComponent } from "./HorizontalLightComponent"; + @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 }) +export class AkTextareaInput extends HorizontalLightComponent { + @property({ type: String, reflect: true }) value = ""; - @property({ type: Boolean }) - required = false; - - @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 html` - - ${this.renderHelp()} - `; + >${this.value !== undefined ? this.value : ""} `; } } diff --git a/web/src/components/stories/ak-multi-select.stories.ts b/web/src/components/stories/ak-multi-select.stories.ts new file mode 100644 index 000000000..a3115c560 --- /dev/null +++ b/web/src/components/stories/ak-multi-select.stories.ts @@ -0,0 +1,79 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html, render } from "lit"; + +import "../ak-multi-select"; +import AkMultiSelect from "../ak-multi-select"; + +const metadata: Meta = { + title: "Components / MultiSelect", + component: "ak-multi-select", + parameters: { + docs: { + description: { + component: "A stylized value control for multi-select displays", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + + ${testItem} + +
+
`; + +const testOptions = [ + ["funky", "Option One: Funky"], + ["strange", "Option Two: Strange"], + ["weird", "Option Three: Weird"], +]; + +export const RadioInput = () => { + const result = ""; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const displayChange = (ev: any) => { + const messagePad = document.getElementById("message-pad"); + const component: AkMultiSelect | null = document.querySelector( + 'ak-multi-select[name="ak-test-multi-select"]', + ); + + const results = html` +

Results from event:

+
    + ${ev.target.value.map((v: string) => html`
  • ${v}
  • `)} +
+

Results from component:

+
    + ${component!.json().map((v: string) => html`
  • ${v}
  • `)} +
+ `; + + render(results, messagePad!); + }; + + return container( + html` +
${result}
`, + ); +}; diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts index 0c4590fac..d465a2435 100644 --- a/web/src/elements/forms/Form.ts +++ b/web/src/elements/forms/Form.ts @@ -31,10 +31,15 @@ export interface KeyUnknown { [key: string]: unknown; } +// Literally the only field `assignValue()` cares about. +type HTMLNamedElement = Pick; + +type AkControlElement = HTMLInputElement & { json: () => string | string[] }; + /** * Recursively assign `value` into `json` while interpreting the dot-path of `element.name` */ -function assignValue(element: HTMLInputElement, value: unknown, json: KeyUnknown): void { +function assignValue(element: HTMLNamedElement, value: unknown, json: KeyUnknown): void { let parent = json; if (!element.name?.includes(".")) { parent[element.name] = value; @@ -60,6 +65,16 @@ export function serializeForm( const json: { [key: string]: unknown } = {}; elements.forEach((element) => { element.requestUpdate(); + if (element.hidden) { + return; + } + + // TODO: Tighten up the typing so that we can handle both. + if ("akControl" in element.dataset) { + assignValue(element, (element as unknown as AkControlElement).json(), json); + return; + } + const inputElement = element.querySelector("[name]"); if (element.hidden || !inputElement) { return; diff --git a/web/xliff/de.xlf b/web/xliff/de.xlf index cacf5580c..35e128a26 100644 --- a/web/xliff/de.xlf +++ b/web/xliff/de.xlf @@ -5800,12 +5800,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved - - In the Application: - - - In the Provider: - Method's display Name. @@ -6075,6 +6069,51 @@ Bindings to groups/users are checked against the user of the event. When enabled, the stage will always accept the given user identifier and continue. + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation. diff --git a/web/xliff/en.xlf b/web/xliff/en.xlf index 11d8ee1a5..4f3ab5218 100644 --- a/web/xliff/en.xlf +++ b/web/xliff/en.xlf @@ -6077,12 +6077,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved - - In the Application: - - - In the Provider: - Method's display Name. @@ -6352,6 +6346,51 @@ Bindings to groups/users are checked against the user of the event. When enabled, the stage will always accept the given user identifier and continue. + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation. diff --git a/web/xliff/es.xlf b/web/xliff/es.xlf index b5708c7ac..4d10b85b6 100644 --- a/web/xliff/es.xlf +++ b/web/xliff/es.xlf @@ -5716,12 +5716,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved - - In the Application: - - - In the Provider: - Method's display Name. @@ -5991,6 +5985,51 @@ Bindings to groups/users are checked against the user of the event. When enabled, the stage will always accept the given user identifier and continue. + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation. diff --git a/web/xliff/fr.xlf b/web/xliff/fr.xlf index 37dfc4bce..44cbc593c 100644 --- a/web/xliff/fr.xlf +++ b/web/xliff/fr.xlf @@ -7617,14 +7617,6 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti Your application has been saved L'application a été sauvegardée - - In the Application: - Dans l'application : - - - In the Provider: - Dans le fournisseur : - Method's display Name. Nom d'affichage de la méthode. @@ -7986,6 +7978,51 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti When enabled, the stage will always accept the given user identifier and continue. Lorsqu'activé, l'étape acceptera toujours l'identifiant utilisateur donné et continuera. + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation. diff --git a/web/xliff/pl.xlf b/web/xliff/pl.xlf index 0c8118ae8..33645d2a7 100644 --- a/web/xliff/pl.xlf +++ b/web/xliff/pl.xlf @@ -5924,12 +5924,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved - - In the Application: - - - In the Provider: - Method's display Name. @@ -6199,6 +6193,51 @@ Bindings to groups/users are checked against the user of the event. When enabled, the stage will always accept the given user identifier and continue. + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation. diff --git a/web/xliff/pseudo-LOCALE.xlf b/web/xliff/pseudo-LOCALE.xlf index 78b560613..47114cbe0 100644 --- a/web/xliff/pseudo-LOCALE.xlf +++ b/web/xliff/pseudo-LOCALE.xlf @@ -7556,14 +7556,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved Ŷōũŕ àƥƥĺĩćàţĩōń ĥàś ƀēēń śàvēď - - In the Application: - Ĩń ţĥē Àƥƥĺĩćàţĩōń: - - - In the Provider: - Ĩń ţĥē Ƥŕōvĩďēŕ: - Method's display Name. Mēţĥōď'ś ďĩśƥĺàŷ Ńàmē. @@ -7732,149 +7724,258 @@ Bindings to groups/users are checked against the user of the event. Create With Wizard + Ćŕēàţē Ŵĩţĥ Ŵĩźàŕď One hint, 'New Application Wizard', is currently hidden + Ōńē ĥĩńţ, 'Ńēŵ Àƥƥĺĩćàţĩōń Ŵĩźàŕď', ĩś ćũŕŕēńţĺŷ ĥĩďďēń External applications that use authentik as an identity provider via protocols like OAuth2 and SAML. All applications are shown here, even ones you cannot access. + Ēxţēŕńàĺ àƥƥĺĩćàţĩōńś ţĥàţ ũśē àũţĥēńţĩķ àś àń ĩďēńţĩţŷ ƥŕōvĩďēŕ vĩà ƥŕōţōćōĺś ĺĩķē ŌÀũţĥ2 àńď ŚÀMĹ. Àĺĺ àƥƥĺĩćàţĩōńś àŕē śĥōŵń ĥēŕē, ēvēń ōńēś ŷōũ ćàńńōţ àććēśś. Deny message + Ďēńŷ mēśśàĝē Message shown when this stage is run. + Mēśśàĝē śĥōŵń ŵĥēń ţĥĩś śţàĝē ĩś ŕũń. Open Wizard + Ōƥēń Ŵĩźàŕď Demo Wizard + Ďēmō Ŵĩźàŕď Run the demo wizard + Ŕũń ţĥē ďēmō ŵĩźàŕď OAuth2/OIDC (Open Authorization/OpenID Connect) + ŌÀũţĥ2/ŌĨĎĆ (Ōƥēń Àũţĥōŕĩźàţĩōń/ŌƥēńĨĎ Ćōńńēćţ) LDAP (Lightweight Directory Access Protocol) + ĹĎÀƤ (Ĺĩĝĥţŵēĩĝĥţ Ďĩŕēćţōŕŷ Àććēśś Ƥŕōţōćōĺ) Forward Auth (Single Application) + Ƒōŕŵàŕď Àũţĥ (Śĩńĝĺē Àƥƥĺĩćàţĩōń) Forward Auth (Domain Level) + Ƒōŕŵàŕď Àũţĥ (Ďōmàĩń Ĺēvēĺ) SAML (Security Assertion Markup Language) + ŚÀMĹ (Śēćũŕĩţŷ Àśśēŕţĩōń Màŕķũƥ Ĺàńĝũàĝē) RADIUS (Remote Authentication Dial-In User Service) + ŔÀĎĨŨŚ (Ŕēmōţē Àũţĥēńţĩćàţĩōń Ďĩàĺ-Ĩń Ũśēŕ Śēŕvĩćē) SCIM (System for Cross-domain Identity Management) + ŚĆĨM (Śŷśţēm ƒōŕ Ćŕōśś-ďōmàĩń Ĩďēńţĩţŷ Màńàĝēmēńţ) The token has been copied to your clipboard + Ţĥē ţōķēń ĥàś ƀēēń ćōƥĩēď ţō ŷōũŕ ćĺĩƥƀōàŕď The token was displayed because authentik does not have permission to write to the clipboard + Ţĥē ţōķēń ŵàś ďĩśƥĺàŷēď ƀēćàũśē àũţĥēńţĩķ ďōēś ńōţ ĥàvē ƥēŕmĩśśĩōń ţō ŵŕĩţē ţō ţĥē ćĺĩƥƀōàŕď A copy of this recovery link has been placed in your clipboard + À ćōƥŷ ōƒ ţĥĩś ŕēćōvēŕŷ ĺĩńķ ĥàś ƀēēń ƥĺàćēď ĩń ŷōũŕ ćĺĩƥƀōàŕď The current tenant must have a recovery flow configured to use a recovery link + Ţĥē ćũŕŕēńţ ţēńàńţ mũśţ ĥàvē à ŕēćōvēŕŷ ƒĺōŵ ćōńƒĩĝũŕēď ţō ũśē à ŕēćōvēŕŷ ĺĩńķ Create recovery link + Ćŕēàţē ŕēćōvēŕŷ ĺĩńķ Create Recovery Link + Ćŕēàţē Ŕēćōvēŕŷ Ĺĩńķ External + Ēxţēŕńàĺ Service account + Śēŕvĩćē àććōũńţ Service account (internal) + Śēŕvĩćē àććōũńţ (ĩńţēŕńàĺ) Check the release notes + Ćĥēćķ ţĥē ŕēĺēàśē ńōţēś User Statistics + Ũśēŕ Śţàţĩśţĩćś <No name set> + <Ńō ńàmē śēţ> For nginx's auth_request or traefik's forwardAuth + Ƒōŕ ńĝĩńx'ś àũţĥ_ŕēǫũēśţ ōŕ ţŕàēƒĩķ'ś ƒōŕŵàŕďÀũţĥ For nginx's auth_request or traefik's forwardAuth per root domain + Ƒōŕ ńĝĩńx'ś àũţĥ_ŕēǫũēśţ ōŕ ţŕàēƒĩķ'ś ƒōŕŵàŕďÀũţĥ ƥēŕ ŕōōţ ďōmàĩń RBAC is in preview. + ŔßÀĆ ĩś ĩń ƥŕēvĩēŵ. User type used for newly created users. + Ũśēŕ ţŷƥē ũśēď ƒōŕ ńēŵĺŷ ćŕēàţēď ũśēŕś. Users created + Ũśēŕś ćŕēàţēď Failed logins + Ƒàĩĺēď ĺōĝĩńś Also known as Client ID. + Àĺśō ķńōŵń àś Ćĺĩēńţ ĨĎ. Also known as Client Secret. + Àĺśō ķńōŵń àś Ćĺĩēńţ Śēćŕēţ. Global status + Ĝĺōƀàĺ śţàţũś Vendor + Vēńďōŕ No sync status. + Ńō śŷńć śţàţũś. Sync currently running. + Śŷńć ćũŕŕēńţĺŷ ŕũńńĩńĝ. Connectivity + Ćōńńēćţĩvĩţŷ 0: Too guessable: risky password. (guesses &lt; 10^3) + 0: Ţōō ĝũēśśàƀĺē: ŕĩśķŷ ƥàśśŵōŕď. (ĝũēśśēś &ĺţ; 10^3) 1: Very guessable: protection from throttled online attacks. (guesses &lt; 10^6) + 1: Vēŕŷ ĝũēśśàƀĺē: ƥŕōţēćţĩōń ƒŕōm ţĥŕōţţĺēď ōńĺĩńē àţţàćķś. (ĝũēśśēś &ĺţ; 10^6) 2: Somewhat guessable: protection from unthrottled online attacks. (guesses &lt; 10^8) + 2: Śōmēŵĥàţ ĝũēśśàƀĺē: ƥŕōţēćţĩōń ƒŕōm ũńţĥŕōţţĺēď ōńĺĩńē àţţàćķś. (ĝũēśśēś &ĺţ; 10^8) 3: Safely unguessable: moderate protection from offline slow-hash scenario. (guesses &lt; 10^10) + 3: Śàƒēĺŷ ũńĝũēśśàƀĺē: mōďēŕàţē ƥŕōţēćţĩōń ƒŕōm ōƒƒĺĩńē śĺōŵ-ĥàśĥ śćēńàŕĩō. (ĝũēśśēś &ĺţ; 10^10) 4: Very unguessable: strong protection from offline slow-hash scenario. (guesses &gt;= 10^10) + 4: Vēŕŷ ũńĝũēśśàƀĺē: śţŕōńĝ ƥŕōţēćţĩōń ƒŕōm ōƒƒĺĩńē śĺōŵ-ĥàśĥ śćēńàŕĩō. (ĝũēśśēś &ĝţ;= 10^10) Successfully created user and added to group + Śũććēśśƒũĺĺŷ ćŕēàţēď ũśēŕ àńď àďďēď ţō ĝŕōũƥ This user will be added to the group "". + Ţĥĩś ũśēŕ ŵĩĺĺ ƀē àďďēď ţō ţĥē ĝŕōũƥ "". Pretend user exists + Ƥŕēţēńď ũśēŕ ēxĩśţś When enabled, the stage will always accept the given user identifier and continue. + Ŵĥēń ēńàƀĺēď, ţĥē śţàĝē ŵĩĺĺ àĺŵàŷś àććēƥţ ţĥē ĝĩvēń ũśēŕ ĩďēńţĩƒĩēŕ àńď ćōńţĩńũē. + + + There was an error in the application. + Ţĥēŕē ŵàś àń ēŕŕōŕ ĩń ţĥē àƥƥĺĩćàţĩōń. + + + Review the application. + Ŕēvĩēŵ ţĥē àƥƥĺĩćàţĩōń. + + + There was an error in the provider. + Ţĥēŕē ŵàś àń ēŕŕōŕ ĩń ţĥē ƥŕōvĩďēŕ. + + + Review the provider. + Ŕēvĩēŵ ţĥē ƥŕōvĩďēŕ. + + + There was an error + Ţĥēŕē ŵàś àń ēŕŕōŕ + + + There was an error creating the application, but no error message was sent. Please review the server logs. + Ţĥēŕē ŵàś àń ēŕŕōŕ ćŕēàţĩńĝ ţĥē àƥƥĺĩćàţĩōń, ƀũţ ńō ēŕŕōŕ mēśśàĝē ŵàś śēńţ. Ƥĺēàśē ŕēvĩēŵ ţĥē śēŕvēŕ ĺōĝś. + + + Configure LDAP Provider + Ćōńƒĩĝũŕē ĹĎÀƤ Ƥŕōvĩďēŕ + + + Configure OAuth2/OpenId Provider + Ćōńƒĩĝũŕē ŌÀũţĥ2/ŌƥēńĨď Ƥŕōvĩďēŕ + + + Configure Proxy Provider + Ćōńƒĩĝũŕē Ƥŕōxŷ Ƥŕōvĩďēŕ + + + AdditionalScopes + ÀďďĩţĩōńàĺŚćōƥēś + + + Configure Radius Provider + Ćōńƒĩĝũŕē Ŕàďĩũś Ƥŕōvĩďēŕ + + + Configure SAML Provider + Ćōńƒĩĝũŕē ŚÀMĹ Ƥŕōvĩďēŕ + + + Property mappings used for user mapping. + Ƥŕōƥēŕţŷ màƥƥĩńĝś ũśēď ƒōŕ ũśēŕ màƥƥĩńĝ. + + + Configure SCIM Provider + Ćōńƒĩĝũŕē ŚĆĨM Ƥŕōvĩďēŕ + + + Property mappings used for group creation. + Ƥŕōƥēŕţŷ màƥƥĩńĝś ũśēď ƒōŕ ĝŕōũƥ ćŕēàţĩōń. diff --git a/web/xliff/tr.xlf b/web/xliff/tr.xlf index f2eb13ba5..0a11cec1e 100644 --- a/web/xliff/tr.xlf +++ b/web/xliff/tr.xlf @@ -5709,12 +5709,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved - - In the Application: - - - In the Provider: - Method's display Name. @@ -5984,6 +5978,51 @@ Bindings to groups/users are checked against the user of the event. When enabled, the stage will always accept the given user identifier and continue. + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation. diff --git a/web/xliff/zh-Hans.xlf b/web/xliff/zh-Hans.xlf index 96bfb9808..04c17aa71 100644 --- a/web/xliff/zh-Hans.xlf +++ b/web/xliff/zh-Hans.xlf @@ -7619,14 +7619,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved 您的应用程序已保存 - - In the Application: - 在应用程序中: - - - In the Provider: - 在提供程序中: - Method's display Name. 方法的显示名称。 @@ -7988,6 +7980,51 @@ Bindings to groups/users are checked against the user of the event. When enabled, the stage will always accept the given user identifier and continue. 启用时,此阶段总是会接受指定的用户 ID 并继续。 + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation. diff --git a/web/xliff/zh-Hant.xlf b/web/xliff/zh-Hant.xlf index e836104f2..47c48f322 100644 --- a/web/xliff/zh-Hant.xlf +++ b/web/xliff/zh-Hant.xlf @@ -5757,12 +5757,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved - - In the Application: - - - In the Provider: - Method's display Name. @@ -6032,6 +6026,51 @@ Bindings to groups/users are checked against the user of the event. When enabled, the stage will always accept the given user identifier and continue. + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation. diff --git a/web/xliff/zh_TW.xlf b/web/xliff/zh_TW.xlf index eccaef9f7..50f4fca35 100644 --- a/web/xliff/zh_TW.xlf +++ b/web/xliff/zh_TW.xlf @@ -7555,14 +7555,6 @@ Bindings to groups/users are checked against the user of the event. Your application has been saved 已經儲存您的應用程式 - - In the Application: - 在應用程式: - - - In the Provider: - 在供應商: - Method's display Name. 方法的顯示名稱。 @@ -7925,6 +7917,51 @@ Bindings to groups/users are checked against the user of the event. Are you sure you want to remove the selected users from the group ? + + + There was an error in the application. + + + Review the application. + + + There was an error in the provider. + + + Review the provider. + + + There was an error + + + There was an error creating the application, but no error message was sent. Please review the server logs. + + + Configure LDAP Provider + + + Configure OAuth2/OpenId Provider + + + Configure Proxy Provider + + + AdditionalScopes + + + Configure Radius Provider + + + Configure SAML Provider + + + Property mappings used for user mapping. + + + Configure SCIM Provider + + + Property mappings used for group creation.