web: package up horizontal elements into their own components (#7053)

* web: laying the groundwork for future expansion

This commit is a hodge-podge of updates and changes to the web.  Functional changes:

- Makefile: Fixed a bug in the `help` section that prevented the WIDTH from being accurately
  calculated if `help` was included rather than in-lined.

- ESLint: Modified the "unused vars" rule so that variables starting with an underline are not
  considered by the rule.  This allows for elided variables in event handlers.  It's not a perfect
  solution-- a better one would be to use Typescript's function-specialization typing, but there are
  too many places where we elide or ignore some variables in a function's usage that switching over
  to specialization would be a huge lift.

- locale: It turns out, lit-locale does its own context management.  We don't need to have a context
  at all in this space, and that's one less listener we need to attach t othe DOM.

- ModalButton: A small thing, but using `nothing` instead of "html``" allows lit better control over
  rendering and reduces the number of actual renders of the page.

- FormGroup: Provided a means to modify the aria-label, rather than stick with the just the word
  "Details."  Specializing this field will both help users of screen readers in the future, and will
  allow test suites to find specific form groups now.

- RadioButton: provide a more consistent interface to the RadioButton.  First, we dispatch the
  events to the outside world, and we set the value locally so that the current `Form.ts` continues
  to behave as expected.  We also prevent the "button lost value" event from propagating; this
  presents a unified select-like interface to users of the RadioButtonGroup.  The current value
  semantics are preserved; other clients of the RadioButton do not see a change in behavior.

- EventEmitter: If the custom event detail is *not* an object, do not use the object-like semantics
  for forwarding it; just send it as-is.

- Comments: In the course of laying the groundwork for the application wizard, I throw a LOT of
  comments into the code, describing APIs, interfaces, class and function signatures, to better
  document the behavior inside and as signposts for future work.

* web: permit arrays to be sent in custom events without interpolation.

* actually use assignValue or rather serializeFieldRecursive

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: package up horizontal elements into their own components.

This commit introduces a number of "components."  Jens has this idiom:

```
   <ak-form-element-horizontal label=${msg("Name")} name="name" ?required=${true}>
       <input
           type="text"
           value="${ifDefined(this.instance?.name)}"
           class="pf-c-form-control"
           required
       />
   </ak-form-element-horizontal>
```

It's a very web-oriented idiom in that it's built out of two building blocks, the "element-horizontal" descriptor,
and the input object itself.  This idiom is repeated a lot throughout the code.  As an alternative, let's wrap
everything into an inheritable interface:

```
  <ak-text-input
      name="name"
      label=${msg("Name")}
      value="${ifDefined(this.instance?.name)}
      required
  >
  </ak-text-input>
```

This preserves all the information of the above, makes it much clearer what kind of interaction we're having
(sometimes the `type=` information in an input is lost or easily missed), and while it does require you know
that there are provided components rather than the pair of layout-behavior as in the original it also gives
the developer more precision over the look and feel of the components.

*Right now* these components are placed into the LightDOM, as they are in the existing source code, because
the Form handler has a need to be able to "peer into" the "element-horizontal" component to find the values
of the input objects.  In a future revision I hope to place the burden of type/value processing onto the
input objects themselves such that the form handler will need only look for the `.value` of the associated
input control.

Other fixes:

- update the FlowSearch() such that it actually emits an input event when its value changes.
- Disable the storybook shortcuts; on Chrome, at least, they get confused with simple inputs
- Fix an issue with precommit to not scan any Python with ESLint!  :-)

* web: provide storybook stories for the components

This commit provides storybook stories for the ak-horizontal-element wrappers.  A few
bugs were found along the way, including one rather nasty one from Radio where we
were still getting the "set/unset" pair in the wrong order, so I had to knuckle down
and fix the event handler properly.

* web: test oauth2 provider "guinea pig" for new components

I used the Oauth2 provider page as my experiment in seeing if the
horizontal-element wrappers could be used instead of the raw wrappers
themselves, and I wanted to make sure a test existed that asserts
that filling out THAT form in the ProvidersList and ProvidersForm
didn't break anything.

This commit updates the WDIO tests to do just that; the test is
simple, but it does exercise the `name` field of the Provider,
something not needed in the Wizard because it's set automatically
based on the Application name, and it even asserts that the new
Provider exists in the list of available Providers when it's done.

* web: making sure ESlint and Prettier are happy

