diff --git a/web/e2e/fixtures/FormFixture.ts b/web/e2e/fixtures/FormFixture.ts index a1130b53e6..71a620343a 100644 --- a/web/e2e/fixtures/FormFixture.ts +++ b/web/e2e/fixtures/FormFixture.ts @@ -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 => { + 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. * diff --git a/web/e2e/fixtures/NavigatorFixture.ts b/web/e2e/fixtures/NavigatorFixture.ts new file mode 100644 index 0000000000..53d55cd850 --- /dev/null +++ b/web/e2e/fixtures/NavigatorFixture.ts @@ -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 => { + 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 => { + if (!to) { + throw new TypeError("No URL or pathname given to navigate to."); + } + + await this.page.goto(to.toString()); + + await this.waitForPathname(to); + }; +} diff --git a/web/e2e/fixtures/PageFixture.ts b/web/e2e/fixtures/PageFixture.ts index 7b2e321ecb..0321c4f327 100644 --- a/web/e2e/fixtures/PageFixture.ts +++ b/web/e2e/fixtures/PageFixture.ts @@ -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; diff --git a/web/e2e/fixtures/PointerFixture.ts b/web/e2e/fixtures/PointerFixture.ts index 5121311216..d37d1d2e59 100644 --- a/web/e2e/fixtures/PointerFixture.ts +++ b/web/e2e/fixtures/PointerFixture.ts @@ -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, diff --git a/web/e2e/fixtures/SessionFixture.ts b/web/e2e/fixtures/SessionFixture.ts index 753a7b13ea..afdb8ed376 100644 --- a/web/e2e/fixtures/SessionFixture.ts +++ b/web/e2e/fixtures/SessionFixture.ts @@ -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 diff --git a/web/e2e/index.ts b/web/e2e/index.ts index d6d291f792..a6c6ccf5b8 100644 --- a/web/e2e/index.ts +++ b/web/e2e/index.ts @@ -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({ - 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 })); }, }); diff --git a/web/src/admin/groups/GroupForm.ts b/web/src/admin/groups/GroupForm.ts index c53fce469f..6e8bcd3e0f 100644 --- a/web/src/admin/groups/GroupForm.ts +++ b/web/src/admin/groups/GroupForm.ts @@ -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 { } renderForm(): TemplateResult { - return html` - - - - -

- ${msg("Users added to this group will be superusers.")} -

-
- + return html` + + + + + => { const args: CoreGroupsListRequest = { ordering: "name", diff --git a/web/src/admin/groups/GroupListPage.ts b/web/src/admin/groups/GroupListPage.ts index b3ca298303..921e340a0b 100644 --- a/web/src/admin/groups/GroupListPage.ts +++ b/web/src/admin/groups/GroupListPage.ts @@ -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 { 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 { row(item: Group): SlottedTemplateResult[] { return [ - html`${item.name}`, + html`${item.name}`, html`${item.parentName || msg("-")}`, html`${Array.from(item.users || []).length}`, html``, @@ -92,10 +98,10 @@ export class GroupListPage extends TablePage { renderObjectCreate(): TemplateResult { return html` - ${msg("Create")} - ${msg("Create Group")} + ${msg("Create Group")} + ${msg("New Group")} - + `; } diff --git a/web/src/admin/groups/MemberSelectModal.ts b/web/src/admin/groups/MemberSelectModal.ts index 822b66f394..65387faee2 100644 --- a/web/src/admin/groups/MemberSelectModal.ts +++ b/web/src/admin/groups/MemberSelectModal.ts @@ -22,6 +22,9 @@ type UserListRequestFilter = Partial>; @customElement("ak-group-member-select-table") export class MemberSelectTable extends TableModal { + 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 { } renderModalInner(): TemplateResult { - return html`
+ return html`
-

${msg("Select users to add")}

+

${msg("Select users")}

-
-
${this.renderTable()}
-
+ +
${this.renderTable()}
+
`; + `; } } diff --git a/web/src/admin/groups/RelatedUserList.ts b/web/src/admin/groups/RelatedUserList.ts index 9e2149727f..76e51a636b 100644 --- a/web/src/admin/groups/RelatedUserList.ts +++ b/web/src/admin/groups/RelatedUserList.ts @@ -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` + // 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` +
+ ${AKLabel({ htmlFor: "assign-users-button" }, msg("Users"))} +
+
- { - this.usersToAdd = items; - this.requestUpdate(); - return Promise.resolve(); - }} - > - - +
+ { + this.usersToAdd = items; + this.requestUpdate(); + return Promise.resolve(); + }} + > + + +
${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)) { + 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` - ${msg("Add")} - ${msg("Add User")} + ${msg("Assign")} + ${msg("Assign Additional Users")} ${this.targetGroup.isSuperuser ? html`
@@ -397,15 +420,29 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl ` : nothing} - -