web: ak-application-wizard-authentication-by-oauth, and many fixes!

1. Fixed `eventEmitter` so that if the detail object is a scalar, it will not attempt to "objectify"
   it.  This was causing a bug where retrofitting the eventEmitter to some older components resulted
   in a detail of "some" being translated into ['s', 'o', 'm', 'e'].  Not what is wanted.
2. Removed the "transitional form" from the existing components; they had a two-step where the web
   component class was just a wrapper around an independent rendering function.  While this worked,
   it was only to make the case that they *were* independent rendering objects and could be
   supported with the right web component framework.  We're halfway there now; the last step will be
   to transform the horizontal-element and various input CSS into componentized CSS, the way
   Patternfly-Elements is currently doing.
3. Fixed the `help` field so that it could take a string or a TemplateResult, and if the latter,
   don't bother wrapping it in the helper text functionality; just let it be its own thing.  This
   supports the multi-line help of redirectURI as well as the `ak-utils-time-delta` capability.
4. Transform Oauth2ProviderForm to use the new components, to the best of our ability.  Also used
   the `provider = this.wizard.provider` and `provider = this.instance` syntax to make the render
   function *completely portable*; it's the exact same text that is dropped into...
5. The complete `ak-application-wizard-authentication-by-oauth` component. They're so similar part
   of me wonders if I could push them both out to a common reference, or a collection of common
   references.  Both components use the PropertyMapping and Sources, and both use the same
   collection of searches (Crypto, Flow).
6. A Storybook for `ak-application-wizard-authentication-by-oauth`, showing the works working.
7. New mocks for `authorizationFlow`, `propertyMappings`, and `hasJWKs`.

