Now, it's starting to look like a complete package. The LDAP method is working, but there is a bug:

the radio is sending the wrong value !?!?!?. Track that down, dammit. The search wrappers now resend
their events as standard `input` events, and that actually seems to work well; the browser is
decorating it with the right target, with the right `name` attribute, and since we have good
definitions of the `value` as a string (the real value of any search object is its UUID4), that
works quite well. Added search wrappers for CoreGroup and CryptoCertificate (CertificateKeyPairs),
and the latter has flags for "use the first one if it's the only one" and "allow the display of
keyless certificates."

Not sure why `state()` is blocking the transmission of typing information from the typed element
to the context handler, but it's a bug in the typechecker, and it's not a problem so far.
This commit is contained in:
Ken Sternberg 2023-08-01 16:18:58 -07:00
parent b4d3b75434
commit b158074d78
17 changed files with 791 additions and 239 deletions

View File

@ -7,16 +7,17 @@ import { state } from "@lit/reactive-element/decorators/state.js";
import { styles as AwadStyles } from "./ak-application-wizard-application-details.css";
import type { WizardState } from "./ak-application-wizard-context";
import applicationWizardContext from "./ak-application-wizard-context-name";
import { applicationWizardContext } from "./ak-application-wizard-context-name";
export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) {
static get styles() {
return AwadStyles;
}
// @ts-expect-error
@consume({ context: applicationWizardContext, subscribe: true })
@state()
private wizard!: WizardState;
public wizard!: WizardState;
dispatchWizardUpdate(update: Partial<WizardState>) {
this.dispatchCustomEvent("ak-wizard-update", {

View File

@ -17,11 +17,16 @@ import ApplicationWizardPageBase from "./ApplicationWizardPageBase";
@customElement("ak-application-wizard-application-details")
export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBase {
handleChange(ev: Event) {
const value = ev.target.type === "checkbox" ? ev.target.checked : ev.target.value;
if (!ev.target) {
console.warn(`Received event with no target: ${ev}`);
return;
}
const target = ev.target as HTMLInputElement;
const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({
application: {
...this.wizard.application,
[ev.target.name]: value,
[target.name]: value,
},
});
}
@ -30,24 +35,24 @@ export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBa
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input
name="name"
value=${this.wizard.application?.name}
value=${ifDefined(this.wizard.application?.name)}
label=${msg("Name")}
required
help=${msg("Application's display Name.")}
></ak-text-input>
<ak-text-input
name="slug"
value=${this.wizard.application?.slug}
value=${ifDefined(this.wizard.application?.slug)}
label=${msg("Slug")}
required
help=${msg("Internal application name used in URLs.")}
></ak-text-input>
<ak-text-input
name="group"
value=${this.wizard.application?.group}
value=${ifDefined(this.wizard.application?.group)}
label=${msg("Group")}
help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together."
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
)}
></ak-text-input>
<ak-radio-input
@ -65,7 +70,7 @@ export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBa
label=${msg("Launch URL")}
value=${ifDefined(this.wizard.application?.metaLaunchUrl)}
help=${msg(
"If left empty, authentik will try to extract the launch URL based on the selected provider."
"If left empty, authentik will try to extract the launch URL based on the selected provider.",
)}
></ak-text-input>
<ak-switch-input
@ -73,7 +78,7 @@ export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBa
?checked=${first(this.wizard.application?.openInNewTab, false)}
label=${msg("Open in new tab")}
help=${msg(
"If checked, the launch URL will open in a new browser tab or window from the user's application library."
"If checked, the launch URL will open in a new browser tab or window from the user's application library.",
)}
>
</ak-switch-input>

View File

@ -17,6 +17,18 @@ import type { TypeCreate } from "@goauthentik/api";
import ApplicationWizardPageBase from "./ApplicationWizardPageBase";
// The provider description that comes from the server is fairly specific and not internationalized.
// We provide alternative descriptions that use the phrase 'authentication method' instead, and make
// it available to i18n.
//
// prettier-ignore
const alternativeDescription = new Map<string, string>([
["oauth2provider", msg("Modern applications, APIs and Single-page applications.")],
["samlprovider", msg("XML-based SSO standard. Use this if your application only supports SAML.")],
["proxyprovider", msg("Legacy applications which don't natively support SSO.")],
["ldapprovider", msg("Provide an LDAP interface for applications and users to authenticate against.")]
]);
@customElement("ak-application-wizard-authentication-method-choice")
export class ApplicationWizardAuthenticationMethodChoice extends ApplicationWizardPageBase {
@state()
@ -26,21 +38,25 @@ export class ApplicationWizardAuthenticationMethodChoice extends ApplicationWiza
super();
this.handleChoice = this.handleChoice.bind(this);
this.renderProvider = this.renderProvider.bind(this);
// If the provider doesn't supply a model to which to send our initialization, the user will
// have to use the older provider path.
new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList().then((types) => {
this.providerTypes = types;
this.providerTypes = types.filter(({ modelName }) => modelName.trim() !== "");
});
}
handleChoice(ev: Event) {
this.dispatchWizardUpdate({ providerType: ev.target.value });
handleChoice(ev: InputEvent) {
const target = ev.target as HTMLInputElement;
this.dispatchWizardUpdate({ providerType: target.value });
}
renderProvider(type: Provider) {
// Special case; the SAML-by-import method is handled differently
// prettier-ignore
const model = /^SAML/.test(type.name) && type.modelName === ""
? "samlimporter"
: type.modelName;
renderProvider(type: TypeCreate) {
const description = alternativeDescription.has(type.modelName)
? alternativeDescription.get(type.modelName)
: type.description;
const label = type.name.replace(/\s+Provider/, "");
return html`<div class="pf-c-radio">
<input
@ -48,11 +64,11 @@ export class ApplicationWizardAuthenticationMethodChoice extends ApplicationWiza
type="radio"
name="type"
id=${type.component}
value=${model}
value=${type.modelName}
@change=${this.handleChoice}
/>
<label class="pf-c-radio__label" for=${type.component}>${type.name}</label>
<span class="pf-c-radio__description">${type.description}</span>
<label class="pf-c-radio__label" for=${type.component}>${label}</label>
<span class="pf-c-radio__description">${description}</span>
</div>`;
}

View File

@ -0,0 +1,29 @@
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { TemplateResult, html } from "lit";
import ApplicationWizardPageBase from "./ApplicationWizardPageBase";
// prettier-ignore
const handlers = new Map<string, () => TemplateResult>([
["ldapprovider", () => html`<ak-application-wizard-authentication-by-ldap></ak-application-wizard-authentication-by-ldap>`],
["oauth2provider", () => html`<p>Under construction</p>`],
["proxyprovider", () => html`<p>Under construction</p>`],
["radiusprovider", () => html`<p>Under construction</p>`],
["samlprovider", () => html`<p>Under construction</p>`],
["scimprovider", () => html`<p>Under construction</p>`],
]);
@customElement("ak-application-wizard-authentication-method")
export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBase {
render() {
const handler = handlers.get(this.wizard.providerType);
if (!handler) {
throw new Error(
"Unrecognized authentication method in ak-application-wizard-authentication-method",
);
}
return handler();
}
}
export default ApplicationWizardApplicationDetails;

View File

@ -1,5 +1,5 @@
import {createContext} from '@lit-labs/context';
import { createContext } from "@lit-labs/context";
export const ApplicationWizardContext = createContext(Symbol('ak-application-wizard-context'));
export const applicationWizardContext = createContext(Symbol("ak-application-wizard-context"));
export default ApplicationWizardContext;
export default applicationWizardContext;

View File

@ -27,12 +27,14 @@ type OneOfProvider =
| Partial<OAuth2Provider>
| Partial<LDAPProvider>;
export type WizardState = {
export interface WizardState {
step: number;
providerType: string;
application: Partial<Application>;
provider: OneOfProvider;
};
}
type WizardStateEvent = WizardState & { target?: HTMLInputElement };
@customElement("ak-application-wizard-context")
export class AkApplicationWizardContext extends CustomListenerElement(LitElement) {
@ -63,7 +65,7 @@ export class AkApplicationWizardContext extends CustomListenerElement(LitElement
super.disconnectedCallback();
}
handleUpdate(event: CustomEvent<WizardState>) {
handleUpdate(event: CustomEvent<WizardStateEvent>) {
delete event.detail.target;
this.wizardState = event.detail;
}

View File

@ -22,6 +22,7 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
/*
const steps = [
{
name: msg("Application Details"),
@ -44,6 +45,7 @@ const steps = [
html`<ak-application-wizard-application-commit></ak-application-wizard-application-commit>`,
},
];
*/
@customElement("ak-application-wizard")
export class ApplicationWizard extends AKElement {

View File

@ -0,0 +1,210 @@
import "@goauthentik/admin/common/ak-core-group-search";
import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import { rootInterface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { FlowsInstancesListDesignationEnum, LDAPAPIAccessMode } from "@goauthentik/api";
import type { LDAPProvider } from "@goauthentik/api";
import ApplicationWizardPageBase from "../ApplicationWizardPageBase";
const bindModeOptions = [
{
label: msg("Cached binding"),
value: LDAPAPIAccessMode.Cached,
default: true,
description: html`${msg(
"Flow is executed and session is cached in memory. Flow is executed when session expires",
)}`,
},
{
label: msg("Direct binding"),
value: LDAPAPIAccessMode.Direct,
description: html`${msg(
"Always execute the configured bind flow to authenticate the user",
)}`,
},
];
const searchModeOptions = [
{
label: msg("Cached querying"),
value: LDAPAPIAccessMode.Cached,
default: true,
description: html`${msg(
"The outpost holds all users and groups in-memory and will refresh every 5 Minutes",
)}`,
},
{
label: msg("Direct querying"),
value: LDAPAPIAccessMode.Direct,
description: html`${msg(
"Always returns the latest data, but slower than cached querying",
)}`,
},
];
const groupHelp = msg(
"Users in the selected group can do search queries. If no group is selected, no LDAP Searches are allowed.",
);
const mfaSupportHelp = msg(
"When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.",
);
@customElement("ak-application-wizard-authentication-by-ldap")
export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBase {
handleChange(ev: InputEvent) {
if (!ev.target) {
console.warn(`Received event with no target: ${ev}`);
return;
}
const target = ev.target as HTMLInputElement;
const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({
provider: {
...this.wizard.provider,
[target.name]: value,
},
});
}
render() {
const provider = this.wizard.provider as LDAPProvider | undefined;
// prettier-ignore
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input
name="name"
value=${ifDefined(provider?.name)}
label=${msg("Name")}
required
help=${msg("Method's display Name.")}
></ak-text-input>
<ak-form-element-horizontal
label=${msg("Bind flow")}
?required=${true}
name="authorizationFlow"
>
<ak-tenanted-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authentication}
.currentFlow=${provider?.authorizationFlow}
.tenantFlow=${rootInterface()?.tenant?.flowAuthentication}
required
></ak-tenanted-flow-search>
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Search group")} name="searchGroup">
<ak-core-group-search
name="searchGroup"
group=${ifDefined(provider?.searchGroup ?? nothing)}
></ak-core-group-search>
<p class="pf-c-form__helper-text">${groupHelp}</p>
</ak-form-element-horizontal>
<ak-radio-input
label=${msg("Bind mode")}
name="bindMode"
.options=${bindModeOptions}
.value=${provider?.bindMode}
help=${msg("Configure how the outpost authenticates requests.")}
>
</ak-radio-input>
<ak-radio-input
label=${msg("Search mode")}
name="searchMode"
.options=${searchModeOptions}
.value=${provider?.searchMode}
help=${msg("Configure how the outpost queries the core authentik server's users.")}
>
</ak-radio-input>
<ak-switch-input
name="openInNewTab"
label=${msg("Code-based MFA Support")}
?checked=${provider?.mfaSupport}
help=${mfaSupportHelp}
>
</ak-switch-input>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-text-input
name="baseDn"
label=${msg("Base DN")}
required
value="${first(
provider?.baseDn,
"DC=ldap,DC=goauthentik,DC=io"
)}"
help=${msg(
"LDAP DN under which bind requests and search requests can be made."
)}
>
</ak-text-input>
<ak-form-element-horizontal label=${msg("Certificate")} name="certificate">
<ak-crypto-certificate-search
certificate=${ifDefined(provider?.certificate ?? nothing)}
name="certificate"
>
</ak-crypto-certificate-search>
<p class="pf-c-form__helper-text">
${msg(
"The certificate for the above configured Base DN. As a fallback, the provider uses a self-signed certificate."
)}
</p>
</ak-form-element-horizontal>
<ak-text-input
label=${msg("TLS Server name")}
required
name="tlsServerName"
value="${first(provider?.tlsServerName, "")}"
help=${msg(
"DNS name for which the above configured certificate should be used. The certificate cannot be detected based on the base DN, as the SSL/TLS negotiation happens before such data is exchanged."
)}
></ak-text-input>
<ak-number-input
label=${msg("UID start number")}
required
name="uidStartNumber"
value="${first(provider?.uidStartNumber, 2000)}"
help=${msg(
"The start for uidNumbers, this number is added to the user.Pk to make sure that the numbers aren't too low for POSIX users. Default is 2000 to ensure that we don't collide with local users uidNumber"
)}
></ak-number-input>
<ak-number-input
label=${msg("GID start number")}
required
name="gidStartNumber"
value="${first(provider?.gidStartNumber, 4000)}"
help=${msg(
"The start for gidNumbers, this number is added to a number generated from the group.Pk to make sure that the numbers aren't too low for POSIX groups. Default is 4000 to ensure that we don't collide with local groups or users primary groups gidNumber"
)}
></ak-number-input>
</div>
</ak-form-group>
</form>`;
}
}
export default ApplicationWizardApplicationDetails;

View File

@ -6,32 +6,14 @@ import "../ak-application-wizard-application-details";
import AkApplicationWizardApplicationDetails from "../ak-application-wizard-application-details";
import "../ak-application-wizard-authentication-method-choice";
import "../ak-application-wizard-context";
import "../ldap/ak-application-wizard-authentication-by-ldap";
import "./ak-application-context-display-for-test";
// prettier-ignore
const providerTypes = [
["LDAP Provider", "ldapprovider",
"Allow applications to authenticate against authentik's users using LDAP.",
],
["OAuth2/OpenID Provider", "oauth2provider",
"OAuth2 Provider for generic OAuth and OpenID Connect Applications.",
],
["Proxy Provider", "proxyprovider",
"Protect applications that don't support any of the other\n Protocols by using a Reverse-Proxy.",
],
["Radius Provider", "radiusprovider",
"Allow applications to authenticate against authentik's users using Radius.",
],
["SAML Provider", "samlprovider",
"SAML 2.0 Endpoint for applications which support SAML.",
],
["SCIM Provider", "scimprovider",
"SCIM 2.0 provider to create users and groups in external applications",
],
["SAML Provider from Metadata", "",
"Create a SAML Provider by importing its Metadata.",
],
].map(([name, model_name, description]) => ({ name, description, model_name }));
import {
dummyAuthenticationFlowsSearch,
dummyCoreGroupsSearch,
dummyCryptoCertsSearch,
dummyProviderTypesList,
} from "./samples";
const metadata: Meta<AkApplicationWizardApplicationDetails> = {
title: "Elements / Application Wizard / Page 1",
@ -47,7 +29,26 @@ const metadata: Meta<AkApplicationWizardApplicationDetails> = {
url: "/api/v3/providers/all/types/",
method: "GET",
status: 200,
response: providerTypes,
response: dummyProviderTypesList,
},
{
url: "/api/v3/core/groups/?ordering=name",
method: "GET",
status: 200,
response: dummyCoreGroupsSearch,
},
{
url: "/api/v3/crypto/certificatekeypairs/?has_key=true&include_details=false&ordering=name",
method: "GET",
status: 200,
response: dummyCryptoCertsSearch,
},
{
url: "/api/v3/flows/instances/?designation=authentication&ordering=slug",
method: "GET",
status: 200,
response: dummyAuthenticationFlowsSearch,
},
],
},
@ -73,7 +74,7 @@ export const PageOne = () => {
html`<ak-application-wizard-context>
<ak-application-wizard-application-details></ak-application-wizard-application-details>
<ak-application-context-display-for-test></ak-application-context-display-for-test>
</ak-application-wizard-context>`
</ak-application-wizard-context>`,
);
};
@ -82,6 +83,15 @@ export const PageTwo = () => {
html`<ak-application-wizard-context>
<ak-application-wizard-authentication-method-choice></ak-application-wizard-authentication-method-choice>
<ak-application-context-display-for-test></ak-application-context-display-for-test>
</ak-application-wizard-context>`
</ak-application-wizard-context>`,
);
};
export const PageThreeLdap = () => {
return container(
html`<ak-application-wizard-context>
<ak-application-wizard-authentication-by-ldap></ak-application-wizard-authentication-by-ldap>
<ak-application-context-display-for-test></ak-application-context-display-for-test>
</ak-application-wizard-context>`,
);
};

View File

@ -0,0 +1,147 @@
export const dummyCryptoCertsSearch = {
pagination: {
next: 0,
previous: 0,
count: 1,
current: 1,
total_pages: 1,
start_index: 1,
end_index: 1,
},
results: [
{
pk: "63efd1b8-6c39-4f65-8157-9a406cb37447",
name: "authentik Self-signed Certificate",
fingerprint_sha256: null,
fingerprint_sha1: null,
cert_expiry: null,
cert_subject: null,
private_key_available: true,
private_key_type: null,
certificate_download_url:
"/api/v3/crypto/certificatekeypairs/63efd1b8-6c39-4f65-8157-9a406cb37447/view_certificate/?download",
private_key_download_url:
"/api/v3/crypto/certificatekeypairs/63efd1b8-6c39-4f65-8157-9a406cb37447/view_private_key/?download",
managed: null,
},
],
};
export const dummyAuthenticationFlowsSearch = {
pagination: {
next: 0,
previous: 0,
count: 2,
current: 1,
total_pages: 1,
start_index: 1,
end_index: 2,
},
results: [
{
pk: "2594b1a0-f234-4965-8b93-a8631a55bd5c",
policybindingmodel_ptr_id: "0bc529a6-dcd0-4ba8-8fef-5702348832f9",
name: "Welcome to authentik!",
slug: "default-authentication-flow",
title: "Welcome to authentik!",
designation: "authentication",
background: "/static/dist/assets/images/flow_background.jpg",
stages: [
"bad9fbce-fb86-4ba4-8124-e7a1d8c147f3",
"1da1f272-a76e-4112-be95-f02421fca1d4",
"945cd956-6670-4dfa-ab3a-2a72dd3051a7",
"0fc1fc5c-b928-4d99-a892-9ae48de089f5",
],
policies: [],
cache_count: 0,
policy_engine_mode: "any",
compatibility_mode: false,
export_url: "/api/v3/flows/instances/default-authentication-flow/export/",
layout: "stacked",
denied_action: "message_continue",
authentication: "none",
},
{
pk: "3526dbd1-b50e-4553-bada-fbe7b3c2f660",
policybindingmodel_ptr_id: "cde67954-b78a-4fe9-830e-c2aba07a724a",
name: "Welcome to authentik!",
slug: "default-source-authentication",
title: "Welcome to authentik!",
designation: "authentication",
background: "/static/dist/assets/images/flow_background.jpg",
stages: ["3713b252-cee3-4acb-a02f-083f26459fff"],
policies: ["f42a4c7f-6586-4b14-9325-a832127ba295"],
cache_count: 0,
policy_engine_mode: "any",
compatibility_mode: false,
export_url: "/api/v3/flows/instances/default-source-authentication/export/",
layout: "stacked",
denied_action: "message_continue",
authentication: "require_unauthenticated",
},
],
};
export const dummyCoreGroupsSearch = {
pagination: {
next: 0,
previous: 0,
count: 1,
current: 1,
total_pages: 1,
start_index: 1,
end_index: 1,
},
results: [
{
pk: "67543d37-0ee2-4a4c-b020-9e735a8b5178",
num_pk: 13734,
name: "authentik Admins",
is_superuser: true,
parent: null,
users: [1],
attributes: {},
users_obj: [
{
pk: 1,
username: "akadmin",
name: "authentik Default Admin",
is_active: true,
last_login: "2023-07-03T16:08:11.196942Z",
email: "ken@goauthentik.io",
attributes: {
settings: {
locale: "en",
},
},
uid: "6dedc98b3fdd0f9afdc705e9d577d61127d89f1d91ea2f90f0b9a353615fb8f2",
},
],
},
],
};
// prettier-ignore
export const dummyProviderTypesList = [
["LDAP Provider", "ldapprovider",
"Allow applications to authenticate against authentik's users using LDAP.",
],
["OAuth2/OpenID Provider", "oauth2provider",
"OAuth2 Provider for generic OAuth and OpenID Connect Applications.",
],
["Proxy Provider", "proxyprovider",
"Protect applications that don't support any of the other\n Protocols by using a Reverse-Proxy.",
],
["Radius Provider", "radiusprovider",
"Allow applications to authenticate against authentik's users using Radius.",
],
["SAML Provider", "samlprovider",
"SAML 2.0 Endpoint for applications which support SAML.",
],
["SCIM Provider", "scimprovider",
"SCIM 2.0 provider to create users and groups in external applications",
],
["SAML Provider from Metadata", "",
"Create a SAML Provider by importing its Metadata.",
],
].map(([name, model_name, description]) => ({ name, description, model_name }));

View File

@ -0,0 +1,104 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { property, query } from "lit/decorators.js";
import { CoreApi, CoreGroupsListRequest, Group } from "@goauthentik/api";
async function fetchObjects(query?: string): Promise<Group[]> {
const args: CoreGroupsListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(args);
return groups.results;
}
const renderElement = (group: Group): string => group.name;
const renderValue = (group: Group | undefined): string | undefined => group?.pk;
/**
* Core Group Search
*
* @element ak-core-group-search
*
* A wrapper around SearchSelect for the 8 search of groups used throughout our code
* base. This is one of those "If it's not error-free, at least it's localized to
* one place" issues.
*
*/
@customElement("ak-core-group-search")
export class CoreGroupSearch extends CustomListenerElement(AKElement) {
/**
* The current group known to the caller.
*
* @attr
*/
@property({ type: String, reflect: true })
group?: string;
@query("ak-search-select")
search!: SearchSelect<Group>;
@property({ type: String })
name: string | null | undefined;
selectedGroup?: Group;
constructor() {
super();
this.selected = this.selected.bind(this);
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
this.addCustomListener("ak-change", this.handleSearchUpdate);
}
get value() {
return this.selectedGroup ? renderValue(this.selectedGroup) : undefined;
}
connectedCallback() {
super.connectedCallback();
const horizontalContainer = this.closest("ak-form-element-horizontal[name]");
if (!horizontalContainer) {
throw new Error("This search can only be used in a named ak-form-element-horizontal");
}
const name = horizontalContainer.getAttribute("name");
const myName = this.getAttribute("name");
if (name !== null && name !== myName) {
this.setAttribute("name", name);
}
}
handleSearchUpdate(ev: CustomEvent) {
ev.stopPropagation();
this.selectedGroup = ev.detail.value;
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
}
selected(group: Group) {
return this.group === group.pk;
}
render() {
return html`
<ak-search-select
.fetchObjects=${fetchObjects}
.renderElement=${renderElement}
.value=${renderValue}
.selected=${this.selected}
?blankable=${true}
>
</ak-search-select>
`;
}
}
export default CoreGroupSearch;

View File

@ -0,0 +1,129 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { property, query } from "lit/decorators.js";
import {
CertificateKeyPair,
CryptoApi,
CryptoCertificatekeypairsListRequest,
} from "@goauthentik/api";
const renderElement = (item: CertificateKeyPair): string => item.name;
const renderValue = (item: CertificateKeyPair | undefined): string | undefined => item?.pk;
/**
* Cryptographic Certificate Search
*
* @element ak-crypto-certificate-search
*
* A wrapper around SearchSelect for the many searches of cryptographic key-pairs used throughout our
* code base. This is another one of those "If it's not error-free, at least it's localized to one
* place" issues.
*
*/
@customElement("ak-crypto-certificate-search")
export class CryptoCertificateSearch extends CustomListenerElement(AKElement) {
@property({ type: String, reflect: true })
certificate?: string;
@query("ak-search-select")
search!: SearchSelect<CertificateKeyPair>;
@property({ type: String })
name: string | null | undefined;
/**
* Set to `true` if you want to find pairs that don't have a valid key. Of our 14 searches, 11
* require the key, 3 do not (as of 2023-08-01).
*
* @attr
*/
@property({ type: Boolean, attribute: "nokey" })
noKey = false;
/**
* Set this to true if, should there be only one certificate available, you want the system to
* use it by default.
*
* @attr
*/
@property({ type: Boolean, attribute: "singleton" })
singleton = false;
selectedKeypair?: CertificateKeyPair;
constructor() {
super();
this.selected = this.selected.bind(this);
this.fetchObjects = this.fetchObjects.bind(this);
this.handleSearchUpdate = this.handleSearchUpdate.bind(this);
this.addCustomListener("ak-change", this.handleSearchUpdate);
}
get value() {
return this.selectedKeypair ? renderValue(this.selectedKeypair) : undefined;
}
connectedCallback() {
super.connectedCallback();
const horizontalContainer = this.closest("ak-form-element-horizontal[name]");
if (!horizontalContainer) {
throw new Error("This search can only be used in a named ak-form-element-horizontal");
}
const name = horizontalContainer.getAttribute("name");
const myName = this.getAttribute("name");
if (name !== null && name !== myName) {
this.setAttribute("name", name);
}
}
handleSearchUpdate(ev: CustomEvent) {
ev.stopPropagation();
this.selectedKeypair = ev.detail.value;
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
}
async fetchObjects(query?: string): Promise<CertificateKeyPair[]> {
const args: CryptoCertificatekeypairsListRequest = {
ordering: "name",
hasKey: !this.noKey,
includeDetails: false,
};
if (query !== undefined) {
args.search = query;
}
const certificates = await new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList(
args,
);
return certificates.results;
}
selected(item: CertificateKeyPair, items: CertificateKeyPair[]) {
return (
(this.singleton && !this.certificate && items.length === 1) ||
(!!this.certificate && this.certificate === item.pk)
);
}
render() {
return html`
<ak-search-select
.fetchObjects=${this.fetchObjects}
.renderElement=${renderElement}
.value=${renderValue}
.selected=${this.selected}
?blankable=${true}
>
</ak-search-select>
`;
}
}
export default CryptoCertificateSearch;

View File

@ -79,6 +79,7 @@ export class FlowSearch<T extends Flow> extends CustomListenerElement(AKElement)
handleSearchUpdate(ev: CustomEvent) {
ev.stopPropagation();
this.selectedFlow = ev.detail.value;
this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true }));
}
async fetchObjects(query?: string): Promise<Flow[]> {

View File

@ -0,0 +1,76 @@
import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
type AkNumberArgs = {
// The name of the field, snake-to-camel'd if necessary.
name: string;
// The label of the field.
label: string;
value?: number;
required: boolean;
// The help message, shown at the bottom.
help?: string;
};
const akNumberDefaults = {
required: false,
};
export function akNumber(args: AkNumberArgs) {
const { name, label, value, required, help } = {
...akNumberDefaults,
...args,
};
return html`<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")
export class AkNumberInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
@property({ type: Number })
value = 0;
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
render() {
return akNumber({
name: this.name,
label: this.label,
value: this.value,
required: this.required,
help: this.help.trim() !== "" ? this.help : undefined,
});
}
}

View File

@ -1,82 +0,0 @@
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "./ak-wizard";
import AkWizard from "./ak-wizard";
const metadata: Meta<AkWizard> = {
title: "Components / Wizard",
component: "ak-wizard",
parameters: {
docs: {
description: {
component: "A Wizard for wrapping multiple steps",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
<ak-message-container></ak-message-container>
${testItem}
<p>Messages received from the button:</p>
<ul id="action-button-message-pad" style="margin-top: 1em"></ul>
</div>`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayMessage = (result: any) => {
const doc = new DOMParser().parseFromString(
`<li><i>Event</i>: ${
"result" in result.detail ? result.detail.result : result.detail.error
}</li>`,
"text/xml",
);
const target = document.querySelector("#action-button-message-pad");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
target!.appendChild(doc.firstChild!);
};
window.addEventListener("ak-button-success", displayMessage);
window.addEventListener("ak-button-failure", displayMessage);
export const ButtonWithSuccess = () => {
const run = () =>
new Promise<string>(function (resolve) {
setTimeout(function () {
resolve("Success!");
}, 3000);
});
return container(
html`<ak-action-button class="pf-m-primary" .apiRequest=${run}
>3 Seconds</ak-action-button
>`,
);
};
export const ButtonWithError = () => {
const run = () =>
new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("This is the error message."));
}, 3000);
});
return container(
html` <ak-action-button class="pf-m-secondary" .apiRequest=${run}
>3 Seconds</ak-action-button
>`,
);
};

View File

@ -1,99 +0,0 @@
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
import { msg } from "@lit/localize";
import { property } from "@lit/reactive-element/decorators/property.js";
import { state } from "@lit/reactive-element/decorators/state.js";
import { html, nothing } from "lit";
import type { TemplateResult } from "lit";
/**
* @class AkWizard
*
* @element ak-wizard
*
* The ak-wizard element exists to guide users through a complex task by dividing it into sections
* and granting them successive access to future sections. Our wizard has four "zones": The header,
* the breadcrumb toolbar, the navigation controls, and the content of the panel.
*
*/
type WizardStep = {
name: string;
constructor: () => TemplateResult;
};
export class AkWizard extends ModalButton {
@property({ type: Boolean })
required = false;
@property()
wizardtitle?: string;
@property()
description?: string;
constructor() {
super();
this.handleClose = this.handleClose.bind(this);
}
handleClose() {
this.open = false;
}
renderModalInner() {
return html`<div class="pf-c-wizard">
${this.renderWizardHeader()}
<div class="pf-c-wizard__outer-wrap">
<div class="pf-c-wizard__inner-wrap">${this.renderWizardNavigation()}</div>
</div>
</div> `;
}
renderWizardHeader() {
const renderCancelButton = () =>
html`<button
class="pf-c-button pf-m-plain pf-c-wizard__close"
type="button"
aria-label="${msg("Close")}"
@click=${this.handleClose}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`;
return html`<div class="pf-c-wizard__header">
${this.required ? nothing : renderCancelButton()}
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.wizardtitle}</h1>
<p class="pf-c-wizard__description">${this.description}</p>
</div>`;
}
renderWizardNavigation() {
const currentIdx = this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0;
const renderNavStep = (step: string, idx: number) => {
return html`
<li class="pf-c-wizard__nav-item">
<button
class="pf-c-wizard__nav-link ${idx === currentIdx ? "pf-m-current" : ""}"
?disabled=${currentIdx < idx}
@click=${() => {
const stepEl = this.querySelector<WizardPage>(`[slot=${step}]`);
if (stepEl) {
this.currentStep = stepEl;
}
}}
>
${this.querySelector<WizardPage>(`[slot=${step}]`)?.sidebarLabel()}
</button>
</li>
`;
};
return html` <nav class="pf-c-wizard__nav">
<ol class="pf-c-wizard__nav-list">
${map(this.steps, renderNavStep)}
</ol>
</nav>`;
}
}

View File

@ -1,5 +1,6 @@
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils";
import { adaptCSS } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { PreventFormSubmit } from "@goauthentik/elements/forms/Form";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
@ -75,7 +76,7 @@ export class SearchSelect<T> extends CustomEmitterElement(AKElement) {
constructor() {
super();
if (!document.adoptedStyleSheets.includes(PFDropdown)) {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, PFDropdown];
document.adoptedStyleSheets = adaptCSS([...document.adoptedStyleSheets, PFDropdown]);
}
this.dropdownContainer = document.createElement("div");
this.observer = new IntersectionObserver(() => {