mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 }));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<ak-spinner-button
|
||||
.callAction=${async () => {
|
||||
this.open = false;
|
||||
}}
|
||||
class="pf-m-secondary"
|
||||
>${msg("Cancel")}</ak-spinner-button
|
||||
>
|
||||
${msg("Cancel")}
|
||||
</ak-spinner-button>
|
||||
</footer>`;
|
||||
</fieldset>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
<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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
> `
|
||||
? 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,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,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user