* "fix" lint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Ken Sternberg 2023-10-04 13:07:52 -07:00 committed by GitHub
parent fb1768270e
commit 6792bf8876
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1434 additions and 295 deletions

View File

@ -78,7 +78,7 @@ install: web-install website-install ## Install all requires dependencies for `
poetry install poetry install
dev-drop-db: dev-drop-db:
echo dropdb -U ${pg_user} -h ${pg_host} ${pg_name} dropdb -U ${pg_user} -h ${pg_host} ${pg_name}
# Also remove the test-db if it exists # Also remove the test-db if it exists
dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true dropdb -U ${pg_user} -h ${pg_host} test_${pg_name} || true
echo redis-cli -n 0 flushall echo redis-cli -n 0 flushall

View File

@ -5,7 +5,7 @@ const CLICK_TIME_DELAY = 250;
export default class AdminPage extends Page { export default class AdminPage extends Page {
public get pageHeader() { public get pageHeader() {
return $(">>>ak-page-header h1"); return $('>>>ak-page-header slot[name="header"]');
} }
async openApplicationsListPage() { async openApplicationsListPage() {

View File

@ -1,21 +0,0 @@
import AdminPage from "./admin.page.js";
import { $ } from "@wdio/globals";
/**
* sub page containing specific selectors and methods for a specific page
*/
class ApplicationsListPage extends AdminPage {
/**
* define selectors using getter methods
*/
get startWizardButton() {
return $('>>>ak-wizard-frame button[slot="trigger"]');
}
async open() {
return await super.open("if/admin/#/core/applications");
}
}
export default new ApplicationsListPage();

View File

@ -0,0 +1,18 @@
import Page from "../page.js";
import { $ } from "@wdio/globals";
export class OauthForm extends Page {
async setAuthorizationFlow(selector: string) {
await this.searchSelect(
'>>>ak-flow-search[name="authorizationFlow"] input[type="text"]',
"authorizationFlow",
`button*=${selector}`,
);
}
get providerName() {
return $('>>>ak-form-element-horizontal[name="name"] input');
}
}
export default new OauthForm();

View File

@ -38,4 +38,9 @@ export default class Page {
const target = searchBlock.$(buttonSelector); const target = searchBlock.$(buttonSelector);
return await target.click(); return await target.click();
} }
public async logout() {
await browser.url("http://localhost:9000/flows/-/default/invalidation/");
return await this.pause();
}
} }

View File

@ -0,0 +1,53 @@
import AdminPage from "./admin.page.js";
import OauthForm from "./forms/oauth.form.js";
import { $ } from "@wdio/globals";
/**
* sub page containing specific selectors and methods for a specific page
*/
class ProviderWizardView extends AdminPage {
/**
* define selectors using getter methods
*/
oauth = OauthForm;
get wizardTitle() {
return $(">>>ak-wizard .pf-c-wizard__header h1.pf-c-title");
}
get providerList() {
return $(">>>ak-provider-wizard-initial");
}
get nextButton() {
return $(">>>ak-wizard footer button.pf-m-primary");
}
async getProviderType(type: string) {
return await this.providerList.$(`>>>input[value="${type}"]`);
}
get successMessage() {
return $('>>>[data-commit-state="success"]');
}
}
type Pair = [string, string];
// Define a getter for each provider type in the radio button collection.
const providerValues: Pair[] = [["oauth2", "oauth2Provider"]];
providerValues.forEach(([value, name]: Pair) => {
Object.defineProperties(ProviderWizardView.prototype, {
[name]: {
get: function () {
return this.providerList.$(`>>>input[id="ak-provider-${value}-form"]`);
},
},
});
});
export default new ProviderWizardView();

View File

@ -0,0 +1,47 @@
import AdminPage from "./admin.page.js";
import { $, browser } from "@wdio/globals";
import { Key } from "webdriverio";
/**
* sub page containing specific selectors and methods for a specific page
*/
class ApplicationsListPage extends AdminPage {
/**
* define selectors using getter methods
*/
get startWizardButton() {
return $('>>>ak-wizard button[slot="trigger"]');
}
get searchInput() {
return $('>>>ak-table-search input[name="search"]');
}
searchButton() {
return $('>>>ak-table-search button[type="submit"]');
}
// Sufficiently esoteric to justify having its own method
async clickSearchButton() {
await browser.execute(
function (searchButton: unknown) {
(searchButton as HTMLButtonElement).focus();
},
await $('>>>ak-table-search button[type="submit"]'),
);
return await browser.action("key").down(Key.Enter).up(Key.Enter).perform();
}
// Only use after a very precise search. :-)
async findProviderRow() {
return await $(">>>ak-provider-list td a");
}
async open() {
return await super.open("if/admin/#/core/providers");
}
}
export default new ApplicationsListPage();

View File

@ -0,0 +1,46 @@
import ProviderWizardView from "../pageobjects/provider-wizard.page.js";
import ProvidersListPage from "../pageobjects/providers-list.page.js";
import { randomId } from "../utils/index.js";
import { login } from "../utils/login.js";
import { expect } from "@wdio/globals";
async function reachTheProvider() {
await ProvidersListPage.logout();
await login();
await ProvidersListPage.open();
await expect(await ProvidersListPage.pageHeader).toHaveText("Providers");
await ProvidersListPage.startWizardButton.click();
await ProviderWizardView.wizardTitle.waitForDisplayed();
await expect(await ProviderWizardView.wizardTitle).toHaveText("New provider");
}
describe("Configure Oauth2 Providers", () => {
it("Should configure a simple LDAP Application", async () => {
const newProviderName = `New OAuth2 Provider - ${randomId()}`;
await reachTheProvider();
await ProviderWizardView.providerList.waitForDisplayed();
await ProviderWizardView.oauth2Provider.scrollIntoView();
await ProviderWizardView.oauth2Provider.click();
await ProviderWizardView.nextButton.click();
await ProviderWizardView.pause();
await ProviderWizardView.oauth.providerName.setValue(newProviderName);
await ProviderWizardView.oauth.setAuthorizationFlow(
"default-provider-authorization-explicit-consent",
);
await ProviderWizardView.nextButton.click();
await ProviderWizardView.pause();
await ProvidersListPage.searchInput.setValue(newProviderName);
await ProvidersListPage.clickSearchButton();
await ProvidersListPage.pause();
const newProvider = await ProvidersListPage.findProviderRow(newProviderName);
await newProvider.waitForDisplayed();
expect(newProvider).toExist();
expect(await newProvider.getText()).toHaveText(newProviderName);
});
});

View File

@ -5,4 +5,5 @@ import authentikTheme from "./authentikTheme";
addons.setConfig({ addons.setConfig({
theme: authentikTheme, theme: authentikTheme,
enableShortcuts: false,
}); });

View File

@ -15,7 +15,7 @@
"build-proxy": "run-s build-locales rollup:build-proxy", "build-proxy": "run-s build-locales rollup:build-proxy",
"watch": "run-s build-locales rollup:watch", "watch": "run-s build-locales rollup:watch",
"lint": "eslint . --max-warnings 0 --fix", "lint": "eslint . --max-warnings 0 --fix",
"lint:precommit": "eslint --max-warnings 0 --config ./.eslintrc.precommit.json $(git status --porcelain | grep '^[M?][M?]' | cut -c8- | grep -E '\\.(ts|js|tsx|jsx)$') ", "lint:precommit": "eslint --max-warnings 0 --config ./.eslintrc.precommit.json $(git status --porcelain . | grep '^[M?][M?]' | cut -c8- | grep -E '\\.(ts|js|tsx|jsx)$') ",
"lint:spelling": "codespell -D - -D ../.github/codespell-dictionary.txt -I ../.github/codespell-words.txt -S './src/locales/**' ./src -s", "lint:spelling": "codespell -D - -D ../.github/codespell-dictionary.txt -I ../.github/codespell-words.txt -S './src/locales/**' ./src -s",
"lit-analyse": "lit-analyzer src", "lit-analyse": "lit-analyzer src",
"precommit": "run-s tsc lit-analyse lint:precommit lint:spelling prettier", "precommit": "run-s tsc lit-analyse lint:precommit lint:spelling prettier",

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

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

View File

@ -92,7 +92,7 @@ export class PolicyBindingForm extends ModelForm<PolicyBinding, string> {
data.group = null; data.group = null;
break; break;
} }
console.log(data);
if (this.instance?.pk) { if (this.instance?.pk) {
return new PoliciesApi(DEFAULT_CONFIG).policiesBindingsUpdate({ return new PoliciesApi(DEFAULT_CONFIG).policiesBindingsUpdate({
policyBindingUuid: this.instance.pk, policyBindingUuid: this.instance.pk,

View File

@ -2,6 +2,9 @@ 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";
@ -27,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;
@ -77,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">
@ -100,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">
@ -117,96 +206,50 @@ 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">
<!-- NOTE: 'null' cast to 'undefined' on signingKey to satisfy Lit requirements -->
<ak-crypto-certificate-search <ak-crypto-certificate-search
certificate=${ifDefined(this.instance?.signingKey || "")} certificate=${ifDefined(this.instance?.signingKey ?? undefined)}
?singleton=${!this.instance} singleton
></ak-crypto-certificate-search> ></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>
@ -216,69 +259,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)}
@ -297,104 +324,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>
@ -407,7 +365,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

@ -10,8 +10,7 @@ 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 { CSSResult } from "lit"; import { CSSResult, TemplateResult, html } from "lit";
import { TemplateResult, html } 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";
@ -92,36 +91,25 @@ export class ProxyProviderFormPage extends ModelForm<ProxyProvider, number> {
} }
renderHttpBasic(): TemplateResult { renderHttpBasic(): TemplateResult {
return html`<ak-form-element-horizontal return html`<ak-text-input
label=${msg("HTTP-Basic Username Key")}
name="basicAuthUserAttribute" name="basicAuthUserAttribute"
> label=${msg("HTTP-Basic Username Key")}
<input
type="text"
value="${ifDefined(this.instance?.basicAuthUserAttribute)}" value="${ifDefined(this.instance?.basicAuthUserAttribute)}"
class="pf-c-form-control" help=${msg(
/>
<p class="pf-c-form__helper-text">
${msg(
"User/Group Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.", "User/Group Attribute used for the user part of the HTTP-Basic Header. If not set, the user's Email address is used.",
)} )}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("HTTP-Basic Password Key")}
name="basicAuthPasswordAttribute"
> >
<input </ak-text-input>
type="text"
<ak-text-input
name="basicAuthPasswordAttribute"
label=${msg("HTTP-Basic Password Key")}
value="${ifDefined(this.instance?.basicAuthPasswordAttribute)}" value="${ifDefined(this.instance?.basicAuthPasswordAttribute)}"
class="pf-c-form-control" help=${msg(
/>
<p class="pf-c-form__helper-text">
${msg(
"User/Group Attribute used for the password part of the HTTP-Basic Header.", "User/Group Attribute used for the password part of the HTTP-Basic Header.",
)} )}
</p> >
</ak-form-element-horizontal>`; </ak-text-input>`;
} }
renderModeSelector(): TemplateResult { renderModeSelector(): TemplateResult {

View File

@ -13,7 +13,7 @@ import { Application } from "@goauthentik/api";
@customElement("ak-app-icon") @customElement("ak-app-icon")
export class AppIcon extends AKElement { export class AppIcon extends AKElement {
@property({ attribute: false }) @property({ type: Object, attribute: false })
app?: Application; app?: Application;
@property() @property()

View File

@ -0,0 +1,66 @@
import { AKElement } from "@goauthentik/elements/Base";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@customElement("ak-file-input")
export class AkFileInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
/*
* The message to show next to the "current icon".
*
* @attr
*/
@property({ type: String })
current = msg("Currently set to:");
@property({ type: String })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
@query('input[type="file"]')
input!: HTMLInputElement;
get files() {
return this.input.files;
}
render() {
const currentMsg =
this.value && this.current
? html` <p class="pf-c-form__helper-text">${this.current} ${this.value}</p> `
: nothing;
return html`<ak-form-element-horizontal
?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

@ -0,0 +1,52 @@
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";
@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, reflect: true })
value = 0;
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
render() {
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
name=${this.name}
>
<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> `;
}
}
export default AkNumberInput;

View File

@ -0,0 +1,61 @@
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";
@customElement("ak-radio-input")
export class AkRadioInput<T> extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
@property({ type: String })
help = "";
@property({ type: Boolean })
required = false;
@property({ type: Object })
value!: T;
@property({ type: Array })
options: RadioOption<T>[] = [];
handleInput(ev: CustomEvent) {
this.value = ev.detail.value;
}
render() {
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
name=${this.name}
>
<ak-radio
.options=${this.options}
.value=${this.value}
@input=${this.handleInput}
></ak-radio>
${this.help.trim()
? html`<p class="pf-c-form__helper-radio">${this.help}</p>`
: nothing}
</ak-form-element-horizontal> `;
}
}
export default AkRadioInput;

View File

@ -0,0 +1,171 @@
import { convertToSlug } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@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 = "";
@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 = "";
origin?: HTMLInputElement | null;
@query("input")
input!: HTMLInputElement;
touched: boolean = false;
constructor() {
super();
this.slugify = this.slugify.bind(this);
this.handleTouch = this.handleTouch.bind(this);
}
firstUpdated() {
this.input.addEventListener("input", this.handleTouch);
}
renderHelp() {
return [
this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : 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) {
this.input.value = convertToSlug(this.input.value);
this.value = this.input.value;
if (this.origin && this.origin.value === "" && this.input.value === "") {
this.touched = false;
return;
}
if (ev && ev.target && ev.target instanceof HTMLInputElement) {
this.touched = true;
}
}
slugify(ev: Event) {
if (!(ev && ev.target && ev.target instanceof HTMLInputElement)) {
return;
}
// Reset 'touched' status if the slug & target have been reset
if (ev.target.value === "" && this.input.value === "") {
this.touched = false;
}
// Don't proceed if the user has hand-modified the slug
if (this.touched) {
return;
}
// A very primitive heuristic: if the previous iteration of the slug and the current
// iteration are *similar enough*, set the input value. "Similar enough" here is defined as
// "any event which adds or removes a character but leaves the rest of the slug looking like
// the previous iteration, set it to the current iteration."
const newSlug = convertToSlug(ev.target.value);
const oldSlug = this.input.value;
const [shorter, longer] =
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];
if (longer.substring(0, shorter.length) !== shorter) {
return;
}
// The browser, as a security measure, sets the originating HTML object to be the
// target; developers cannot change it. In order to provide a meaningful value
// to listeners, both the name and value of the host must match those of the target
// input. The name is already handled since it's both required and automatically
// forwarded to our templated input, but the value must also be set.
this.value = this.input.value = newSlug;
this.dispatchEvent(
new Event("input", {
bubbles: true,
cancelable: true,
}),
);
}
connectedCallback() {
super.connectedCallback();
// Set up listener on source element, so we can slugify the content.
setTimeout(() => {
if (this.source) {
const rootNode = this.getRootNode();
if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
this.origin = rootNode.querySelector(this.source);
}
if (this.origin) {
this.origin.addEventListener("input", this.slugify);
}
}
}, 0);
}
disconnectedCallback() {
if (this.origin) {
this.origin.removeEventListener("input", this.slugify);
}
super.disconnectedCallback();
}
render() {
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
?hidden=${this.hidden}
name=${this.name}
>
<input
type="text"
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${this.required}
/>
${this.renderHelp()}
</ak-form-element-horizontal> `;
}
}
export default AkSlugInput;

View File

@ -0,0 +1,55 @@
import { AKElement } from "@goauthentik/elements/Base";
import { html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
@customElement("ak-switch-input")
export class AkSwitchInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
@property({ type: Boolean })
checked: boolean = false;
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
@query("input.pf-c-switch__input[type=checkbox]")
checkbox!: HTMLInputElement;
render() {
const doCheck = this.checked ? this.checked : undefined;
return html` <ak-form-element-horizontal name=${this.name} ?required=${this.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">${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

@ -0,0 +1,66 @@
import { AKElement } from "@goauthentik/elements/Base";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-text-input")
export class AkTextInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
@property({ type: String, 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`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing,
this.bighelp ? this.bighelp : nothing,
];
}
render() {
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
?hidden=${this.hidden}
name=${this.name}
>
<input
type="text"
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${this.required}
/>
${this.renderHelp()}
</ak-form-element-horizontal> `;
}
}
export default AkTextInput;

View File

@ -0,0 +1,58 @@
import { AKElement } from "@goauthentik/elements/Base";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-textarea-input")
export class AkTextareaInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
@property({ type: String })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
@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() {
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
name=${this.name}
>
<textarea class="pf-c-form-control" ?required=${this.required} name=${this.name}>
${this.value !== undefined ? this.value : ""}</textarea
>
${this.renderHelp()}
</ak-form-element-horizontal> `;
}
}
export default AkTextareaInput;

View File

@ -0,0 +1,38 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../ak-app-icon";
import AkAppIcon from "../ak-app-icon";
const metadata: Meta<AkAppIcon> = {
title: "Components / App Icon",
component: "ak-app-icon",
parameters: {
docs: {
description: {
component: "A small card displaying an application icon",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #000; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
</div>`;
export const AppIcon = () => {
return container(html`<ak-app-icon .app=${{ name: "Demo app" }} size="pf-m-md"></ak-app-icon>`);
};

View File

@ -0,0 +1,55 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../ak-number-input";
import AkNumberInput from "../ak-number-input";
const metadata: Meta<AkNumberInput> = {
title: "Components / Number Input",
component: "ak-number-input",
parameters: {
docs: {
description: {
component: "A stylized value control for number input",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #000; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="number-message-pad" style="color: #fff; margin-top: 1em"></ul>
</div>`;
export const NumberInput = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById(
"number-message-pad",
)!.innerText = `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`;
};
return container(
html`<ak-number-input
@input=${displayChange}
label="Test Number Input"
name="ak-test-number-input"
help="This is where you would read the help messages"
></ak-number-input>`,
);
};

View File

@ -0,0 +1,67 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../ak-radio-input";
import AkRadioInput from "../ak-radio-input";
const metadata: Meta<AkRadioInput<Record<string, number>>> = {
title: "Components / Radio Input",
component: "ak-radio-input",
parameters: {
docs: {
description: {
component: "A stylized value control for radio buttons",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="radio-message-pad" style="margin-top: 1em"></ul>
</div>`;
const testOptions = [
{ label: "Option One", description: html`This is option one.`, value: { funky: 1 } },
{ label: "Option Two", description: html`This is option two.`, value: { invalid: 2 } },
{ label: "Option Three", description: html`This is option three.`, value: { weird: 3 } },
];
export const RadioInput = () => {
const result = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById("radio-message-pad")!.innerText = `Value selected: ${JSON.stringify(
ev.target.value,
null,
2,
)}`;
};
return container(
html`<ak-radio-input
@input=${displayChange}
label="Test Radio Button"
name="ak-test-radio-input"
help="This is where you would read the help messages"
.options=${testOptions}
></ak-radio-input>
<div>${result}</div>`,
);
};

View File

@ -0,0 +1,64 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../ak-slug-input";
import AkSlugInput from "../ak-slug-input";
import "../ak-text-input";
const metadata: Meta<AkSlugInput> = {
title: "Components / Slug Input",
component: "ak-slug-input",
parameters: {
docs: {
description: {
component: "A stylized value control for slug input",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #000; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="text-message-pad" style="color: #fff; margin-top: 1em"></ul>
</div>`;
export const SlugInput = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById("text-message-pad")!.innerText = `Value selected: ${JSON.stringify(
ev.target.value,
null,
2,
)}`;
};
return container(
html`<ak-text-input
label="Test Text Input"
name="ak-test-text-input"
help="Type your input here"
></ak-text-input>
<ak-slug-input
@input=${displayChange}
source="ak-text-input[name=ak-test-text-input]"
label="Test Text Input"
name="ak-test-text-input"
help="Here should be the slugified version"
></ak-slug-input> `,
);
};

View File

@ -0,0 +1,63 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
// Necessary because we're NOT supplying the CSS for the interiors
// in our "light" dom.
import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css";
import "../ak-switch-input";
import AkSwitchInput from "../ak-switch-input";
const metadata: Meta<AkSwitchInput> = {
title: "Components / Switch Input",
component: "ak-switch-input",
parameters: {
docs: {
description: {
component: "A stylized value control for a switch-like toggle",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #fff; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
${PFSwitch};
</style>
${testItem}
<ul id="switch-message-pad" style="margin-top: 1em"></ul>
</div>`;
export const SwitchInput = () => {
const result = "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById(
"switch-message-pad",
)!.innerText = `Value selected: ${JSON.stringify(ev.target.checked, null, 2)}`;
};
return container(
html`<ak-switch-input
@input=${displayChange}
name="ak-test-switch-input"
label="Test Switch Toggle"
help="This is where you would read the help messages"
></ak-switch-input>
<div>${result}</div>`,
);
};

View File

@ -0,0 +1,57 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../ak-text-input";
import AkTextInput from "../ak-text-input";
const metadata: Meta<AkTextInput> = {
title: "Components / Text Input",
component: "ak-text-input",
parameters: {
docs: {
description: {
component: "A stylized value control for text input",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #000; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="text-message-pad" style="color: #fff; margin-top: 1em"></ul>
</div>`;
export const TextInput = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById("text-message-pad")!.innerText = `Value selected: ${JSON.stringify(
ev.target.value,
null,
2,
)}`;
};
return container(
html`<ak-text-input
@input=${displayChange}
label="Test Text Input"
name="ak-test-text-input"
help="This is where you would read the help messages"
></ak-text-input>`,
);
};

View File

@ -0,0 +1,55 @@
import "@goauthentik/elements/messages/MessageContainer";
import { Meta } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import "../ak-textarea-input";
import AkTextareaInput from "../ak-textarea-input";
const metadata: Meta<AkTextareaInput> = {
title: "Components / Textarea Input",
component: "ak-textarea-input",
parameters: {
docs: {
description: {
component: "A stylized value control for textarea input",
},
},
},
};
export default metadata;
const container = (testItem: TemplateResult) =>
html` <div style="background: #000; padding: 2em">
<style>
li {
display: block;
}
p {
margin-top: 1em;
}
</style>
${testItem}
<ul id="textarea-message-pad" style="color: #fff; margin-top: 1em"></ul>
</div>`;
export const TextareaInput = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => {
document.getElementById(
"textarea-message-pad",
)!.innerText = `Value selected: ${JSON.stringify(ev.target.value, null, 2)}`;
};
return container(
html`<ak-textarea-input
@input=${displayChange}
label="Test Textarea Input"
name="ak-test-textarea-input"
help="This is where you would read the help messages"
></ak-textarea-input>`,
);
};

View File

@ -44,7 +44,6 @@ const container = (testItem: TemplateResult) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayMessage = (result: any) => { const displayMessage = (result: any) => {
console.log(result);
const doc = new DOMParser().parseFromString( const doc = new DOMParser().parseFromString(
`<li><p><i>Event</i>: ${ `<li><p><i>Event</i>: ${
"result" in result.detail ? result.detail.result.key : result.detail.error "result" in result.detail ? result.detail.result.key : result.detail.error

View File

@ -9,6 +9,8 @@ import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css"; import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { randomId } from "../utils/randomId";
export interface RadioOption<T> { export interface RadioOption<T> {
label: string; label: string;
description?: TemplateResult; description?: TemplateResult;
@ -27,6 +29,8 @@ export class Radio<T> extends CustomEmitterElement(AKElement) {
@property({ attribute: false }) @property({ attribute: false })
value?: T; value?: T;
internalId: string;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
PFBase, PFBase,
@ -50,6 +54,7 @@ export class Radio<T> extends CustomEmitterElement(AKElement) {
super(); super();
this.renderRadio = this.renderRadio.bind(this); this.renderRadio = this.renderRadio.bind(this);
this.buildChangeHandler = this.buildChangeHandler.bind(this); this.buildChangeHandler = this.buildChangeHandler.bind(this);
this.internalId = this.name || `radio-${randomId(8)}`;
} }
// Set the value if it's not set already. Property changes inside the `willUpdate()` method do // Set the value if it's not set already. Property changes inside the `willUpdate()` method do
@ -72,15 +77,14 @@ export class Radio<T> extends CustomEmitterElement(AKElement) {
// This is a controlled input. Stop the native event from escaping or affecting the // This is a controlled input. Stop the native event from escaping or affecting the
// value. We'll do that ourselves. // value. We'll do that ourselves.
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault();
this.value = option.value; this.value = option.value;
this.dispatchCustomEvent("change", option.value); this.dispatchCustomEvent("change", { value: option.value });
this.dispatchCustomEvent("input", option.value); this.dispatchCustomEvent("input", { value: option.value });
}; };
} }
renderRadio(option: RadioOption<T>) { renderRadio(option: RadioOption<T>, index: number) {
const elId = `${this.name}-${option.value}`; const elId = `${this.internalId}-${index}`;
const handler = this.buildChangeHandler(option); const handler = this.buildChangeHandler(option);
return html`<div class="pf-c-radio" @click=${handler}> return html`<div class="pf-c-radio" @click=${handler}>
<input <input

View File

@ -0,0 +1,8 @@
export function randomId(length = 8) {
let dt = new Date().getTime();
return "x".repeat(length).replace(/x/g, (c) => {
const r = (dt + Math.random() * 16) % 16 | 0;
dt = Math.floor(dt / 16);
return (c == "x" ? r : (r & 0x3) | 0x8).toString(16);
});
}