Compare commits

...

1 Commits

Author SHA1 Message Date
Teffen Ellis
0b9fe8267f wip: Flesh out. 2025-09-26 02:13:22 +02:00
30 changed files with 802 additions and 475 deletions

View File

@@ -1,7 +1,7 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import type { LocatorContext } from "#e2e/selectors/types";
import { expect, Page } from "@playwright/test";
import { expect, Locator, Page } from "@playwright/test";
export class FormFixture extends PageFixture {
static fixtureName = "Form";
@@ -18,22 +18,62 @@ export class FormFixture extends PageFixture {
* @param fieldName The name of the form element.
* @param value the value to set.
*/
public fill = async (
fieldName: string,
value: string,
parent: LocatorContext = this.page,
): Promise<void> => {
const control = parent
.getByRole("textbox", {
name: fieldName,
public findTextualInput = async (
fieldName: string | RegExp,
context: LocatorContext = this.page,
) => {
const control = context
.getByLabel(fieldName, { exact: true })
.filter({
hasNot: context.getByRole("presentation"),
})
.or(
parent.getByRole("spinbutton", {
context.getByRole("textbox", {
name: fieldName,
}),
)
.or(
context.getByRole("spinbutton", {
name: fieldName,
}),
);
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
const role = await control.getAttribute("role");
let textbox: Locator;
if (role === "combobox") {
// Comboboxes, such as our Query Language input need additional handling...
const textbox = control.getByRole("textbox");
return textbox;
} else {
textbox = control;
}
await expect(textbox, `Field (${fieldName}) should be visible`).toBeVisible();
return textbox;
};
/**
* Set the value of a text input.
*
* @param target The name of the form element.
* @param value the value to set.
*/
public fill = async (
target: string | RegExp | Locator,
value: string,
context: LocatorContext = this.page,
): Promise<void> => {
let control: Locator;
if (typeof target === "string" || target instanceof RegExp) {
control = await this.findTextualInput(target, context);
} else {
control = target;
}
await control.fill(value);
};

View File

@@ -18,12 +18,14 @@ export class PointerFixture extends PageFixture {
public static fixtureName = "Pointer";
public click = (
name: string,
name: string | RegExp,
optionsOrRole?: ARIAOptions | ARIARole,
context: LocatorContext = this.page,
): Promise<void> => {
if (typeof optionsOrRole === "string") {
return context.getByRole(optionsOrRole, { name }).click();
return context.getByRole(optionsOrRole, { name }).click({
force: true,
});
}
const options = {
@@ -36,7 +38,9 @@ export class PointerFixture extends PageFixture {
// ---
.getByRole("button", options)
.or(context.getByRole("link", options))
.click()
.click({
force: true,
})
);
};
}

View File

@@ -80,7 +80,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
protected renderSidebarAfter(): TemplateResult {
return html`<aside
aria-label=${msg("Applications Documentation")}
class="pf-c-sidebar__panel pf-m-width-25"
class="pf-c-sidebar__panel"
>
<div class="pf-c-card">
<div class="pf-c-card__body">

View File

@@ -28,7 +28,7 @@ export class EnterpriseLicenseForm extends ModelForm<License, string> {
getSuccessMessage(): string {
return this.instance
? msg("Successfully updated license.")
: msg("Successfully created license.");
: msg("Successfully installed license.");
}
async load(): Promise<void> {

View File

@@ -76,27 +76,26 @@ export class MemberSelectTable extends TableModal<User> {
this.fetch();
};
return html`&nbsp;
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group show-disabled-toggle-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.userListFilter === "all"}
@change=${toggleShowDisabledUsers}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group show-disabled-toggle-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.userListFilter === "all"}
@change=${toggleShowDisabledUsers}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
<span class="pf-c-switch__label">${msg("Show inactive users")}</span>
</label>
</div>
</span>
<span class="pf-c-switch__label">${msg("Show inactive users")}</span>
</label>
</div>
</div>`;
</div>
</div>`;
}
row(item: User): SlottedTemplateResult[] {

View File

@@ -449,34 +449,33 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
}
renderToolbarAfter(): TemplateResult {
return html`&nbsp;
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.hideServiceAccounts}
@change=${() => {
this.hideServiceAccounts = !this.hideServiceAccounts;
this.page = 1;
this.fetch();
updateURLParams({
hideServiceAccounts: this.hideServiceAccounts,
});
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.hideServiceAccounts}
@change=${() => {
this.hideServiceAccounts = !this.hideServiceAccounts;
this.page = 1;
this.fetch();
updateURLParams({
hideServiceAccounts: this.hideServiceAccounts,
});
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
<span class="pf-c-switch__label">${msg("Hide service-accounts")}</span>
</label>
</div>
</span>
<span class="pf-c-switch__label">${msg("Hide service-accounts")}</span>
</label>
</div>
</div>`;
</div>
</div>`;
}
}

View File

@@ -127,34 +127,33 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
}
renderToolbarAfter(): TemplateResult {
return html`&nbsp;
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.hideManaged}
@change=${() => {
this.hideManaged = !this.hideManaged;
this.page = 1;
this.fetch();
updateURLParams({
hideManaged: this.hideManaged,
});
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.hideManaged}
@change=${() => {
this.hideManaged = !this.hideManaged;
this.page = 1;
this.fetch();
updateURLParams({
hideManaged: this.hideManaged,
});
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
<span class="pf-c-switch__label">${msg("Hide managed mappings")}</span>
</label>
</div>
</span>
<span class="pf-c-switch__label">${msg("Hide managed mappings")}</span>
</label>
</div>
</div>`;
</div>
</div>`;
}
}

View File

@@ -1,5 +1,8 @@
import "#components/ak-hidden-text-input";
import "#elements/forms/HorizontalFormElement";
import "#components/ak-text-input";
import "#components/ak-radio-input";
import "#components/ak-switch-input";
import { DEFAULT_CONFIG } from "#common/api/config";
import { dateTimeLocal } from "#common/temporal";
@@ -18,15 +21,29 @@ import { msg, str } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
@customElement("ak-user-service-account-form")
export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
#initialExpirationValue = dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360));
//#region Refs
#expiringInputRef = createRef<HTMLInputElement>();
#expirationDateInputRef = createRef<HTMLInputElement>();
//#endregion
//#region Properties
@property({ attribute: false })
result: UserServiceAccountResponse | null = null;
@property({ attribute: false })
group?: Group;
//#endregion
getSuccessMessage(): string {
if (this.group) {
return msg(str`Successfully created user and added to group ${this.group.name}`);
@@ -58,38 +75,36 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
}
renderForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${msg("Username")} required name="name">
<input
type="text"
value=""
class="pf-c-form-control pf-m-monospace"
autocomplete="off"
spellcheck="false"
required
/>
<p class="pf-c-form__helper-text">
${msg("User's primary identifier. 150 characters or fewer.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="createGroup">
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" checked />
<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("Create group")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"Enabling this toggle will create a group named after the user, with the user as member.",
)}
</p>
</ak-form-element-horizontal>
return html`<ak-text-input
name="name"
label=${msg("Username")}
placeholder=${msg("Type a username for the user...")}
autocomplete="off"
value=""
input-hint="code"
required
maxlength=${150}
help=${msg(
"The user's primary identifier used for authentication. 150 characters or fewer.",
)}
></ak-text-input>
<ak-switch-input
name="createGroup"
label=${msg("Create group")}
help=${msg("Create and assign a group with the same name as the user.")}
>
</ak-switch-input>
<ak-form-element-horizontal name="expiring">
<label class="pf-c-switch">
<input class="pf-c-switch__input" type="checkbox" checked />
<input
${ref(this.#expiringInputRef)}
class="pf-c-switch__input"
type="checkbox"
checked
@change=${this.expiringChangeListener}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
@@ -99,15 +114,17 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
</label>
<p class="pf-c-form__helper-text">
${msg(
"If this is selected, the token will expire. Upon expiration, the token will be rotated.",
"Whether the token will expire. Upon expiration, the token will be rotated.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Expires on")} name="expires">
<input
${ref(this.#expirationDateInputRef)}
type="datetime-local"
data-type="datetime-local"
value="${dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360))}"
value="${this.#initialExpirationValue}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>`;
@@ -120,17 +137,20 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
)}
</p>
<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${msg("Username")}>
<input
type="text"
readonly
value=${ifDefined(this.result?.username)}
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-text-input
name="name"
label=${msg("Username")}
autocomplete="off"
value=${ifDefined(this.result?.username)}
input-hint="code"
readonly
></ak-text-input>
<ak-hidden-text-input
label=${msg("Password")}
value="${this.result?.token ?? ""}"
input-hint="code"
readonly
.help=${msg(
"Valid for 360 days, after which the password will automatically rotate. You can copy the password from the Token List.",
)}
@@ -139,6 +159,15 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
</form>`;
}
expiringChangeListener = () => {
const expiringElement = this.#expiringInputRef.value;
const expirationDateElement = this.#expirationDateInputRef.value;
if (!expiringElement || !expirationDateElement) return;
expirationDateElement.disabled = !expiringElement.checked;
};
renderFormWrapper(): TemplateResult {
if (this.result) {
return this.renderResponseForm();

View File

@@ -2,11 +2,15 @@ import "#admin/users/GroupSelectModal";
import "#elements/CodeMirror";
import "#elements/forms/HorizontalFormElement";
import "#elements/forms/Radio";
import "#components/ak-text-input";
import "#components/ak-radio-input";
import "#components/ak-switch-input";
import { DEFAULT_CONFIG } from "#common/api/config";
import { CodeMirrorMode } from "#elements/CodeMirror";
import { ModelForm } from "#elements/forms/ModelForm";
import { RadioOption } from "#elements/forms/Radio";
import { CoreApi, Group, User, UserTypeEnum } from "@goauthentik/api";
@@ -17,6 +21,28 @@ import { css, CSSResult, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
const UserTypeOptions: readonly RadioOption<UserTypeEnum>[] = [
{
label: msg("Internal"),
value: UserTypeEnum.Internal,
default: true,
description: html`${msg(
"Company employees with access to the full enterprise feature set.",
)}`,
},
{
label: msg("External"),
value: UserTypeEnum.External,
description: html`${msg(
"External consultants or B2C customers without access to enterprise features.",
)}`,
},
{
label: msg("Service account"),
value: UserTypeEnum.ServiceAccount,
description: html`${msg("Machine-to-machine authentication or other automations.")}`,
},
];
@customElement("ak-user-form")
export class UserForm extends ModelForm<User, number> {
@property({ attribute: false })
@@ -85,101 +111,92 @@ export class UserForm extends ModelForm<User, number> {
}
renderForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${msg("Username")} required name="username">
<input
type="text"
value="${ifDefined(this.instance?.username)}"
class="pf-c-form-control pf-m-monospace"
autocomplete="off"
spellcheck="false"
required
/>
<p class="pf-c-form__helper-text">
${msg("User's primary identifier. 150 characters or fewer.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Name")} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">${msg("User's display name.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("User type")} required name="type">
<ak-radio
.options=${[
{
label: msg("Internal"),
value: UserTypeEnum.Internal,
default: true,
description: html`${msg(
"Internal users might be users such as company employees, which will get access to the full Enterprise feature set.",
)}`,
},
{
label: msg("External"),
value: UserTypeEnum.External,
description: html`${msg(
"External users might be external consultants or B2C customers. These users don't get access to enterprise features.",
)}`,
},
{
label: msg("Service account"),
value: UserTypeEnum.ServiceAccount,
description: html`${msg(
"Service accounts should be used for machine-to-machine authentication or other automations.",
)}`,
},
{
label: msg("Internal Service account"),
value: UserTypeEnum.InternalServiceAccount,
disabled: true,
description: html`${msg(
"Internal Service accounts are created and managed by authentik and cannot be created manually.",
)}`,
},
]}
.value=${this.instance?.type}
>
</ak-radio>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Email")} name="email">
<input
type="email"
autocomplete="off"
value="${ifDefined(this.instance?.email)}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="isActive">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.isActive ?? 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("Is active")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Path")} required name="path">
<input
type="text"
value="${this.instance?.path ?? this.defaultPath}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
return html` <ak-text-input
name="username"
label=${msg("Username")}
placeholder=${msg("Type a username for the user...")}
autocomplete="off"
value="${ifDefined(this.instance?.username)}"
input-hint="code"
required
maxlength=${150}
help=${msg(
"The user's primary identifier used for authentication. 150 characters or fewer.",
)}
></ak-text-input>
<ak-text-input
name="name"
label=${msg("Display Name")}
placeholder=${msg("Type an optional display name...")}
autocomplete="off"
value="${ifDefined(this.instance?.name)}"
input-hint="code"
maxlength=${150}
help=${msg("The user's display name. 150 characters or fewer.")}
></ak-text-input>
<ak-radio-input
label=${msg("User type")}
required
name="type"
.value=${this.instance?.type}
.options=${[
...UserTypeOptions,
...(this.instance
? [
{
label: msg("Internal Service account"),
value: UserTypeEnum.InternalServiceAccount,
disabled: true,
description: html`${msg(
"Managed by authentik and cannot be assigned manually.",
)}`,
},
]
: []),
] satisfies RadioOption<UserTypeEnum>[]}
>
</ak-radio-input>
<ak-text-input
name="email"
label=${msg("Email Address")}
placeholder=${msg("Type an optional email address...")}
autocomplete="off"
value="${ifDefined(this.instance?.email)}"
input-hint="code"
></ak-text-input>
<ak-switch-input
name="isActive"
label=${msg("Active")}
?checked=${this.instance?.isActive ?? true}
help=${msg(
"Whether this user is active and allowed to authenticate. Setting this to inactive can be used to temporarily disable a user without deleting their account.",
)}
>
</ak-switch-input>
<ak-text-input
name="path"
label=${msg("Path")}
placeholder=${msg("Type a path for the user...")}
autocomplete="off"
value="${this.instance?.path ?? this.defaultPath}"
input-hint="code"
required
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg(
"Paths can be used to organize users into folders depending on which source created them or organizational structure.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg(
"Paths may not start or end with a slash, but they can contain any other character as path segments. The paths are currently purely used for organization, it does not affect their permissions, group memberships, or anything else.",
)}
</p>`}
></ak-text-input>
<ak-form-element-horizontal label=${msg("Attributes")} name="attributes">
<ak-codemirror
mode=${CodeMirrorMode.YAML}

View File

@@ -91,6 +91,9 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
supportsQL = true;
protected override searchEnabled = true;
public override searchPlaceholder = msg("Search by username, email, etc...");
public override searchLabel = msg("User Search");
public pageTitle = msg("Users");
public pageDescription = "";
public pageIcon = "pf-icon pf-icon-user";
@@ -207,34 +210,33 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
}
renderToolbarAfter(): TemplateResult {
return html`&nbsp;
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.hideDeactivated}
@change=${() => {
this.hideDeactivated = !this.hideDeactivated;
this.page = 1;
this.fetch();
updateURLParams({
hideDeactivated: this.hideDeactivated,
});
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${!this.hideDeactivated}
@change=${() => {
this.hideDeactivated = !this.hideDeactivated;
this.page = 1;
this.fetch();
updateURLParams({
hideDeactivated: this.hideDeactivated,
});
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
<span class="pf-c-switch__label">${msg("Hide deactivated user")}</span>
</label>
</div>
</span>
<span class="pf-c-switch__label">${msg("Show deactivated users")}</span>
</label>
</div>
</div>`;
</div>
</div>`;
}
row(item: User): SlottedTemplateResult[] {
@@ -392,27 +394,24 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit">${msg("Create")}</span>
<span slot="header">${msg("Create User")}</span>
<span slot="submit">${msg("Create User")}</span>
<span slot="header">${msg("New User")}</span>
<ak-user-form defaultPath=${this.activePath} slot="form"> </ak-user-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("New User")}</button>
</ak-forms-modal>
<ak-forms-modal .closeAfterSuccessfulSubmit=${false} .cancelText=${msg("Close")}>
<span slot="submit">${msg("Create")}</span>
<span slot="header">${msg("Create Service account")}</span>
<span slot="submit">${msg("Create Service Account")}</span>
<span slot="header">${msg("New Service Account")}</span>
<ak-user-service-account-form slot="form"> </ak-user-service-account-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Create Service account")}
${msg("New Service Account")}
</button>
</ak-forms-modal>
`;
}
protected renderSidebarBefore(): TemplateResult {
return html`<aside
aria-labelledby="sidebar-left-panel-header"
class="pf-c-sidebar__panel pf-m-width-25"
>
return html`<aside aria-labelledby="sidebar-left-panel-header" class="pf-c-sidebar__panel">
<div class="pf-c-card">
<div
role="heading"

View File

@@ -55,8 +55,8 @@
}
}
.pf-c-form__label[aria-required] .pf-c-form__label-text::after {
content: "*" / "Required";
.pf-c-form__label[aria-required="true"] .pf-c-form__label-text::after {
content: "*" / "";
user-select: none;
margin-left: var(--pf-c-form__label-required--MarginLeft);
font-size: var(--pf-c-form__label-required--FontSize);
@@ -219,12 +219,24 @@ html > form > input {
user-select: none;
}
.pf-c-form__helper-text {
text-wrap: pretty;
}
::placeholder {
font-style: italic;
}
/* #endregion */
/* #region Fonts */
.pf-m-monospace {
font-family: var(--pf-global--FontFamily--monospace);
&::placeholder {
font-family: var(--pf-global--FontFamily--sans-serif);
}
}
/* #endregion */

View File

@@ -225,10 +225,6 @@ select.pf-c-form-control {
.pf-c-switch__input:checked ~ .pf-c-switch__label {
--pf-c-switch__input--checked__label--Color: var(--ak-dark-foreground);
}
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(1);
}
/* select toggle */
.pf-c-select__toggle::before {

View File

@@ -40,6 +40,8 @@ export abstract class HorizontalLightComponent<T>
return this;
}
public override role = "presentation";
//#region Properties
/**
@@ -56,26 +58,7 @@ export abstract class HorizontalLightComponent<T>
* @attribute
*/
@property({ type: String })
public get label() {
return this.ariaLabel;
}
public set label(value: string | null) {
this.ariaLabel = value;
}
/**
* The ARIA role for the input control
* @property
* @attribute
*/
public get role() {
return super.role || "group";
}
public set role(value: string | null) {
super.role = value;
}
label: string | null = null;
/**
* @property
@@ -146,15 +129,32 @@ export abstract class HorizontalLightComponent<T>
@property({ type: String, attribute: "input-hint" })
inputHint?: string;
#fieldID = IDGenerator.elementID().toString();
protected helpID = `field-help-${this.#fieldID}`;
protected labelID = `field-label-${this.#fieldID}`;
/**
* A unique ID to associate with the input and label.
* @property
*/
@property({ type: String, reflect: false })
public fieldID?: string = IDGenerator.elementID().toString();
public get fieldID() {
return this.#fieldID;
}
protected get helpID() {
return this.fieldID ? `field-help-${this.fieldID}` : "field-help";
public set fieldID(value: string) {
this.#fieldID = value;
this.helpID = `field-help-${this.#fieldID}`;
this.labelID = `field-label-${this.#fieldID}`;
}
//#endregion
//#region Lifecycle
connectedCallback() {
super.connectedCallback();
this.setAttribute("aria-labelledby", this.labelID);
}
//#endregion
@@ -187,7 +187,14 @@ export abstract class HorizontalLightComponent<T>
.errorMessages=${this.errorMessages}
>
<div slot="label" class="pf-c-form__group-label">
${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label || "")}
${AKLabel(
{
id: this.labelID,
htmlFor: this.fieldID,
required: this.required,
},
this.label || "",
)}
</div>
${this.renderControl()}

View File

@@ -82,6 +82,9 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
@property({ type: String })
public autocomplete?: AutoFill;
@property({ type: Boolean, attribute: "readonly" })
public readOnly: boolean = false;
/**
* @property
* @attribute
@@ -117,7 +120,10 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
"pf-m-monospace": code,
})}"
spellcheck=${code ? "false" : "true"}
id=${ifPresent(this.fieldID)}
aria-describedby=${this.helpID}
?required=${this.required}
?readonly=${this.readOnly}
/>`;
}

View File

@@ -1,10 +1,11 @@
import { LitFC } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { spread } from "@open-wc/lit-helpers";
import type { LabelHTMLAttributes } from "react";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
export interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
required?: boolean;
@@ -18,10 +19,14 @@ export const AKLabel: LitFC<FormLabelProps> = (
return html`<label
class="pf-c-form__label"
for=${ifDefined(htmlFor)}
?aria-required=${required}
for=${ifPresent(htmlFor)}
aria-required=${required ? "true" : "false"}
${spread(labelAttributes)}
>
<span class="pf-c-form__label-text">${children}</span>
<span
class="pf-c-form__label-text"
data-required-label=${required ? msg("Required") : nothing}
>${children}</span
>
</label>`;
};

View File

@@ -10,6 +10,8 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-radio-input")
export class AkRadioInput<T> extends HorizontalLightComponent<T> {
public override role = "radiogroup";
@property({ type: Object })
value!: T;
@@ -22,11 +24,6 @@ export class AkRadioInput<T> extends HorizontalLightComponent<T> {
}
}
public override connectedCallback(): void {
super.connectedCallback();
this.role = "radiogroup";
}
protected override renderHelp(): SlottedTemplateResult {
return nothing;
}

View File

@@ -5,6 +5,7 @@ import { rootInterface } from "#common/theme";
import { FormAssociated, FormAssociatedElement } from "#elements/forms/form-associated-element";
import { PaginatedResponse } from "#elements/table/Table";
import { ifPresent } from "#elements/utils/attributes";
import DjangoQL, { Introspections } from "@mrmarble/djangoql-completion";
@@ -45,6 +46,8 @@ function torusIndex(lengthLike: number | ArrayLike<number>, delta: number): numb
@customElement("ak-search-ql")
export class QLSearch extends FormAssociatedElement<string> implements FormAssociated {
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
declare anchorRef: Ref<HTMLTextAreaElement>;
declare anchor: HTMLTextAreaElement | null;
@@ -53,10 +56,6 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
PFFormControl,
PFSearchInput,
css`
::-webkit-search-cancel-button {
display: none;
}
.ql.pf-c-form-control {
--input-height: 2.25em;
@@ -64,12 +63,33 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
min-height: var(--input-height);
max-height: calc(var(--input-height) * 6);
resize: vertical;
outline: none;
}
.pf-c-search-input[aria-expanded="true"] {
.ql.pf-c-form-control {
--pf-c-form-control--BorderBottomColor: var(
--pf-c-form-control--focus--BorderBottomColor
);
}
.pf-c-search-input__text::after {
--pf-c-search-input__text--after--BorderBottomWidth: var(
--pf-c-search-input__text--focus-within--after--BorderBottomWidth
);
--pf-c-search-input__text--after--BorderBottomColor: var(
--pf-c-search-input__text--focus-within--after--BorderBottomColor
);
}
}
.selected {
background-color: var(--pf-c-search-input__menu-item--hover--BackgroundColor);
}
.pf-c-search-input__menu-list {
user-select: none;
}
:host([theme="dark"]) {
.pf-c-search-input__menu {
--pf-c-search-input__menu--BackgroundColor: var(--ak-dark-background-light-ish);
@@ -108,6 +128,12 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
//#region Properties
@property({ type: String })
public placeholder = msg("Search...");
@property({ type: String })
public label: string | null = msg("Search");
@property({ type: Boolean })
public open = false;
@@ -123,8 +149,16 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
public set value(value: unknown) {
const parsed = typeof value === "string" ? value : "";
const trimmed = parsed.trim();
this.setFormValue(trimmed, parsed);
if (trimmed) {
this.internals.states.add("present");
} else {
this.internals.states.delete("present");
}
this.setFormValue(parsed.trim(), parsed);
this.#value = parsed;
if (this.anchor) {
@@ -134,6 +168,21 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
//#endregion
//#region Public API
public submit() {
if (!this.form) return;
const submitEvent = new SubmitEvent("submit", {
submitter: this,
bubbles: true,
composed: true,
cancelable: true,
});
this.form.dispatchEvent(submitEvent);
}
//#region State
#menuRef = createRef<HTMLDivElement>();
@@ -158,10 +207,6 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
public override connectedCallback() {
super.connectedCallback();
this.internals.ariaAutoComplete = "list";
this.internals.role = "combobox";
this.internals.ariaHasPopup = "listbox";
this.#scrollContainer =
rootInterface<LitElement>().renderRoot.querySelector("#main-content");
@@ -170,6 +215,8 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
});
this.tabIndex = 0;
this.addEventListener("focus", this.#delegateFocusListener);
}
public override disconnectedCallback() {
@@ -191,15 +238,9 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
}
public override updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("open")) {
this.internals.ariaExpanded = this.open ? "true" : "false";
}
if (changedProperties.has("selectionIndex")) {
const id = `suggestion-${this.selectionIndex}`;
this.setAttribute("aria-activedescendant", this.selectionIndex === -1 ? "" : id);
this.renderRoot.querySelector(`#${id}`)?.scrollIntoView({
behavior: "auto",
block: "nearest",
@@ -326,14 +367,7 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
const suggestionsLength = this.#ql?.suggestions.length;
if (event.key === "Enter" && !this.open && this.form) {
const submitEvent = new SubmitEvent("submit", {
submitter: this,
bubbles: true,
composed: true,
cancelable: true,
});
this.form.dispatchEvent(submitEvent);
this.submit();
return;
}
@@ -419,6 +453,10 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
this.#refreshCompletions();
};
#delegateFocusListener = () => {
this.anchorRef?.value?.focus();
};
//#endregion
//#region Render
@@ -469,7 +507,17 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
}
public override render(): TemplateResult {
return html`<div class="pf-c-search-input">
return html`<div
class="pf-c-search-input"
aria-expanded=${this.open ? "true" : "false"}
aria-autocomplete="list"
role="combobox"
aria-label=${ifPresent(this.label)}
aria-has-popup="listbox"
aria-activedescendant=${this.selectionIndex === -1
? ""
: `suggestion-${this.selectionIndex}`}
>
<div class="pf-c-search-input__bar">
<span class="pf-c-search-input__text">
<textarea
@@ -479,7 +527,8 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
autocomplete="off"
aria-controls="ql-suggestions"
?required=${this.required}
placeholder=${msg("Search...")}
placeholder=${ifPresent(this.placeholder)}
aria-label=${msg("Query input")}
spellcheck="false"
@input=${this.#refreshCompletions}
@focus=${this.#focusListener}

View File

@@ -106,7 +106,7 @@ export class AkStatusLabel extends AKElement {
"pf-m-compact": this.compact,
};
return html`<span class="${classMap(classes)}">
return html`<span class="${classMap(classes)}" aria-label=${label} role="status">
<span class="pf-c-label__content">
<span class="pf-c-label__icon">
<i class="fas fa-fw ${icon}" aria-hidden="true"></i> </span

View File

@@ -18,15 +18,27 @@ export class AkTextInput extends HorizontalLightComponent<string> {
@property({ type: String })
public placeholder: string | null = null;
@property({ type: Number, attribute: "maxlength" })
public maxLength?: number;
@property({ type: Number, attribute: "minlength" })
public minLength?: number;
@property({ type: Boolean, attribute: "readonly" })
public readOnly: boolean = false;
@property({ type: String })
public type: "text" | "email" = "text";
#inputListener(ev: InputEvent) {
this.value = (ev.target as HTMLInputElement).value;
}
public override renderControl() {
const code = this.inputHint === "code";
return html` <input
type="text"
role="textbox"
type=${this.type}
id=${ifDefined(this.fieldID)}
@input=${this.#inputListener}
value=${ifDefined(this.value)}
@@ -34,9 +46,10 @@ export class AkTextInput extends HorizontalLightComponent<string> {
"pf-c-form-control": true,
"pf-m-monospace": code,
})}"
maxlength=${ifPresent(this.maxLength)}
minlength=${ifPresent(this.minLength)}
autocomplete=${ifPresent(code ? "off" : this.autocomplete)}
spellcheck=${ifPresent(code ? "false" : this.spellcheck)}
aria-label=${ifPresent(this.placeholder || this.label)}
aria-describedby=${this.helpID}
placeholder=${ifPresent(this.placeholder)}
?required=${this.required}

View File

@@ -71,6 +71,10 @@ export abstract class ModalButton extends AKElement {
.pf-c-modal-box.pf-m-xl {
--pf-c-modal-box--Width: calc(1.5 * var(--pf-c-modal-box--m-lg--lg--MaxWidth));
}
.pf-c-modal-box__footer {
gap: var(--pf-global--spacer--sm);
}
`,
];

View File

@@ -106,7 +106,7 @@ export class ModalForm extends ModalButton {
: nothing}
<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1 class="pf-c-title pf-m-2xl">
<h1 id="modal-title" class="pf-c-title pf-m-2xl">
<slot name="header"></slot>
</h1>
</div>
@@ -117,13 +117,13 @@ export class ModalForm extends ModalButton {
</section>
<footer class="pf-c-modal-box__footer">
${this.showSubmitButton
? html`<ak-spinner-button .callAction=${this.#confirm} class="pf-m-primary">
<slot name="submit"></slot> </ak-spinner-button
>&nbsp;`
? html`<button @click=${this.#confirm} class="pf-c-button pf-m-primary">
<slot name="submit"></slot>
</button>`
: nothing}
<ak-spinner-button .callAction=${this.#cancel} class="pf-m-secondary">
<button @click=${this.#cancel} class="pf-c-button pf-m-secondary">
${this.cancelText}
</ak-spinner-button>
</button>
</footer>`;
}
}

View File

@@ -17,6 +17,7 @@ import { AKElement } from "#elements/Base";
import { WithLicenseSummary } from "#elements/mixins/license";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { Pagination } from "@goauthentik/api";
@@ -74,7 +75,7 @@ export abstract class Table<T extends object>
.presentational {
--pf-c-table--cell--MinWidth: 0;
}
@container (width > 600px) {
@container (width > 1200px) {
--pf-c-table--cell--MinWidth: 9em;
}
}
@@ -122,11 +123,8 @@ export abstract class Table<T extends object>
}
}
.pf-c-toolbar__group.pf-m-search-filter.ql {
flex-grow: 1;
}
ak-table-search.ql {
width: 100% !important;
.pf-m-search-filter {
flex: 1 1 auto;
}
.pf-c-table thead .pf-c-table__check {
min-width: 3rem;
@@ -134,11 +132,13 @@ export abstract class Table<T extends object>
.pf-c-table tbody .pf-c-table__check input {
margin-top: calc(var(--pf-c-table__check--input--MarginTop) + 1px);
}
.pf-c-toolbar__content {
row-gap: var(--pf-global--spacer--sm);
justify-content: space-between;
gap: var(--pf-global--spacer--sm);
}
.pf-c-toolbar__item .pf-c-input-group {
padding: 0 var(--pf-global--spacer--sm);
.pf-c-toolbar__group {
gap: var(--pf-global--spacer--sm);
}
.pf-c-table {
@@ -155,6 +155,10 @@ export abstract class Table<T extends object>
.pf-c-table tr.pf-m-hoverable {
user-select: none;
}
time {
text-transform: capitalize;
}
`,
];
@@ -173,7 +177,8 @@ export abstract class Table<T extends object>
*/
protected abstract row(item: T): SlottedTemplateResult[];
#loading = false;
@state()
protected loading = false;
#pageParam = `${this.tagName.toLowerCase()}-page`;
#searchParam = `${this.tagName.toLowerCase()}-search`;
@@ -329,11 +334,11 @@ export abstract class Table<T extends object>
}
public fetch(): Promise<void> {
if (this.#loading) {
if (this.loading) {
return Promise.resolve();
}
this.#loading = true;
this.loading = true;
return this.apiEndpoint()
.then((data) => {
@@ -364,7 +369,7 @@ export abstract class Table<T extends object>
this.error = await parseAPIResponseError(error);
})
.finally(() => {
this.#loading = false;
this.loading = false;
this.requestUpdate();
});
}
@@ -438,7 +443,7 @@ export abstract class Table<T extends object>
return this.renderEmpty(this.renderError());
}
if (!this.data || this.#loading) {
if (!this.data || this.loading) {
return this.renderLoading();
}
@@ -673,26 +678,24 @@ export abstract class Table<T extends object>
return nothing;
}
protected renderToolbarAfter(): SlottedTemplateResult {
return nothing;
}
protected renderToolbarAfter?(): SlottedTemplateResult;
protected renderToolbarContainer(): SlottedTemplateResult {
const label = this.toolbarLabel ?? msg(str`${this.label ?? "Table"} actions`);
return html`<header class="pf-c-toolbar" role="toolbar" aria-label="${label}">
<div role="presentation" class="pf-c-toolbar__content">
<div class="pf-c-toolbar__content">
${this.renderSearch()}
<div role="presentation" class="pf-c-toolbar__bulk-select">
${this.renderToolbar()}
${this.renderToolbarAfter
? html`<div class="pf-c-toolbar__group">${this.renderToolbarAfter()}</div>`
: nothing}
</div>
<div class="pf-c-toolbar__content">
<div class="pf-c-toolbar__group">
${this.renderToolbar()} ${this.renderToolbarSelected()}
</div>
<div role="presentation" class="pf-c-toolbar__group">
${this.renderToolbarAfter()}
</div>
<div role="presentation" class="pf-c-toolbar__group">
${this.renderToolbarSelected()}
</div>
${this.paginated ? this.renderTablePagination() : nothing}
${this.renderTablePagination()}
</div>
</header>`;
}
@@ -716,18 +719,16 @@ export abstract class Table<T extends object>
const isQL = this.supportsQL && this.hasEnterpriseLicense;
return html`<div class="pf-c-toolbar__group pf-m-search-filter ${isQL ? "ql" : ""}">
<ak-table-search
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
.defaultValue=${this.search}
label=${ifDefined(this.searchLabel)}
placeholder=${ifDefined(this.searchPlaceholder)}
.onSearch=${this.#searchListener}
.supportsQL=${this.supportsQL}
.apiResponse=${this.data}
>
</ak-table-search>
</div>`;
return html` <ak-table-search
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
.defaultValue=${this.search}
label=${ifDefined(this.searchLabel)}
placeholder=${ifDefined(this.searchPlaceholder)}
.onSearch=${this.#searchListener}
.supportsQL=${this.supportsQL}
.apiResponse=${this.data}
>
</ak-table-search>`;
}
//#endregion
@@ -816,6 +817,8 @@ export abstract class Table<T extends object>
* A simple pagination display, shown at both the top and bottom of the page.
*/
protected renderTablePagination(): SlottedTemplateResult {
if (!this.paginated) return nothing;
const handler = (page: number) => {
this.page = page;
this.fetch();
@@ -823,7 +826,8 @@ export abstract class Table<T extends object>
return html`
<ak-table-pagination
label=${ifDefined(this.label || undefined)}
?loading=${this.loading}
label=${ifPresent(this.label)}
class="pf-c-toolbar__item pf-m-pagination"
.pages=${this.data?.pagination}
.onPageChange=${handler}

View File

@@ -5,7 +5,7 @@ import { SlottedTemplateResult } from "#elements/types";
import { setPageDetails } from "#components/ak-page-navbar";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { css, CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
@@ -18,6 +18,14 @@ export abstract class TablePage<T extends object> extends Table<T> {
PFPage,
PFContent,
PFSidebar,
css`
.pf-c-sidebar__panel {
flex: 0 1 25%;
}
.pf-c-sidebar__content {
flex: 1 1 75%;
}
`,
];
//#region Abstract properties

View File

@@ -20,6 +20,9 @@ export class TablePagination extends AKElement {
@property({ attribute: false })
public pages?: Pagination;
@property({ type: Boolean })
public loading = false;
@property({ attribute: false })
public onPageChange?: TablePageChangeListener;
@@ -28,12 +31,25 @@ export class TablePagination extends AKElement {
PFButton,
PFPagination,
css`
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button {
color: var(--pf-c-button--m-plain--disabled--Color);
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
.pf-c-pagination {
min-width: 8rem;
&[inert] {
opacity: 0.5;
}
}
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button:disabled {
color: var(--pf-c-button--disabled--Color);
:host([theme="dark"]) {
.pf-c-pagination__nav-control {
.pf-c-button {
color: var(--pf-c-button--m-plain--disabled--Color);
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
&:disabled {
color: var(--pf-c-button--disabled--Color);
}
}
}
}
`,
];
@@ -47,21 +63,24 @@ export class TablePagination extends AKElement {
};
render() {
if (!this.pages) {
if (!this.pages && !this.loading) {
return nothing;
}
const startIndex = this.pages?.startIndex || 1;
const endIndex = this.pages?.endIndex || 1;
const pageCount = this.pages?.count || 1;
return html` <nav
aria-label=${msg(str`${this.label || ""} table pagination`)}
class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md"
?inert=${this.loading}
>
<div class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md">
<div class="pf-c-options-menu">
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
<span role="heading" aria-level="4" class="pf-c-options-menu__toggle-text">
${msg(
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`,
)}
${msg(str`${startIndex} - ${endIndex} of ${pageCount}`)}
</span>
</div>
</div>

View File

@@ -3,11 +3,11 @@ import "#components/ak-search-ql/index";
import { AKElement } from "#elements/Base";
import { WithLicenseSummary } from "#elements/mixins/license";
import { PaginatedResponse } from "#elements/table/Table";
import { ifPresent } from "#elements/utils/attributes";
import { msg } from "@lit/localize";
import { css, CSSResult, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref } from "lit/directives/ref.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -43,12 +43,33 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
PFInputGroup,
PFFormControl,
css`
::-webkit-search-cancel-button {
display: none;
form.pf-c-input-group {
position: relative;
}
ak-search-ql {
width: 100%;
}
button[type="reset"] {
position: absolute;
inset-inline-end: 0.25em;
inset-block-start: 0.25em;
appearance: none;
border: none;
background: none;
line-height: 1;
font-family: ui-monospace, monospace;
color: initial;
color: ButtonText;
z-index: var(--pf-global--ZIndex--xs);
cursor: pointer;
display: none;
}
ak-search-ql:state(present) + button[type="reset"] {
display: block;
}
`,
];
@@ -76,24 +97,25 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
this.onSearch(value);
};
renderInput(): TemplateResult {
protected renderInput(): TemplateResult {
if (this.supportsQL && this.hasEnterpriseLicense) {
return html`<ak-search-ql
aria-label=${ifDefined(this.label)}
name="search"
required
placeholder=${ifDefined(this.placeholder)}
value=${ifDefined(this.defaultValue)}
.apiResponse=${this.apiResponse}
></ak-search-ql>`;
label=${ifPresent(this.label)}
role="presentation"
name="search"
placeholder=${ifPresent(this.placeholder)}
value=${ifPresent(this.defaultValue)}
.apiResponse=${this.apiResponse}
></ak-search-ql>
<button type="reset" aria-label=${msg("Clear search")}>&times;</button>`;
}
return html`<input
aria-label=${ifDefined(this.label)}
aria-label=${ifPresent(this.label)}
name="search"
required
placeholder=${ifDefined(this.placeholder)}
value=${ifDefined(this.defaultValue)}
type="search"
placeholder=${ifPresent(this.placeholder)}
value=${ifPresent(this.defaultValue)}
class="pf-c-form-control"
/>`;
}
@@ -103,19 +125,9 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
${ref(this.#formRef)}
class="pf-c-input-group"
@submit=${this.#submitListener}
@reset=${this.reset}
>
${this.renderInput()}
<button
aria-label=${msg("Clear search")}
class="pf-c-button pf-m-control"
type="reset"
@click=${this.reset}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
<button aria-label=${msg("Search")} type="submit" class="pf-c-button pf-m-control">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
</form>`;
}
}

View File

@@ -37,9 +37,10 @@ export function Timestamp(timestamp?: Date | null): TemplateResult {
}
const elapsed = formatElapsedTime(timestamp);
const title = timestamp.toLocaleString();
return html` <time datetime=${timestamp.toISOString()} aria-label=${elapsed}>
return html` <time datetime=${timestamp.toISOString()} aria-label=${elapsed} title=${title}>
<div>${elapsed}</div>
<small>${timestamp.toLocaleString()}</small>
<small>${timestamp.toLocaleDateString()}</small>
</time>`;
}

View File

@@ -83,29 +83,28 @@ export class ScheduleList extends Table<Schedule> {
if (this.relObjId !== undefined) {
return nothing;
}
return html`&nbsp;
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.showOnlyStandalone}
@change=${this.#toggleShowOnlyStandalone}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"> </i>
</span>
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.showOnlyStandalone}
@change=${this.#toggleShowOnlyStandalone}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"> </i>
</span>
<span class="pf-c-switch__label">
${msg("Show only standalone schedules")}
</span>
</label>
</div>
</span>
<span class="pf-c-switch__label">
${msg("Show only standalone schedules")}
</span>
</label>
</div>
</div>`;
</div>
</div>`;
}
row(item: Schedule): SlottedTemplateResult[] {

View File

@@ -106,47 +106,44 @@ export class TaskList extends Table<Task> {
];
renderToolbarAfter(): TemplateResult {
return html`&nbsp;
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
${this.relObjId === undefined
? html` <label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.showOnlyStandalone}
@change=${this.#toggleShowOnlyStandalone}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"> </i>
</span>
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
${this.relObjId === undefined
? html` <label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.showOnlyStandalone}
@change=${this.#toggleShowOnlyStandalone}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"> </i>
</span>
<span class="pf-c-switch__label">
${msg("Show only standalone tasks")}
</span>
</label>`
: nothing}
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.excludeSuccessful}
@change=${this.#toggleExcludeSuccessful}
/>
<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("Show only standalone tasks")}
</span>
</label>`
: nothing}
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.excludeSuccessful}
@change=${this.#toggleExcludeSuccessful}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"> </i>
</span>
<span class="pf-c-switch__label">
${msg("Exclude successful tasks")}
</span>
</label>
</div>
</span>
<span class="pf-c-switch__label"> ${msg("Exclude successful tasks")} </span>
</label>
</div>
</div>`;
</div>
</div>`;
}
row(item: Task): SlottedTemplateResult[] {

View File

@@ -45,15 +45,14 @@ test.describe("Provider Wizard", () => {
});
});
test.afterEach("Verification", async ({ page }, { testId }) => {
test.afterEach("Verification", async ({ page, form }, { testId }) => {
//#region Confirm provider
const providerName = providerNames.get(testId)!;
const { fill, findTextualInput } = form;
const $provider = await test.step("Find provider via search", async () => {
const searchInput = page.getByLabel("Provider Search");
await searchInput.fill(providerName);
const providerSearch = await findTextualInput("Provider Search");
// We have to wait for the provider to appear in the table,
// but several UI elements will be rendered asynchronously.
@@ -63,8 +62,8 @@ test.describe("Provider Wizard", () => {
let found = false;
for (let i = 0; i < tries; i++) {
await searchInput.press("Enter");
await searchInput.blur();
await fill(providerSearch, providerName);
await providerSearch.press("Enter");
const $rowEntry = page.getByRole("row", {
name: providerName,

View File

@@ -0,0 +1,113 @@
import { expect, test } from "#e2e";
import { randomName } from "#e2e/utils/generators";
import { ConsoleLogger } from "#logger/node";
import { IDGenerator } from "@goauthentik/core/id";
import { series } from "@goauthentik/core/promises";
import { snakeCase } from "change-case";
test.describe("Users", () => {
const usernames = new Map<string, string>();
const displayNames = new Map<string, string>();
//#region Lifecycle
test.beforeEach("Prepare user", async ({ page, session }, { testId }) => {
const seed = IDGenerator.randomID(6);
const displayName = `${randomName(seed)} (${seed})`;
displayNames.set(testId, displayName);
usernames.set(testId, snakeCase(displayName));
const wizard = page.getByRole("dialog", { name: "New User" });
await test.step("Authenticate", async () => {
await session.login({
to: "/if/admin/#/identity/users",
});
});
await test.step("Navigate to new user wizard", async () => {
await expect(wizard, "Wizard is initially closed").toBeHidden();
await page.getByRole("button", { name: "New User" }).click();
await expect(wizard, "Wizard opens after clicking on New User").toBeVisible();
});
});
test.afterEach("Verification", async ({ page, form }, { testId }) => {
//#region Confirm user
const username = usernames.get(testId)!;
const { fill, findTextualInput } = form;
const $user = await test.step("Find user via search", async () => {
const userSearch = await findTextualInput("User Search");
// We have to wait for the user to appear in the table,
// but several UI elements will be rendered asynchronously.
// We attempt several times to find the user to avoid flakiness.
const tries = 10;
let found = false;
for (let i = 0; i < tries; i++) {
await fill(userSearch, username);
await userSearch.press("Enter");
const $rowEntry = page.getByRole("row", {
name: username,
});
ConsoleLogger.info(
`${i + 1}/${tries} Waiting for user ${username} to appear in the table`,
);
found = await $rowEntry
.waitFor({
timeout: 1500,
})
.then(() => true)
.catch(() => false);
if (found) {
ConsoleLogger.info(`User ${username} found in the table`);
return $rowEntry;
}
}
throw new Error(`User ${username} not found in the table`);
});
await expect($user, "User is visible").toBeVisible();
//#endregion
});
//#endregion
//#region Tests
test("Simple user", async ({ form, pointer, page }, testInfo) => {
const displayName = displayNames.get(testInfo.testId)!;
const username = usernames.get(testInfo.testId)!;
const { fill } = form;
const { click } = pointer;
const wizard = page.getByRole("dialog", { name: "New User" });
await expect(wizard, "Wizard is open at start of test").toBeVisible();
await series(
[fill, /^Username/, username],
[fill, /^Display Name/, displayName],
[fill, /^Email Address/, `${username}@example.com`],
// [click, /^Create User/, "button"],
);
await click("Create User", "button");
await expect(wizard, "Wizard closes after creating user").toBeHidden();
});
//#endregion
});