This sequence has revealed a bug in the radio control.  (It's always the radio control.)  If the
default doesn't match the current setting, the radio control doesn't behave as expected; it won't
change when you fully expect that it should.  I'll investigate how to harmonize those tomorrow.
This commit is contained in:
Ken Sternberg 2023-08-02 15:36:20 -07:00
parent f4807a3081
commit 0bba3ae97f
12 changed files with 809 additions and 541 deletions

View file

@ -1,5 +1,4 @@
import "@goauthentik/admin/applications/ApplicationForm"; import "@goauthentik/admin/applications/ApplicationForm";
import "@goauthentik/admin/applications/wizard/ApplicationWizard";
import { PFSize } from "@goauthentik/app/elements/Spinner"; import { PFSize } from "@goauthentik/app/elements/Spinner";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config"; import { uiConfig } from "@goauthentik/common/ui/config";
@ -89,11 +88,14 @@ export class ApplicationListPage extends TablePage<Application> {
renderSidebarAfter(): TemplateResult { renderSidebarAfter(): TemplateResult {
// Rendering the wizard with .open here, as if we set the attribute in // 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 // renderObjectCreate() it'll open two wizards, since that function gets called twice
return html`<ak-application-wizard
/* Re-enable the wizard later:
<ak-application-wizard
.open=${getURLParam("createWizard", false)} .open=${getURLParam("createWizard", false)}
.showButton=${false} .showButton=${false}
></ak-application-wizard> ></ak-application-wizard>*/
<div class="pf-c-sidebar__panel pf-m-width-25">
return html` <div class="pf-c-sidebar__panel pf-m-width-25">
<div class="pf-c-card"> <div class="pf-c-card">
<div class="pf-c-card__body"> <div class="pf-c-card__body">
<ak-markdown .md=${MDApplication}></ak-markdown> <ak-markdown .md=${MDApplication}></ak-markdown>

View file

@ -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`<form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input
name="name"
label=${msg("Name")}
value=${ifDefined(provider?.name)}
required
></ak-text-input>
<ak-form-element-horizontal
name="authenticationFlow"
label=${msg("Authentication flow")}
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${provider?.authenticationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used when a user access this provider and is not authenticated.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization flow")}
?required=${true}
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${provider?.authorizationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used when authorizing this provider.")}
</p>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-radio-input
name="clientType"
label=${msg("Client type")}
.value=${provider?.clientType}
required
@change=${(ev: CustomEvent<ClientTypeEnum>) => {
this.showClientSecret = ev.detail !== ClientTypeEnum.Public;
}}
.options=${clientTypeOptions}
>
</ak-radio-input>
<ak-text-input
name="clientId"
label=${msg("Client ID")}
value="${first(
provider?.clientId,
randomString(40, ascii_letters + digits)
)}"
required
>
</ak-text-input>
<ak-text-input
name="clientSecret"
label=${msg("Client Secret")}
value="${first(
provider?.clientSecret,
randomString(128, ascii_letters + digits)
)}"
?hidden=${!this.showClientSecret}
>
</ak-text-input>
<ak-textarea-input
name="redirectUris"
label=${msg("Redirect URIs/Origins (RegEx)")}
.value=${provider?.redirectUris}
.bighelp=${redirectUriHelp}
>
</ak-textarea-input>
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
<ak-crypto-certificate-search
certificate=${ifDefined(provider?.signingKey ?? nothing)}
name="certificate"
singleton
>
</ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">${msg("Key used to sign the tokens.")}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Advanced protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-text-input
name="accessCodeValidity"
label=${msg("Access code validity")}
required
value="${first(provider?.accessCodeValidity, "minutes=1")}"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Configure how long access codes are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-text-input
name="accessTokenValidity"
label=${msg("Access Token validity")}
value="${first(provider?.accessTokenValidity, "minutes=5")}"
required
.bighelp=${html` <p class="pf-c-form__helper-text">
${msg("Configure how long access tokens are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-text-input
name="refreshTokenValidity"
label=${msg("Refresh Token validity")}
value="${first(provider?.refreshTokenValidity, "days=30")}"
?required=${true}
.bighelp=${html` <p class="pf-c-form__helper-text">
${msg("Configure how long refresh tokens are valid for.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>`}
>
</ak-text-input>
<ak-form-element-horizontal label=${msg("Scopes")} name="propertyMappings">
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((scope) => {
let selected = false;
if (!provider?.propertyMappings) {
selected =
scope.managed?.startsWith(
"goauthentik.io/providers/oauth2/scope-"
) || false;
} else {
selected = Array.from(provider?.propertyMappings).some(
(su) => {
return su == scope.pk;
}
);
}
return html`<option
value=${ifDefined(scope.pk)}
?selected=${selected}
>
${scope.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg(
"Select which scopes can be used by the client. The client still has to specify the scope to access the data."
)}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-radio-input
name="subMode"
label=${msg("Subject mode")}
required
.options=${subjectModeOptions}
.value=${provider?.subMode}
help=${msg(
"Configure what data should be used as unique User Identifier. For most cases, the default should be fine."
)}
>
</ak-radio-input>
<ak-switch-input name="includeClaimsInIdToken">
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."
)}></ak-switch-input
>
<ak-radio-input
name="issuerMode"
label=${msg("Issuer mode")}
required
.options=${issuerModeOptions}
.value=${provider?.issuerMode}
help=${msg(
"Configure how the issuer field of the ID Token should be filled."
)}
>
</ak-radio-input>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header">${msg("Machine-to-Machine authentication settings")}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Trusted OIDC Sources")}
name="jwksSources"
>
<select class="pf-c-form-control" multiple>
${this.oauthSources?.results.map((source) => {
const selected = (provider?.jwksSources || []).some((su) => {
return su == source.pk;
});
return html`<option value=${source.pk} ?selected=${selected}>
${source.name} (${source.slug})
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg(
"JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider."
)}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>`;
}
}
export default ApplicationWizardAuthenticationByOauth;

View file

@ -7,11 +7,15 @@ import AkApplicationWizardApplicationDetails from "../ak-application-wizard-appl
import "../ak-application-wizard-authentication-method-choice"; import "../ak-application-wizard-authentication-method-choice";
import "../ak-application-wizard-context"; import "../ak-application-wizard-context";
import "../ldap/ak-application-wizard-authentication-by-ldap"; import "../ldap/ak-application-wizard-authentication-by-ldap";
import "../oauth/ak-application-wizard-authentication-by-oauth";
import "./ak-application-context-display-for-test"; import "./ak-application-context-display-for-test";
import { import {
dummyAuthenticationFlowsSearch, dummyAuthenticationFlowsSearch,
dummyAuthorizationFlowsSearch,
dummyCoreGroupsSearch, dummyCoreGroupsSearch,
dummyCryptoCertsSearch, dummyCryptoCertsSearch,
dummyHasJwks,
dummyPropertyMappings,
dummyProviderTypesList, dummyProviderTypesList,
} from "./samples"; } from "./samples";
@ -50,6 +54,26 @@ const metadata: Meta<AkApplicationWizardApplicationDetails> = {
status: 200, status: 200,
response: dummyAuthenticationFlowsSearch, 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,10 +84,9 @@ function injectTheme() {
if (!document.body.classList.contains(LIGHT)) { if (!document.body.classList.contains(LIGHT)) {
document.body.classList.add(LIGHT); document.body.classList.add(LIGHT);
} }
}) });
} }
export default metadata; export default metadata;
const container = (testItem: TemplateResult) => { const container = (testItem: TemplateResult) => {
@ -79,7 +102,7 @@ const container = (testItem: TemplateResult) => {
</style> </style>
${testItem} ${testItem}
</div>`; </div>`;
} };
export const PageOne = () => { export const PageOne = () => {
return container( return container(
@ -110,3 +133,14 @@ export const PageThreeLdap = () => {
</ak-application-wizard-context>`, </ak-application-wizard-context>`,
); );
}; };
export const PageThreeOauth2 = () => {
return container(
html`<ak-application-wizard-context>
<ak-application-wizard-authentication-by-oauth></ak-application-wizard-authentication-by-oauth>
<hr />
<ak-application-context-display-for-test></ak-application-context-display-for-test>
</ak-application-wizard-context>`,
);
};

View file

@ -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 = { export const dummyCoreGroupsSearch = {
pagination: { pagination: {
next: 0, 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 // prettier-ignore
export const dummyProviderTypesList = [ export const dummyProviderTypesList = [
["LDAP Provider", "ldapprovider", ["LDAP Provider", "ldapprovider",

View file

@ -1,6 +1,10 @@
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; 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/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -9,15 +13,12 @@ import "@goauthentik/elements/forms/SearchSelect";
import "@goauthentik/elements/utils/TimeDeltaHelp"; import "@goauthentik/elements/utils/TimeDeltaHelp";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit"; import { TemplateResult, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { import {
CertificateKeyPair,
ClientTypeEnum, ClientTypeEnum,
CryptoApi,
CryptoCertificatekeypairsListRequest,
FlowsInstancesListDesignationEnum, FlowsInstancesListDesignationEnum,
IssuerModeEnum, IssuerModeEnum,
OAuth2Provider, OAuth2Provider,
@ -29,6 +30,91 @@ import {
SubModeEnum, SubModeEnum,
} from "@goauthentik/api"; } 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`<p class="pf-c-form__helper-text">${m}</p>`,
)}`;
/**
* Form page for OAuth2 Authentication Method
*
* @element ak-provider-oauth2-form
*
*/
@customElement("ak-provider-oauth2-form") @customElement("ak-provider-oauth2-form")
export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> { export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> {
propertyMappings?: PaginatedScopeMappingList; propertyMappings?: PaginatedScopeMappingList;
@ -79,22 +165,23 @@ export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> {
} }
renderForm(): TemplateResult { renderForm(): TemplateResult {
const provider = this.instance;
return html`<form class="pf-c-form pf-m-horizontal"> return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name"> <ak-text-input
<input name="name"
type="text" label=${msg("Name")}
value="${ifDefined(this.instance?.name)}" value=${ifDefined(provider?.name)}
class="pf-c-form-control"
required required
/> ></ak-text-input>
</ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Authentication flow")}
name="authenticationFlow" name="authenticationFlow"
label=${msg("Authentication flow")}
> >
<ak-flow-search <ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication} flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${this.instance?.authenticationFlow} .currentFlow=${provider?.authenticationFlow}
required required
></ak-flow-search> ></ak-flow-search>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
@ -102,13 +189,13 @@ export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> {
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization flow")} label=${msg("Authorization flow")}
?required=${true} ?required=${true}
name="authorizationFlow"
> >
<ak-flow-search <ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization} flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${this.instance?.authorizationFlow} .currentFlow=${provider?.authorizationFlow}
required required
></ak-flow-search> ></ak-flow-search>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
@ -119,129 +206,52 @@ export class OAuth2ProviderFormPage extends ModelForm<OAuth2Provider, number> {
<ak-form-group .expanded=${true}> <ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span> <span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form"> <div slot="body" class="pf-c-form">
<ak-form-element-horizontal <ak-radio-input
label=${msg("Client type")}
?required=${true}
name="clientType" name="clientType"
> label=${msg("Client type")}
<ak-radio .value=${provider?.clientType}
required
@change=${(ev: CustomEvent<ClientTypeEnum>) => { @change=${(ev: CustomEvent<ClientTypeEnum>) => {
if (ev.detail === ClientTypeEnum.Public) { this.showClientSecret = ev.detail !== ClientTypeEnum.Public;
this.showClientSecret = false;
} else {
this.showClientSecret = true;
}
}} }}
.options=${[ .options=${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. ",
)}`,
},
]}
.value=${this.instance?.clientType}
> >
</ak-radio> </ak-radio-input>
</ak-form-element-horizontal> <ak-text-input
<ak-form-element-horizontal
label=${msg("Client ID")}
?required=${true}
name="clientId" name="clientId"
> label=${msg("Client ID")}
<input
type="text"
value="${first( value="${first(
this.instance?.clientId, provider?.clientId,
randomString(40, ascii_letters + digits), randomString(40, ascii_letters + digits),
)}" )}"
class="pf-c-form-control"
required required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
?hidden=${!this.showClientSecret}
label=${msg("Client Secret")}
name="clientSecret"
> >
<input </ak-text-input>
type="text" <ak-text-input
name="clientSecret"
label=${msg("Client Secret")}
value="${first( value="${first(
this.instance?.clientSecret, provider?.clientSecret,
randomString(128, ascii_letters + digits), randomString(128, ascii_letters + digits),
)}" )}"
class="pf-c-form-control" ?hidden=${!this.showClientSecret}
/> >
</ak-form-element-horizontal> </ak-text-input>
<ak-form-element-horizontal <ak-textarea-input
label=${msg("Redirect URIs/Origins (RegEx)")}
name="redirectUris" name="redirectUris"
label=${msg("Redirect URIs/Origins (RegEx)")}
.value=${provider?.redirectUris}
.bighelp=${redirectUriHelp}
> >
<textarea class="pf-c-form-control"> </ak-textarea-input>
${this.instance?.redirectUris}</textarea
>
<p class="pf-c-form__helper-text">
${msg(
"Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg(
"If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg(
'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.',
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey"> <ak-form-element-horizontal label=${msg("Signing Key")} name="signingKey">
<ak-search-select <ak-crypto-certificate-search
.fetchObjects=${async ( certificate=${ifDefined(provider?.signingKey ?? nothing)}
query?: string, name="certificate"
): Promise<CertificateKeyPair[]> => { singleton
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}
> >
</ak-search-select> </ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">${msg("Key used to sign the tokens.")}</p> <p class="pf-c-form__helper-text">${msg("Key used to sign the tokens.")}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
</div> </div>
@ -250,69 +260,53 @@ ${this.instance?.redirectUris}</textarea
<ak-form-group> <ak-form-group>
<span slot="header"> ${msg("Advanced protocol settings")} </span> <span slot="header"> ${msg("Advanced protocol settings")} </span>
<div slot="body" class="pf-c-form"> <div slot="body" class="pf-c-form">
<ak-form-element-horizontal <ak-text-input
label=${msg("Access code validity")}
?required=${true}
name="accessCodeValidity" name="accessCodeValidity"
> label=${msg("Access code validity")}
<input
type="text"
value="${first(this.instance?.accessCodeValidity, "minutes=1")}"
class="pf-c-form-control"
required required
/> value="${first(provider?.accessCodeValidity, "minutes=1")}"
<p class="pf-c-form__helper-text"> .bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Configure how long access codes are valid for.")} ${msg("Configure how long access codes are valid for.")}
</p> </p>
<ak-utils-time-delta-help></ak-utils-time-delta-help> <ak-utils-time-delta-help></ak-utils-time-delta-help>`}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Access Token validity")}
?required=${true}
name="accessTokenValidity"
> >
<input </ak-text-input>
type="text" <ak-text-input
value="${first(this.instance?.accessTokenValidity, "minutes=5")}" name="accessTokenValidity"
class="pf-c-form-control" label=${msg("Access Token validity")}
value="${first(provider?.accessTokenValidity, "minutes=5")}"
required required
/> .bighelp=${html` <p class="pf-c-form__helper-text">
<p class="pf-c-form__helper-text">
${msg("Configure how long access tokens are valid for.")} ${msg("Configure how long access tokens are valid for.")}
</p> </p>
<ak-utils-time-delta-help></ak-utils-time-delta-help> <ak-utils-time-delta-help></ak-utils-time-delta-help>`}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Refresh Token validity")}
?required=${true}
name="refreshTokenValidity"
> >
<input </ak-text-input>
type="text"
value="${first(this.instance?.refreshTokenValidity, "days=30")}" <ak-text-input
class="pf-c-form-control" name="refreshTokenValidity"
required label=${msg("Refresh Token validity")}
/> value="${first(provider?.refreshTokenValidity, "days=30")}"
<p class="pf-c-form__helper-text"> ?required=${true}
.bighelp=${html` <p class="pf-c-form__helper-text">
${msg("Configure how long refresh tokens are valid for.")} ${msg("Configure how long refresh tokens are valid for.")}
</p> </p>
<ak-utils-time-delta-help></ak-utils-time-delta-help> <ak-utils-time-delta-help></ak-utils-time-delta-help>`}
</ak-form-element-horizontal> >
</ak-text-input>
<ak-form-element-horizontal label=${msg("Scopes")} name="propertyMappings"> <ak-form-element-horizontal label=${msg("Scopes")} name="propertyMappings">
<select class="pf-c-form-control" multiple> <select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((scope) => { ${this.propertyMappings?.results.map((scope) => {
let selected = false; let selected = false;
if (!this.instance?.propertyMappings) { if (!provider?.propertyMappings) {
selected = selected =
scope.managed?.startsWith( scope.managed?.startsWith(
"goauthentik.io/providers/oauth2/scope-", "goauthentik.io/providers/oauth2/scope-",
) || false; ) || false;
} else { } else {
selected = Array.from(this.instance?.propertyMappings).some( selected = Array.from(provider?.propertyMappings).some((su) => {
(su) => {
return su == scope.pk; return su == scope.pk;
}, });
);
} }
return html`<option return html`<option
value=${ifDefined(scope.pk)} value=${ifDefined(scope.pk)}
@ -331,104 +325,35 @@ ${this.instance?.redirectUris}</textarea
${msg("Hold control/command to select multiple items.")} ${msg("Hold control/command to select multiple items.")}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal <ak-radio-input
label=${msg("Subject mode")}
?required=${true}
name="subMode" name="subMode"
> label=${msg("Subject mode")}
<ak-radio required
.options=${[ .options=${subjectModeOptions}
{ .value=${provider?.subMode}
label: msg("Based on the User's hashed ID"), help=${msg(
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.",
)}`,
},
]}
.value=${this.instance?.subMode}
>
</ak-radio>
<p class="pf-c-form__helper-text">
${msg(
"Configure what data should be used as unique User Identifier. For most cases, the default should be fine.", "Configure what data should be used as unique User Identifier. For most cases, the default should be fine.",
)} )}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="includeClaimsInIdToken">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.includeClaimsInIdToken, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Include claims in id_token")}</span
> >
</label> </ak-radio-input>
<p class="pf-c-form__helper-text"> <ak-switch-input name="includeClaimsInIdToken">
${msg( 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.", "Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.",
)} )}></ak-switch-input
</p> >
</ak-form-element-horizontal> <ak-radio-input
<ak-form-element-horizontal
label=${msg("Issuer mode")}
?required=${true}
name="issuerMode" name="issuerMode"
> label=${msg("Issuer mode")}
<ak-radio required
.options=${[ .options=${issuerModeOptions}
{ .value=${provider?.issuerMode}
label: msg( help=${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,
},
]}
.value=${this.instance?.issuerMode}
>
</ak-radio>
<p class="pf-c-form__helper-text">
${msg(
"Configure how the issuer field of the ID Token should be filled.", "Configure how the issuer field of the ID Token should be filled.",
)} )}
</p> >
</ak-form-element-horizontal> </ak-radio-input>
</div> </div>
</ak-form-group> </ak-form-group>
@ -441,7 +366,7 @@ ${this.instance?.redirectUris}</textarea
> >
<select class="pf-c-form-control" multiple> <select class="pf-c-form-control" multiple>
${this.oauthSources?.results.map((source) => { ${this.oauthSources?.results.map((source) => {
const selected = (this.instance?.jwksSources || []).some((su) => { const selected = (provider?.jwksSources || []).some((su) => {
return su == source.pk; return su == source.pk;
}); });
return html`<option value=${source.pk} ?selected=${selected}> return html`<option value=${source.pk} ?selected=${selected}>

View file

@ -4,43 +4,6 @@ import { msg } from "@lit/localize";
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
type AkFileArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value?: any;
required: boolean;
// The message to show next to the "current icon".
current: string;
// The help message, shown at the bottom.
help?: string;
};
const akFileDefaults = {
name: "",
required: false,
current: msg("Currently set to:"),
};
export function akFile(args: AkFileArgs) {
const { name, label, required, value, help, current } = {
...akFileDefaults,
...args,
};
const currentMsg =
value && current
? html` <p class="pf-c-form__helper-text">${current} ${value}</p> `
: nothing;
return html`<ak-form-element-horizontal ?required="${required}" label=${label} name=${name}>
<input type="file" value="" class="pf-c-form-control" />
${currentMsg} ${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
@customElement("ak-file-input") @customElement("ak-file-input")
export class AkFileInput extends AKElement { export class AkFileInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but // 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 }) @property({ type: String })
label = ""; label = "";
/*
* The message to show next to the "current icon".
*
* @attr
*/
@property({ type: String }) @property({ type: String })
current = msg("Currently set to:"); current = msg("Currently set to:");
@ -73,13 +41,19 @@ export class AkFileInput extends AKElement {
help = ""; help = "";
render() { render() {
return akFile({ const currentMsg =
name: this.name, this.value && this.current
label: this.label, ? html` <p class="pf-c-form__helper-text">${this.current} ${this.value}</p> `
value: this.value, : nothing;
current: this.current,
required: this.required, return html`<ak-form-element-horizontal
help: this.help.trim() !== "" ? this.help : undefined, ?required="${this.required}"
}); label=${this.label}
name=${this.name}
>
<input type="file" value="" class="pf-c-form-control" />
${currentMsg}
${this.help.trim() ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal>`;
} }
} }

View file

@ -4,38 +4,6 @@ import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.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`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<input
type="number"
value=${ifDefined(value)}
class="pf-c-form-control"
?required=${required}
/>
${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-number-input") @customElement("ak-number-input")
export class AkNumberInput extends AKElement { export class AkNumberInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but // 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 }) @property({ type: String })
label = ""; label = "";
@property({ type: Number }) @property({ type: Number, reflect: true })
value = 0; value = 0;
@property({ type: Boolean }) @property({ type: Boolean })
@ -65,12 +33,18 @@ export class AkNumberInput extends AKElement {
help = ""; help = "";
render() { render() {
return akNumber({ return html`<ak-form-element-horizontal
name: this.name, label=${this.label}
label: this.label, ?required=${this.required}
value: this.value, name=${this.name}
required: this.required, >
help: this.help.trim() !== "" ? this.help : undefined, <input
}); type="number"
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${this.required}
/>
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal> `;
} }
} }

View file

@ -4,35 +4,6 @@ import { RadioOption } from "@goauthentik/elements/forms/Radio";
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
type AkRadioArgs<T> = {
// 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<T>[];
// The help message, shown at the bottom.
help?: string;
};
const akRadioDefaults = {
required: false,
options: [],
};
export function akRadioInput<T>(args: AkRadioArgs<T>) {
const { name, label, help, required, options, value } = {
...akRadioDefaults,
...args,
};
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<ak-radio .options=${options} .value=${value}></ak-radio>
${help ? html`<p class="pf-c-form__helper-radio">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-radio-input") @customElement("ak-radio-input")
export class AkRadioInput<T> extends AKElement { export class AkRadioInput<T> extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but // Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
@ -65,14 +36,16 @@ export class AkRadioInput<T> extends AKElement {
options: RadioOption<T>[] = []; options: RadioOption<T>[] = [];
render() { render() {
return akRadioInput({ return html`<ak-form-element-horizontal
name: this.name, label=${this.label}
label: this.label, ?required=${this.required}
value: this.value, name=${this.name}
options: this.options, >
required: this.required, <ak-radio .options=${this.options} .value=${this.value}></ak-radio>
help: this.help.trim() !== "" ? this.help : undefined, ${this.help.trim()
}); ? html`<p class="pf-c-form__helper-radio">${this.help}</p>`
: nothing}
</ak-form-element-horizontal> `;
} }
} }

View file

@ -3,44 +3,6 @@ import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js"; 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` <ak-form-element-horizontal name=${name} ?required=${required}>
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" ?checked=${doCheck} />
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${label}</span>
</label>
${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal>`;
}
@customElement("ak-switch-input") @customElement("ak-switch-input")
export class AkSwitchInput extends AKElement { export class AkSwitchInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but // 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; checkbox!: HTMLInputElement;
render() { render() {
return akSwitch({ const doCheck = this.checked ? this.checked : undefined;
name: this.name,
label: this.label, return html` <ak-form-element-horizontal name=${this.name} ?required=${this.required}>
checked: this.checked, <label class="pf-c-switch">
required: this.required, <input class="pf-c-switch__input" type="checkbox" ?checked=${doCheck} />
help: this.help.trim() !== "" ? this.help : undefined, <span class="pf-c-switch__toggle">
}); <span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${this.label}</span>
</label>
${this.help.trim() ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
</ak-form-element-horizontal>`;
} }
} }
export default AkSwitchInput;

View file

@ -1,41 +1,9 @@
import { AKElement } from "@goauthentik/elements/Base"; 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 { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.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`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<input
type="text"
value=${ifDefined(value)}
class="pf-c-form-control"
?required=${required}
/>
${help ? html`<p class="pf-c-form__helper-text">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-text-input") @customElement("ak-text-input")
export class AkTextInput extends AKElement { export class AkTextInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but // 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 }) @property({ type: String })
label = ""; label = "";
@property({ type: String }) @property({ type: String, reflect: true })
value = ""; value = "";
@property({ type: Boolean }) @property({ type: Boolean })
@ -64,13 +32,33 @@ export class AkTextInput extends AKElement {
@property({ type: String }) @property({ type: String })
help = ""; help = "";
@property({ type: Boolean })
hidden = false;
@property({ type: Object })
bighelp!: TemplateResult | TemplateResult[];
renderHelp() {
return [
this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing,
this.bighelp ? this.bighelp : nothing,
];
}
render() { render() {
return akText({ return html`<ak-form-element-horizontal
name: this.name, label=${this.label}
label: this.label, ?required=${this.required}
value: this.value, ?hidden=${this.hidden}
required: this.required, name=${this.name}
help: this.help.trim() !== "" ? this.help : undefined, >
}); <input
type="text"
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${this.required}
/>
${this.renderHelp()}
</ak-form-element-horizontal> `;
} }
} }

View file

@ -1,40 +1,8 @@
import { AKElement } from "@goauthentik/elements/Base"; 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 { 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,
};
// `<textarea>` is highly sensitive to whitespace. When editing this control, take care that the
// provided value has no whitespace between the `textarea` open and close tags.
//
// prettier-ignore
return html`<ak-form-element-horizontal label=${label} ?required=${required} name=${name}>
<textarea class="pf-c-form-control" ?required=${required}
name=${name}>${value !== undefined ? value : ""}</textarea>
${help ? html`<p class="pf-c-form__helper-textarea">${help}</p>` : nothing}
</ak-form-element-horizontal> `;
}
@customElement("ak-textarea-input") @customElement("ak-textarea-input")
export class AkTextareaInput extends AKElement { export class AkTextareaInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but // 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 }) @property({ type: String })
help = ""; help = "";
@property({ type: Object })
bighelp!: TemplateResult | TemplateResult[];
renderHelp() {
return [
this.help ? html`<p class="pf-c-form__helper-textarea">${this.help}</p>` : nothing,
this.bighelp ? this.bighelp : nothing,
];
}
render() { render() {
return akTextarea({ return html`<ak-form-element-horizontal
name: this.name, label=${this.label}
label: this.label, ?required=${this.required}
value: this.value, name=${this.name}
required: this.required, >
help: this.help.trim() !== "" ? this.help : undefined, <textarea class="pf-c-form-control" ?required=${this.required} name=${this.name}>
}); ${this.value !== undefined ? this.value : ""}</textarea
>
${this.renderHelp()}
</ak-form-element-horizontal> `;
} }
} }

View file

@ -11,15 +11,19 @@ export function CustomEmitterElement<T extends Constructor<LitElement>>(supercla
return class EmmiterElementHandler extends superclass { return class EmmiterElementHandler extends superclass {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) { dispatchCustomEvent(eventName: string, detail: any = {}, options = {}) {
const fullDetail =
typeof detail === "object"
? {
target: this,
...detail,
}
: detail;
this.dispatchEvent( this.dispatchEvent(
new CustomEvent(eventName, { new CustomEvent(eventName, {
composed: true, composed: true,
bubbles: true, bubbles: true,
...options, ...options,
detail: { detail: fullDetail,
target: this,
...detail,
},
}), }),
); );
} }