web: Fix numeric values in search select inputs, search input fixes (#16928)

* web: Fix numeric values in search select inputs.

* web: Fix ARIA attributes on menu items.

* web: Fix issues surrounding nested modal actions, selectors, labels.

* web: Prepare group forms for testing, ARIA, etc.

* web: Clarify when spinner buttons are busy.

* web: Fix dark theme toggle input visibility.

* web: Fix issue where tests complete before optional search inputs load.

* web: Add user creation tests, group creation. Flesh out fixtures.
This commit is contained in:
Teffen Ellis
2025-10-02 05:04:38 +02:00
committed by GitHub
parent 9e4b6098fd
commit 2e8a1d80a3
24 changed files with 661 additions and 191 deletions
+41
View File
@@ -78,6 +78,47 @@ export class FormFixture extends PageFixture {
await control.fill(value);
};
/**
* Search for a row containing the given text.
*/
public search = async (
query: string,
context: LocatorContext = this.page,
): Promise<Locator> => {
const searchInput = await this.findTextualInput(/search/i, context);
// 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 this.fill(searchInput, query);
await searchInput.press("Enter");
const $rowEntry = context.getByRole("row", {
name: query,
});
this.logger.info(`${i + 1}/${tries} Waiting for "${query}" to appear in the table`);
found = await $rowEntry
.waitFor({
timeout: 1500,
})
.then(() => true)
.catch(() => false);
if (found) {
this.logger.info(`"${query}" found in the table`);
return $rowEntry;
}
}
throw new Error(`"${query}" not found in the table`);
};
/**
* Set the value of a radio or checkbox input.
*
+56
View File
@@ -0,0 +1,56 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import { Page } from "@playwright/test";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
export const BAD_USERNAME = "bad-username@bad-login.io";
export const BAD_PASSWORD = "-this-is-a-bad-password-";
export interface LoginInit {
username?: string;
password?: string;
to?: URL | string;
}
export class NavigatorFixture extends PageFixture {
static fixtureName = "Navigator";
constructor(page: Page, testName: string) {
super({ page, testName });
}
/**
* Wait for the current page to navigate to the given pathname.
*
* This method is useful to verify that a navigation has completed after an action
* automatically updates the URL, such as form submissions or link clicks.
*
* @see {@linkcode navigate} for navigation.
*
* @param to The pathname or URL to wait for.
*/
public waitForPathname = async (to: string | URL): Promise<void> => {
const expectedPathname = typeof to === "string" ? to : to.pathname;
this.logger.info(`Waiting for URL to change to ${expectedPathname}`);
await this.page.waitForURL(`**${expectedPathname}**`);
this.logger.info(`URL changed to ${this.page.url()}`);
};
/**
* Navigate to the given URL or pathname, and wait for the navigation to complete.
*/
public navigate = async (to: URL | string | null | undefined): Promise<void> => {
if (!to) {
throw new TypeError("No URL or pathname given to navigate to.");
}
await this.page.goto(to.toString());
await this.waitForPathname(to);
};
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { ConsoleLogger, FixtureLogger } from "#logger/node";
import { Page } from "@playwright/test";
export interface PageFixtureOptions {
export interface PageFixtureInit {
page: Page;
testName: string;
}
@@ -19,7 +19,7 @@ export abstract class PageFixture {
protected readonly page: Page;
protected readonly testName: string;
constructor({ page, testName }: PageFixtureOptions) {
constructor({ page, testName }: PageFixtureInit) {
this.page = page;
this.testName = testName;
+3
View File
@@ -17,6 +17,9 @@ export type ClickByRole = (
export class PointerFixture extends PageFixture {
public static fixtureName = "Pointer";
/**
* A high-level click function that simplifies clicking on buttons and links.
*/
public click = (
name: string | RegExp,
optionsOrRole?: ARIAOptions | ARIARole,
+11 -11
View File
@@ -1,6 +1,5 @@
import { PageFixture } from "#e2e/fixtures/PageFixture";
import { Page } from "@playwright/test";
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
import { PageFixture, PageFixtureInit } from "#e2e/fixtures/PageFixture";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
@@ -14,11 +13,17 @@ export interface LoginInit {
to?: URL | string;
}
export interface SessionFixtureInit extends PageFixtureInit {
navigator: NavigatorFixture;
}
export class SessionFixture extends PageFixture {
static fixtureName = "Session";
public static readonly pathname = "/if/flow/default-authentication-flow/";
protected navigator: NavigatorFixture;
//#region Selectors
public $identificationStage = this.page.locator("ak-stage-identification");
@@ -46,8 +51,9 @@ export class SessionFixture extends PageFixture {
//#endregion
constructor(page: Page, testName: string) {
constructor({ page, testName, navigator }: SessionFixtureInit) {
super({ page, testName });
this.navigator = navigator;
}
//#region Specific interactions
@@ -89,13 +95,7 @@ export class SessionFixture extends PageFixture {
await this.$submitButton.click();
const expectedPathname = typeof to === "string" ? to : to.pathname;
this.logger.info(`Waiting for URL to change to ${expectedPathname}`);
await this.page.waitForURL(`**${expectedPathname}**`);
this.logger.info(`URL changed to ${this.page.url()}`);
await this.navigator.waitForPathname(to);
}
//#endregion
+10 -4
View File
@@ -3,6 +3,7 @@
*/
import { FormFixture } from "#e2e/fixtures/FormFixture";
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
import { PointerFixture } from "#e2e/fixtures/PointerFixture";
import { SessionFixture } from "#e2e/fixtures/SessionFixture";
@@ -13,6 +14,7 @@ export { expect } from "@playwright/test";
/* eslint-disable react-hooks/rules-of-hooks */
interface E2EFixturesTestScope {
navigator: NavigatorFixture;
session: SessionFixture;
pointer: PointerFixture;
form: FormFixture;
@@ -23,15 +25,19 @@ interface E2EWorkerScope {
}
export const test = base.extend<E2EFixturesTestScope, E2EWorkerScope>({
session: async ({ page }, use, { title }) => {
await use(new SessionFixture(page, title));
navigator: async ({ page }, use, { title }) => {
await use(new NavigatorFixture(page, title));
},
session: async ({ page, navigator }, use, { title: testName }) => {
await use(new SessionFixture({ page, testName, navigator }));
},
form: async ({ page }, use, { title }) => {
await use(new FormFixture(page, title));
},
pointer: async ({ page }, use, { title }) => {
await use(new PointerFixture({ page, testName: title }));
pointer: async ({ page }, use, { title: testName }) => {
await use(new PointerFixture({ page, testName }));
},
});
+22 -27
View File
@@ -5,6 +5,8 @@ import "#elements/chips/Chip";
import "#elements/chips/ChipGroup";
import "#elements/forms/HorizontalFormElement";
import "#elements/forms/SearchSelect/index";
import "#components/ak-text-input";
import "#components/ak-switch-input";
import { DEFAULT_CONFIG } from "#common/api/config";
@@ -67,34 +69,27 @@ export class GroupForm extends ModelForm<Group, string> {
}
renderForm(): TemplateResult {
return html` <ak-form-element-horizontal label=${msg("Name")} required name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="isSuperuser">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.instance?.isSuperuser ?? false}
/>
<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 superuser")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg("Users added to this group will be superusers.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Parent")} name="parent">
return html` <ak-text-input
name="name"
required
placeholder=${msg("Type a group name...")}
value="${ifDefined(this.instance?.name)}"
label=${msg("Group Name")}
autocomplete="off"
spellcheck="false"
></ak-text-input>
<ak-switch-input
name="isSuperuser"
label=${msg("Superuser Privileges")}
?checked=${this.instance?.isSuperuser ?? false}
help=${msg("Whether users added to this group will have superuser privileges.")}
>
</ak-switch-input>
<ak-form-element-horizontal label=${msg("Parent Group")} name="parent">
<ak-search-select
placeholder=${msg("Select an optional parent group...")}
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
+11 -5
View File
@@ -13,7 +13,7 @@ import { SlottedTemplateResult } from "#elements/types";
import { CoreApi, Group } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { msg, str } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@@ -22,6 +22,8 @@ export class GroupListPage extends TablePage<Group> {
checkbox = true;
clearOnRefresh = true;
protected override searchEnabled = true;
public searchPlaceholder = msg("Search for a group by name…");
public searchLabel = msg("Group Search");
public pageTitle = msg("Groups");
public pageDescription = msg(
"Group users together and give them permissions based on the membership.",
@@ -70,7 +72,11 @@ export class GroupListPage extends TablePage<Group> {
row(item: Group): SlottedTemplateResult[] {
return [
html`<a href="#/identity/groups/${item.pk}">${item.name}</a>`,
html`<a
href="#/identity/groups/${item.pk}"
aria-label=${msg(str`View details of group "${item.name}"`)}
>${item.name}</a
>`,
html`${item.parentName || msg("-")}`,
html`${Array.from(item.users || []).length}`,
html`<ak-status-label type="neutral" ?good=${item.isSuperuser}></ak-status-label>`,
@@ -92,10 +98,10 @@ export class GroupListPage extends TablePage<Group> {
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit">${msg("Create")}</span>
<span slot="header">${msg("Create Group")}</span>
<span slot="submit">${msg("Create Group")}</span>
<span slot="header">${msg("New Group")}</span>
<ak-group-form slot="form"> </ak-group-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 Group")}</button>
</ak-forms-modal>
`;
}
+11 -10
View File
@@ -22,6 +22,9 @@ type UserListRequestFilter = Partial<Pick<CoreUsersListRequest, "isActive">>;
@customElement("ak-group-member-select-table")
export class MemberSelectTable extends TableModal<User> {
public override searchPlaceholder = msg("Search for users by username or display name...");
public override searchLabel = msg("Search Users");
public override label = msg("Select Users");
static styles = [
...super.styles,
css`
@@ -113,13 +116,13 @@ export class MemberSelectTable extends TableModal<User> {
}
renderModalInner(): TemplateResult {
return html`<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
return html`<div 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">${msg("Select users to add")}</h1>
<h1 id="modal-title" class="pf-c-title pf-m-2xl">${msg("Select users")}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">${this.renderTable()}</section>
<footer class="pf-c-modal-box__footer">
</div>
<div class="pf-c-modal-box__body pf-m-light">${this.renderTable()}</div>
<fieldset name="actions" class="pf-c-modal-box__footer">
<ak-spinner-button
.callAction=${() => {
return this.confirm(this.selectedElements).then(() => {
@@ -127,18 +130,16 @@ export class MemberSelectTable extends TableModal<User> {
});
}}
class="pf-m-primary"
>${msg("Confirm")}</ak-spinner-button
>
${msg("Add")} </ak-spinner-button
>&nbsp;
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-secondary"
>${msg("Cancel")}</ak-spinner-button
>
${msg("Cancel")}
</ak-spinner-button>
</footer>`;
</fieldset>`;
}
}
+92 -55
View File
@@ -27,6 +27,8 @@ import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/tabl
import { SlottedTemplateResult } from "#elements/types";
import { UserOption } from "#elements/user/utils";
import { AKLabel } from "#components/ak-label";
import { CoreApi, CoreUsersListTypeEnum, Group, SessionUser, User } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
@@ -65,21 +67,38 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
}
renderForm(): TemplateResult {
return html` <ak-form-element-horizontal label=${msg("Users to add")} name="users">
// TODO: The `form-control-sibling` container is a workaround to get the
// table to allow the table to appear as an inline-block element next to the input group.
// This should be fixed by moving the `@container` query off `:host`.
return html` <ak-form-element-horizontal name="users">
<div slot="label" class="pf-c-form__group-label">
${AKLabel({ htmlFor: "assign-users-button" }, msg("Users"))}
</div>
<div class="pf-c-input-group">
<ak-group-member-select-table
.confirm=${(items: User[]) => {
this.usersToAdd = items;
this.requestUpdate();
return Promise.resolve();
}}
>
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
<pf-tooltip position="top" content=${msg("Add users")}>
<i class="fas fa-plus" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-group-member-select-table>
<div class="form-control-sibling">
<ak-group-member-select-table
.confirm=${(items: User[]) => {
this.usersToAdd = items;
this.requestUpdate();
return Promise.resolve();
}}
>
<button
slot="trigger"
class="pf-c-button pf-m-control"
type="button"
id="assign-users-button"
aria-haspopup="dialog"
aria-label=${msg("Open user selection dialog")}
>
<pf-tooltip position="top" content=${msg("Add users")}>
<i class="fas fa-plus" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-group-member-select-table>
</div>
<div class="pf-c-form-control">
<ak-chip-group>
${this.usersToAdd.map((user) => {
@@ -104,6 +123,10 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
@customElement("ak-user-related-list")
export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Table<User>)) {
public override searchPlaceholder = msg("Search for users by username or display name...");
public override searchLabel = msg("Group User Search");
public override label = msg("Group Users");
expandable = true;
checkbox = true;
clearOnRefresh = true;
@@ -378,8 +401,8 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
return html`
${this.targetGroup
? html`<ak-forms-modal>
<span slot="submit">${msg("Add")}</span>
<span slot="header">${msg("Add User")}</span>
<span slot="submit">${msg("Assign")}</span>
<span slot="header">${msg("Assign Additional Users")}</span>
${this.targetGroup.isSuperuser
? html`
<div class="pf-c-banner pf-m-warning" slot="above-form">
@@ -397,15 +420,29 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
</ak-forms-modal>`
: nothing}
<ak-dropdown class="pf-c-dropdown">
<button class="pf-m-secondary pf-c-dropdown__toggle" type="button">
<span class="pf-c-dropdown__toggle-text">${msg("Create user")}</span>
<button
class="pf-m-secondary pf-c-dropdown__toggle"
type="button"
id="add-user-toggle"
aria-haspopup="menu"
aria-controls="add-user-menu"
tabindex="0"
>
<span class="pf-c-dropdown__toggle-text">${msg("Add new user")}</span>
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
</button>
<ul class="pf-c-dropdown__menu" hidden>
<li>
<ul
class="pf-c-dropdown__menu"
hidden
role="menu"
id="add-user-menu"
aria-labelledby="add-user-toggle"
tabindex="-1"
>
<li role="presentation">
<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>
${this.targetGroup
? html`
<div class="pf-c-banner pf-m-info" slot="above-form">
@@ -416,18 +453,18 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
`
: nothing}
<ak-user-form .group=${this.targetGroup} slot="form"> </ak-user-form>
<a slot="trigger" class="pf-c-dropdown__menu-item">
${msg("Create user")}
<a role="menuitem" slot="trigger" class="pf-c-dropdown__menu-item">
${msg("New user...")}
</a>
</ak-forms-modal>
</li>
<li>
<li role="presentation">
<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>
${this.targetGroup
? html`
<div class="pf-c-banner pf-m-info" slot="above-form">
@@ -439,8 +476,8 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
: nothing}
<ak-user-service-account-form .group=${this.targetGroup} slot="form">
</ak-user-service-account-form>
<a slot="trigger" class="pf-c-dropdown__menu-item">
${msg("Create Service account")}
<a role="menuitem" slot="trigger" class="pf-c-dropdown__menu-item">
${msg("New service account...")}
</a>
</ak-forms-modal>
</li>
@@ -451,34 +488,34 @@ 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" id="hide-service-accounts-label">
<input
id="hide-service-accounts"
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>`;
}
}
+6
View File
@@ -253,6 +253,12 @@ html > form > input {
/* #region Fields */
fieldset[name="actions"] {
border: none;
display: flex;
gap: var(--pf-global--spacer--sm);
}
/* #region Switch */
.pf-c-switch {
--pf-c-switch__input--focus__toggle--OutlineWidth: 0;
+7 -1
View File
@@ -220,10 +220,16 @@ select.pf-c-form-control {
--pf-c-form-control--disabled--BackgroundColor: var(--ak-dark-background-light);
}
.pf-c-switch__input:checked ~ .pf-c-switch__label {
/* #endregion */
/* #region Switch */
.pf-c-switch__label {
--pf-c-switch__input--not-checked__label--Color: var(--ak-dark-foreground);
--pf-c-switch__input--checked__label--Color: var(--ak-dark-foreground);
}
/* #endregion */
/* select toggle */
.pf-c-select__toggle::before {
--pf-c-select__toggle--before--BorderTopColor: var(--ak-dark-background-lighter);
+17 -10
View File
@@ -11,32 +11,39 @@ export class DropdownButton extends AKElement {
constructor() {
super();
window.addEventListener(EVENT_REFRESH, this.clickHandler);
window.addEventListener(EVENT_REFRESH, this.show);
}
clickHandler = (): void => {
if (!this.menu) {
return;
}
public show = (): void => {
if (!this.menu) return;
this.menu.hidden = true;
};
connectedCallback() {
super.connectedCallback();
this.menu = this.querySelector<HTMLElement>(".pf-c-dropdown__menu");
const menu = this.querySelector<HTMLElement>(".pf-c-dropdown__menu");
if (!menu) {
console.warn("authentik/dropdown: No menu found");
}
this.menu = menu;
this.querySelectorAll("button.pf-c-dropdown__toggle").forEach((btn) => {
btn.addEventListener("click", () => {
if (!this.menu) {
return;
}
if (!this.menu) return;
this.menu.hidden = !this.menu.hidden;
btn.ariaExpanded = (!this.menu.hidden).toString();
});
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_REFRESH, this.clickHandler);
window.removeEventListener(EVENT_REFRESH, this.show);
}
render(): TemplateResult {
@@ -39,12 +39,12 @@ const buttonStyles = [
`,
];
const StatusMap = new Map<TaskStatus, string>([
[TaskStatus.INITIAL, ""],
[TaskStatus.PENDING, PROGRESS_CLASS],
[TaskStatus.COMPLETE, SUCCESS_CLASS],
[TaskStatus.ERROR, ERROR_CLASS],
]);
const StatusMap = {
[TaskStatus.INITIAL]: "",
[TaskStatus.PENDING]: PROGRESS_CLASS,
[TaskStatus.COMPLETE]: SUCCESS_CLASS,
[TaskStatus.ERROR]: ERROR_CLASS,
} as const satisfies Record<TaskStatus, string>;
const SPINNER_TIMEOUT = 1000 * 1.5; // milliseconds
@@ -131,7 +131,7 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
get buttonClasses() {
return [
...this.classList,
StatusMap.get(this.actionTask.status),
StatusMap[this.actionTask.status],
this.actionTask.status === TaskStatus.INITIAL ? "" : "working",
]
.join(" ")
@@ -146,9 +146,12 @@ export abstract class BaseTaskButton extends CustomEmitterElement(AKElement) {
@click=${this.onClick}
type="button"
aria-label=${ifPresent(this.label)}
aria-busy=${this.actionTask.status === TaskStatus.PENDING ? "true" : "false"}
?disabled=${this.disabled}
>
${this.actionTask.render({ pending: () => this.spinner })}
${this.actionTask.render({
pending: () => this.spinner,
})}
<slot></slot>
</button>`;
}
+4 -1
View File
@@ -374,7 +374,10 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
return response;
})
.catch(async (error: unknown) => {
if (error instanceof PreventFormSubmit && error.element) {
if (
error instanceof PreventFormSubmit &&
error.element instanceof HorizontalFormElement
) {
error.element.errorMessages = [error.message];
}
+18 -7
View File
@@ -115,16 +115,27 @@ export class ModalForm extends ModalButton {
<section class="pf-c-modal-box__body" @scroll=${this.#scrollListener}>
<slot name="form"></slot>
</section>
<footer class="pf-c-modal-box__footer">
<fieldset name="actions" class="pf-c-modal-box__footer">
<legend class="sr-only">${msg("Form actions")}</legend>
${this.showSubmitButton
? html`<ak-spinner-button .callAction=${this.#confirm} class="pf-m-primary">
<slot name="submit"></slot> </ak-spinner-button
>&nbsp;`
? html`<button
type="button"
@click=${this.#confirm}
class="pf-c-button pf-m-primary"
aria-description=${msg("Submit action")}
>
<slot name="submit"></slot>
</button>`
: nothing}
<ak-spinner-button .callAction=${this.#cancel} class="pf-m-secondary">
<button
type="button"
aria-description=${msg("Cancel action")}
@click=${this.#cancel}
class="pf-c-button pf-m-secondary"
>
${this.cancelText}
</ak-spinner-button>
</footer>`;
</button>
</fieldset>`;
}
}
@@ -156,8 +156,12 @@ export abstract class SearchSelectBase<T>
//#endregion
public toForm(): string {
if (!this.objects) {
throw new PreventFormSubmit(msg("Loading options..."));
if (!this.objects && !this.blankable) {
// TODO: The loading state needs more exposure to forms.
// For E2E tests that run significantly faster than humans,
// there isn't enough context to know that the data is still being fetched.
throw new PreventFormSubmit("SearchSelect has not yet loaded data", this);
}
return this.value(this.selectedObject) || "";
}
@@ -243,7 +247,19 @@ export abstract class SearchSelectBase<T>
return;
}
const selected = this.objects?.find((obj) => this.value(obj) === value) || null;
const selected =
this.objects?.find((obj) => {
// TODO: Despite the return of `value()` being a string,
// a lack of type on the property can lead to non-string returns,
// a common occurrence in the user primary keys.
//
// We fix this by forcing a string cast here.
// Remove this after migrating to Lit JSX.
const serialized = `${this.value(obj)}`;
return serialized && serialized === value;
}) || null;
if (!selected) {
console.warn(`ak-search-select: No corresponding object found for value (${value}`);
+1 -3
View File
@@ -1,9 +1,7 @@
import { HorizontalFormElement } from "#elements/forms/HorizontalFormElement";
export class PreventFormSubmit {
// Stub class which can be returned by form elements to prevent the form from submitting
constructor(
public message: string,
public element?: HorizontalFormElement,
public element?: Element,
) {}
}
+7
View File
@@ -178,6 +178,13 @@ export abstract class Table<T extends object>
}
}
}
/**
* TODO: Remove after <dialog> modals are implemented.
*/
.pf-c-dropdown__menu:has(ak-forms-modal) {
z-index: var(--pf-global--ZIndex--lg);
}
`,
];
+7 -1
View File
@@ -100,7 +100,13 @@ export abstract class TableModal<T extends object> extends Table<T> {
protected renderModal(): SlottedTemplateResult {
return html`<div class="pf-c-backdrop" @click=${this.#backdropListener}>
<div class="pf-l-bullseye">
<div class="pf-c-modal-box ${this.size}" role="dialog" aria-modal="true">
<div
class="pf-c-modal-box ${this.size}"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<button
@click=${this.#closeListener}
class="pf-c-button pf-m-plain"
@@ -64,14 +64,29 @@ export class MFADevicesPage extends Table<Device> {
return stage.configureUrl;
});
return html`<ak-dropdown class="pf-c-dropdown">
<button class="pf-m-primary pf-c-dropdown__toggle" type="button">
<button
class="pf-m-primary pf-c-dropdown__toggle"
type="button"
id="add-mfa-toggle"
aria-haspopup="menu"
aria-controls="add-mfa-menu"
tabindex="0"
>
<span class="pf-c-dropdown__toggle-text">${msg("Enroll")}</span>
<i class="fas fa-caret-down pf-c-dropdown__toggle-icon" aria-hidden="true"></i>
</button>
<ul class="pf-c-dropdown__menu" hidden>
<ul
class="pf-c-dropdown__menu"
hidden
role="menu"
id="add-mfa-menu"
aria-labelledby="add-mfa-toggle"
tabindex="-1"
>
${settings.map((stage) => {
return html`<li>
return html`<li role="presentation">
<a
role="menuitem"
href="${ifDefined(stage.configureUrl)}${AndNext(
`${globalAK().api.relBase}if/user/#/settings;${JSON.stringify({
page: "page-mfa",
+176
View File
@@ -0,0 +1,176 @@
import { expect, test } from "#e2e";
import { randomName } from "#e2e/utils/generators";
import { IDGenerator } from "@goauthentik/core/id";
import { series } from "@goauthentik/core/promises";
import { snakeCase } from "change-case";
test.describe("Groups", () => {
const adminGroupName = "authentik Admins";
const adminUsername = "akadmin";
const usernames = new Map<string, string>();
const groupNames = new Map<string, string>();
//#region Lifecycle
test.beforeEach("Prepare user", async ({ session }, { testId }) => {
const seed = IDGenerator.randomID(6);
const groupName = `${randomName(seed)} (${seed})`;
groupNames.set(testId, groupName);
usernames.set(testId, snakeCase(groupName));
await test.step("Authenticate", async () => {
await session.login({
to: "/if/admin/#/identity/groups",
});
});
});
//#endregion
//#region Tests
// TODO: The use of `force: true` is a temporary workaround for
// buttons with slotted content, which are not considered visible by
// Playwright. This should be removed after native dialog modals are implemented.
test("Creating a user within the admin group", async ({
navigator,
form,
pointer,
page,
}, testInfo) => {
const { fill, search } = form;
const { click } = pointer;
const displayName = groupNames.get(testInfo.testId)!;
const username = usernames.get(testInfo.testId)!;
const adminsURL = await test.step("Find admin group via search", async () => {
const $adminGroupRow = await search(adminGroupName);
await expect($adminGroupRow, "Admin group is visible").toBeVisible();
const groupLink = $adminGroupRow.getByRole("link", { name: "view details" });
await expect(groupLink, "Admin group link is visible").toBeVisible();
return groupLink.evaluate((el: HTMLAnchorElement) => el.href);
});
expect(adminsURL, "Admin group link has href").not.toBeNull();
await navigator.navigate(adminsURL);
await test.step("User creation", async () => {
await click("Users", "tab");
const wizard = page.getByRole("dialog", { name: "New User" });
await expect(wizard, "Wizard is initially closed").toBeHidden();
await click("Add new user", "button");
await click("New user...", "menuitem");
await expect(wizard, "Wizard opens").toBeVisible();
await series(
[fill, /^Username/, username],
[fill, /^Display Name/, displayName],
[fill, /^Email Address/, `${username}@example.com`],
);
await page.getByRole("button", { name: "Create User" }).click({ force: true });
await expect(wizard, "Wizard closes after creating user").toBeHidden();
});
await test.step("Verify user creation", async () => {
const $user = await test.step("Find user via search", () => search(username));
await expect($user, "User is visible").toBeVisible();
});
});
test("Simple group", async ({ form, pointer, page }, testInfo) => {
const groupName = groupNames.get(testInfo.testId)!;
const { fill, search } = form;
const { click } = pointer;
const newGroupModal = page.getByRole("dialog", { name: "New Group" });
await test.step("Group Creation", async () => {
await expect(newGroupModal, "Wizard is initially closed").toBeHidden();
await click("New Group", "button");
await expect(newGroupModal, "Wizard opens").toBeVisible();
await series(
// ---
[fill, /^Group Name/, groupName],
);
const createButton = page
.getByRole("group", { name: "Form actions" })
.getByRole("button", { name: "Create Group" });
await expect(createButton, "Create button is visible").toBeVisible();
await createButton.evaluate((element: HTMLButtonElement) => element.click());
await expect(newGroupModal, "Wizard closes after creating group").toBeHidden();
});
await test.step("Verify group creation", async () => {
const groupRow = await test.step("Find group via search", () => search(groupName));
await expect(groupRow, "Group is visible").toBeVisible();
await groupRow.getByRole("link", { name: "view details" }).click();
});
await test.step("Assigning a user to the group", async () => {
const assignUsersModal = page.getByRole("dialog", { name: "Assign Additional Users" });
const selectUsersModal = page.getByRole("dialog", { name: "Select users" });
await series(
// ---
[click, "users", "tab"],
[click, "Add existing user", "button"],
[click, "Open user selection dialog", "button"],
);
const adminRow = await test.step("Find admin via search", () =>
search(adminUsername, selectUsersModal));
await expect(adminRow, "Admin is visible").toBeVisible();
await adminRow.getByRole("checkbox").check();
const confirmButton = selectUsersModal.getByRole("button", { name: "Confirm" });
await expect(confirmButton, "Confirm button is visible").toBeVisible();
await confirmButton.evaluate((element: HTMLButtonElement) => element.click());
const assignButton = assignUsersModal.getByRole("button", { name: "Assign" });
await expect(assignButton, "Assign button is visible").toBeVisible();
await assignButton.evaluate((element: HTMLButtonElement) => element.click());
await expect(assignUsersModal, "Assign users modal closes").toBeHidden();
await test.step("Verify admin user assignment", async () => {
// eslint-disable-next-line max-nested-callbacks
const groupRow = await test.step("Find group via search", () =>
search(adminUsername));
await expect(groupRow, "Group is visible").toBeVisible();
});
});
});
//#endregion
});
+3 -40
View File
@@ -1,6 +1,5 @@
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";
@@ -45,49 +44,13 @@ test.describe("Provider Wizard", () => {
});
});
test.afterEach("Verification", async ({ page, form }, { testId }) => {
test.afterEach("Verification", async ({ form }, { testId }) => {
//#region Confirm provider
const providerName = providerNames.get(testId)!;
const { fill, findTextualInput } = form;
const { search } = form;
const $provider = await test.step("Find provider via search", async () => {
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.
// We attempt several times to find the provider to avoid flakiness.
const tries = 10;
let found = false;
for (let i = 0; i < tries; i++) {
await fill(providerSearch, providerName);
await providerSearch.press("Enter");
const $rowEntry = page.getByRole("row", {
name: providerName,
});
ConsoleLogger.info(
`${i + 1}/${tries} Waiting for provider ${providerName} to appear in the table`,
);
found = await $rowEntry
.waitFor({
timeout: 1500,
})
.then(() => true)
.catch(() => false);
if (found) {
ConsoleLogger.info(`Provider ${providerName} found in the table`);
return $rowEntry;
}
}
throw new Error(`Provider ${providerName} not found in the table`);
});
const $provider = await test.step("Find provider via search", () => search(providerName));
await expect($provider, "Provider is visible").toBeVisible();
+108
View File
@@ -0,0 +1,108 @@
import { expect, test } from "#e2e";
import { randomName } from "#e2e/utils/generators";
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 ({ session }, { testId }) => {
const seed = IDGenerator.randomID(6);
const displayName = `${randomName(seed)} (${seed})`;
displayNames.set(testId, displayName);
usernames.set(testId, snakeCase(displayName));
await test.step("Authenticate", async () => {
await session.login({
to: "/if/admin/#/identity/users",
});
});
});
test.afterEach("Verification", async ({ form }, { testId }) => {
//#region Confirm user
const username = usernames.get(testId)!;
const { search } = form;
const $user = await test.step("Find user via search", () => search(username));
await expect($user, "User is visible").toBeVisible();
//#endregion
});
//#endregion
//#region Tests
// TODO: The use of `force: true` is a temporary workaround for
// buttons with slotted content, which are not considered visible by
// Playwright. This should be removed after native dialog modals are implemented.
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 initially closed").toBeHidden();
await click("New User", "button");
await expect(wizard, "Wizard opens").toBeVisible();
await series(
[fill, /^Username/, username],
[fill, /^Display Name/, displayName],
[fill, /^Email Address/, `${username}@example.com`],
);
await page.getByRole("button", { name: "Create User" }).click({ force: true });
await expect(wizard, "Wizard closes after creating user").toBeHidden();
});
test("Service user", async ({ form, pointer, page }, testInfo) => {
const username = usernames.get(testInfo.testId)!;
const { fill } = form;
const { click } = pointer;
const wizard = page.getByRole("dialog", { name: "New Service Account" });
await expect(wizard, "Wizard is initially closed").toBeHidden();
await click("New Service Account", "button");
await expect(wizard, "Wizard opens").toBeVisible();
await series(
// ---
[fill, /^Username/, username],
);
await page.getByRole("button", { name: "Create Service Account" }).click({ force: true });
await expect(wizard, "Wizard is open after creating service account").toBeVisible();
await click("Close", "button");
const userPathsTree = page.getByRole("tree", { name: "User paths" });
await expect(userPathsTree, "User paths tree is visible").toBeVisible();
await userPathsTree.getByRole("button", { name: `Select "Root"`, exact: true }).click();
});
//#endregion
});