mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web/table: fetch on first render when already visible (#22376)
* web/table: fetch on first render when already visible Tables inside `<ak-modal>` rendered empty until the user clicked the refresh button. The 2026.5 RC native-`<dialog>` migration taught `AKModal.updated()` to force `visible = true` on its slotted child, but `Table.firstUpdated()` was delegating to `#synchronizeRefreshSchedule()`, which only flushes a *previously deferred* refresh. With visibility forced on before the first update cycle, no deferred refresh was ever queued, so the synchronizer no-op'd and nothing fetched. Switch the first-update hook to call `fetch()` directly. `fetch()` already handles both states correctly: if the table is visible it issues the request immediately, and if it isn't it queues the deferred refresh that the synchronizer flushes when visibility flips on. Beyond the modal case this also covers any future caller that mounts a Table already-visible. Reproduced and verified against the user-library RAC endpoint launcher (the surface from the beta report). Added a Playwright e2e (`rac-launch-modal.test.ts`) that seeds a RAC provider + two endpoints via the API, opens the launcher, and asserts the endpoint rows appear without a manual refresh — fails on `main`, passes with this change. A 2026.5 backport will follow as a separate PR. Co-Authored-By: Agent (authentik-m-triage-rac-proper-shared-lilac) <279763771+playpen-agent@users.noreply.github.com> * web/test: silence cspell on AK_TEST_BOOTSTRAP_TOKEN fallback `changeme` in the playpen-specific default for `AK_TEST_BOOTSTRAP_TOKEN` trips the spellcheck lint job. Add an inline `cspell:ignore` directive so the fallback can stay (CI sets the env var so the default is only used locally inside playpen sandboxes). * Flesh out RAC test coverage. * Use simple search for applications list. * Add order. * Ignore playwright result. * Remove unused. * Tidy for test. * Fix test selectors. * Fix overlap. * Defer to connected callback. * Use consistent Patternfly input outline. * Clean up labels. * Only trigger navigation on non-current entries. * Ensure that selected type is retained. --------- Co-authored-by: Agent (authentik-m-triage-rac-proper-shared-lilac) <279763771+playpen-agent@users.noreply.github.com>
This commit is contained in:
@@ -32,6 +32,7 @@ lib-cov
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
playwright-report
|
||||
playwright-traces
|
||||
test-results
|
||||
*.lcov
|
||||
|
||||
|
||||
@@ -22,31 +22,15 @@ export class FormFixture extends PageFixture {
|
||||
fieldName: string | RegExp,
|
||||
context: LocatorContext = this.page,
|
||||
) => {
|
||||
const control = context
|
||||
.getByLabel(fieldName, { exact: true })
|
||||
.filter({
|
||||
hasNot: context.getByRole("presentation"),
|
||||
})
|
||||
.and(context.locator(":not(button)"))
|
||||
.or(
|
||||
context.getByRole("textbox", {
|
||||
name: fieldName,
|
||||
}),
|
||||
)
|
||||
.or(
|
||||
context.getByRole("spinbutton", {
|
||||
name: fieldName,
|
||||
}),
|
||||
);
|
||||
const textbox = context.getByRole("textbox", { name: fieldName });
|
||||
const searchbox = context.getByRole("searchbox", { name: fieldName });
|
||||
const spinbutton = context.getByRole("spinbutton", { name: fieldName });
|
||||
// Comboboxes (e.g. the Query Language input) wrap an inner textbox.
|
||||
const comboboxTextbox = context
|
||||
.getByRole("combobox", { name: fieldName })
|
||||
.getByRole("textbox");
|
||||
|
||||
const role = await control.getAttribute("role");
|
||||
|
||||
if (role === "combobox") {
|
||||
// Comboboxes, such as our Query Language input need additional handling...
|
||||
const textbox = control.getByRole("textbox");
|
||||
|
||||
return textbox;
|
||||
}
|
||||
const control = textbox.or(searchbox).or(spinbutton).or(comboboxTextbox);
|
||||
|
||||
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
|
||||
|
||||
@@ -2,12 +2,6 @@ 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;
|
||||
|
||||
@@ -56,6 +56,14 @@ const eslintConfig = defineConfig(
|
||||
},
|
||||
files: ["**/*.d.ts"],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
// Playwright first parameter must be a destructure pattern,
|
||||
// even when not referencing any fixtures.
|
||||
"no-empty-pattern": "off",
|
||||
},
|
||||
files: ["**/*.spec.ts", "**/*.test.ts"],
|
||||
},
|
||||
);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -23,10 +23,16 @@ export default defineConfig({
|
||||
testDir: "./test/browser",
|
||||
fullyParallel: true,
|
||||
forbidOnly: CI,
|
||||
retries: CI ? 2 : 0,
|
||||
workers: CI ? 1 : undefined,
|
||||
retries: CI ? 1 : 0,
|
||||
workers: "50%",
|
||||
maxFailures: CI ? 5 : 2,
|
||||
reporter: CI
|
||||
? "github"
|
||||
? [
|
||||
// ---
|
||||
["github"],
|
||||
["html", { open: "never", outputFolder: "playwright-report" }],
|
||||
["json", { outputFile: "playwright-report/results.json" }],
|
||||
]
|
||||
: [
|
||||
// ---
|
||||
["list", { printSteps: true }],
|
||||
@@ -36,6 +42,8 @@ export default defineConfig({
|
||||
testIdAttribute: "data-test-id",
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: CI ? "retain-on-failure" : "off",
|
||||
colorScheme: "dark",
|
||||
launchOptions: {
|
||||
logger: {
|
||||
|
||||
@@ -13,8 +13,6 @@ import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
import { ModelForm } from "#elements/forms/ModelForm";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { Endpoint, EndpointAuthModeEnum, ProtocolEnum, RacApi } from "@goauthentik/api";
|
||||
|
||||
import YAML from "yaml";
|
||||
@@ -73,38 +71,28 @@ export class EndpointForm extends ModelForm<Endpoint, string> {
|
||||
?autofocus=${!this.instance}
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-form-element-horizontal required name="protocol">
|
||||
${AKLabel(
|
||||
{
|
||||
slot: "label",
|
||||
className: "pf-c-form__group-label",
|
||||
htmlFor: "protocol",
|
||||
required: true,
|
||||
},
|
||||
msg("Protocol"),
|
||||
)}
|
||||
|
||||
<ak-radio
|
||||
id="protocol"
|
||||
required
|
||||
.options=${[
|
||||
{
|
||||
label: msg("RDP"),
|
||||
value: ProtocolEnum.Rdp,
|
||||
},
|
||||
{
|
||||
label: msg("SSH"),
|
||||
value: ProtocolEnum.Ssh,
|
||||
},
|
||||
{
|
||||
label: msg("VNC"),
|
||||
value: ProtocolEnum.Vnc,
|
||||
},
|
||||
]}
|
||||
.value=${this.instance?.protocol}
|
||||
>
|
||||
</ak-radio>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-radio-input
|
||||
label=${msg("Protocol")}
|
||||
name="protocol"
|
||||
required
|
||||
.options=${[
|
||||
{
|
||||
label: msg("RDP"),
|
||||
value: ProtocolEnum.Rdp,
|
||||
},
|
||||
{
|
||||
label: msg("SSH"),
|
||||
value: ProtocolEnum.Ssh,
|
||||
},
|
||||
{
|
||||
label: msg("VNC"),
|
||||
value: ProtocolEnum.Vnc,
|
||||
},
|
||||
]}
|
||||
.value=${this.instance?.protocol}
|
||||
>
|
||||
</ak-radio-input>
|
||||
<ak-text-input
|
||||
label=${msg("Host")}
|
||||
name="host"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "#admin/common/ak-flow-search/ak-flow-search";
|
||||
import "#admin/common/ak-crypto-certificate-search";
|
||||
import "#admin/common/ak-flow-search/ak-branded-flow-search";
|
||||
import "#components/ak-text-input";
|
||||
import "#components/ak-switch-input";
|
||||
import "#elements/CodeMirror";
|
||||
import "#elements/ak-dual-select/ak-dual-select-dynamic-selected-provider";
|
||||
@@ -16,6 +17,8 @@ import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { ModelForm } from "#elements/forms/ModelForm";
|
||||
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
|
||||
import { FlowDesignationEnum, ProvidersApi, RACProvider } from "@goauthentik/api";
|
||||
|
||||
import YAML from "yaml";
|
||||
@@ -54,23 +57,29 @@ export class RACProviderFormPage extends ModelForm<RACProvider, number> {
|
||||
|
||||
protected override renderForm(): TemplateResult {
|
||||
return html`
|
||||
<ak-form-element-horizontal label=${msg("Provider Name")} required name="name">
|
||||
<input
|
||||
type="text"
|
||||
value="${ifDefined(this.instance?.name)}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
placeholder=${msg("Type a provider name...")}
|
||||
spellcheck="false"
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-element-horizontal
|
||||
name="authorizationFlow"
|
||||
label=${msg("Authorization Flow")}
|
||||
<ak-text-input
|
||||
label=${msg("Provider Name")}
|
||||
required
|
||||
>
|
||||
name="name"
|
||||
value="${ifDefined(this.instance?.name)}"
|
||||
placeholder=${msg("Type a provider name...")}
|
||||
spellcheck="false"
|
||||
?autofocus=${!this.instance}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-form-element-horizontal name="authorizationFlow" required>
|
||||
${AKLabel(
|
||||
{
|
||||
className: "pf-c-form__group-label",
|
||||
slot: "label",
|
||||
htmlFor: "authorizationFlow",
|
||||
required: true,
|
||||
},
|
||||
msg("Authorization Flow"),
|
||||
)}
|
||||
<ak-flow-search
|
||||
id="authorizationFlow"
|
||||
label=${msg("Authorization Flow")}
|
||||
flowType=${FlowDesignationEnum.Authorization}
|
||||
.currentFlow=${this.instance?.authorizationFlow}
|
||||
required
|
||||
|
||||
@@ -18,7 +18,7 @@ import { RoleForm } from "#admin/roles/ak-role-form";
|
||||
|
||||
import { RbacApi, Role } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
@@ -83,7 +83,11 @@ export class RoleListPage extends TablePage<Role> {
|
||||
|
||||
row(item: Role): SlottedTemplateResult[] {
|
||||
return [
|
||||
html`<a href="#/identity/roles/${item.pk}">${item.name}</a>`,
|
||||
html`<a
|
||||
href="#/identity/roles/${item.pk}"
|
||||
aria-label=${msg(str`View details of role "${item.name}"`)}
|
||||
>${item.name}</a
|
||||
>`,
|
||||
html`<div class="ak-c-table__actions">${IconEditButton(RoleForm, item.pk)}</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export class AKStageWizard extends CreateWizard {
|
||||
|
||||
public override layout = TypeCreateWizardPageLayouts.list;
|
||||
|
||||
public override groupLabel = msg("Bind New Stage");
|
||||
public override groupLabel = msg("New Stage");
|
||||
public override groupDescription = msg("Select the type of stage you want to create.");
|
||||
|
||||
protected apiEndpoint = async (requestInit?: RequestInit): Promise<TypeCreate[]> => {
|
||||
@@ -75,12 +75,7 @@ export class AKStageWizard extends CreateWizard {
|
||||
return null;
|
||||
}
|
||||
|
||||
return html`<ak-form-group
|
||||
slot="pre-items"
|
||||
label=${msg("Existing Stage")}
|
||||
description=${msg("Bind an existing stage to this flow.")}
|
||||
open
|
||||
>
|
||||
return html`<ak-form-group slot="pre-items" label=${msg("Existing Stage")} open>
|
||||
<ak-radio
|
||||
.options=${[
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ export class InvitationWizardEmailStep extends WizardPage {
|
||||
@state()
|
||||
availableTemplates: TypeCreate[] = [];
|
||||
|
||||
override formatNextLabel(): SlottedTemplateResult {
|
||||
public override formatNextLabel(): SlottedTemplateResult {
|
||||
return html`${msg("Send")}
|
||||
<span class="pf-c-button__icon pf-m-end">
|
||||
<i class="fas fa-paper-plane" aria-hidden="true"></i>
|
||||
|
||||
@@ -79,7 +79,7 @@ export class ServiceAccountResultPage extends WizardPage<UserWizardState> {
|
||||
this.host.cancelable = false;
|
||||
};
|
||||
|
||||
public formatNextLabel(): SlottedTemplateResult | null {
|
||||
public override formatNextLabel(): SlottedTemplateResult | null {
|
||||
return ButtonKindLabelRecord.close();
|
||||
}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export class MessageContainer extends AKElement {
|
||||
public messages: APIMessage[] = [];
|
||||
|
||||
@property({ type: String, reflect: true, useDefault: true })
|
||||
public alignment: MessageContainerAlignment = "bottom-right";
|
||||
public alignment: MessageContainerAlignment = "bottom-left";
|
||||
|
||||
static styles: CSSResult[] = [PFAlertGroup, Styles];
|
||||
|
||||
|
||||
@@ -357,6 +357,12 @@ export abstract class Table<T extends object, D = T>
|
||||
if (this.searchEnabled) {
|
||||
this.search = getURLParam(this.#searchParam, "");
|
||||
}
|
||||
|
||||
// Use `fetch()` rather than `#synchronizeRefreshSchedule()` here: the
|
||||
// latter only flushes a *previously deferred* refresh and would no-op
|
||||
// when a parent (e.g. `AKModal`) has already forced `visible = true`
|
||||
// before the first update cycle, leaving the table empty on open.
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
@@ -403,11 +409,6 @@ export abstract class Table<T extends object, D = T>
|
||||
}
|
||||
}
|
||||
|
||||
protected override firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
this.#synchronizeRefreshSchedule();
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
protected async defaultEndpointConfig(): Promise<BaseTableListRequest> {
|
||||
|
||||
@@ -33,7 +33,6 @@ import { html, PropertyValues } from "lit";
|
||||
import { guard } from "lit-html/directives/guard.js";
|
||||
import { createRef, ref } from "lit-html/directives/ref.js";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { keyed } from "lit/directives/keyed.js";
|
||||
|
||||
export class CreateWizard extends AKElement implements TransclusionChildElement {
|
||||
/**
|
||||
@@ -141,6 +140,10 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
|
||||
return this.wizardRef.value || null;
|
||||
}
|
||||
|
||||
public get pageTypeCreate(): TypeCreateWizardPage | null {
|
||||
return this.pageTypeCreateRef.value || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An optional description to show on the initial page of the wizard,
|
||||
* used to explain the different types or provide general information about the creation process.
|
||||
@@ -177,7 +180,7 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
|
||||
});
|
||||
}
|
||||
|
||||
public override firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
protected override firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
||||
this.refresh();
|
||||
@@ -241,18 +244,25 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
|
||||
* responsible for updating the wizard's steps and validity.
|
||||
*/
|
||||
protected typeSelectListener = ({
|
||||
detail: typeCreate,
|
||||
}: CustomEvent<TypeCreate>): boolean | Promise<boolean> => {
|
||||
this.selectedType = typeCreate;
|
||||
detail: selectedType,
|
||||
}: CustomEvent<TypeCreate | null>): boolean | Promise<boolean> => {
|
||||
this.selectedType = selectedType;
|
||||
|
||||
const { wizard } = this;
|
||||
|
||||
if (!wizard) return false;
|
||||
|
||||
const nextSteps = [...this.initialSteps];
|
||||
|
||||
if (!selectedType) {
|
||||
wizard.steps = nextSteps;
|
||||
wizard.valid = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentSteps = wizard.steps.slice();
|
||||
|
||||
const selectedSteps = this.selectSteps(typeCreate, currentSteps);
|
||||
const nextSteps = [...this.initialSteps];
|
||||
const selectedSteps = this.selectSteps(selectedType, currentSteps);
|
||||
|
||||
const idx = nextSteps.indexOf("initial") + 1;
|
||||
|
||||
@@ -305,34 +315,36 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
|
||||
part="main"
|
||||
.initialSteps=${this.initialSteps}
|
||||
.finalHandler=${this.finalHandler}
|
||||
>
|
||||
${this.renderHeading()}
|
||||
${keyed(
|
||||
this.wizard?.activeStep,
|
||||
html`<ak-wizard-page-type-create
|
||||
${ref(this.pageTypeCreateRef)}
|
||||
slot="initial"
|
||||
.types=${this.creationTypes}
|
||||
layout=${this.layout}
|
||||
group-label=${ifPresent(this.groupLabel)}
|
||||
group-description=${ifPresent(this.groupDescription)}
|
||||
headline=${this.verboseName
|
||||
? msg(str`Choose ${this.verboseName} Type`)
|
||||
: msg("Choose type")}
|
||||
@ak-type-create-select=${this.typeSelectListener}
|
||||
>
|
||||
${this.renderCreateBefore()}
|
||||
${guard([initialPageContent], () => {
|
||||
if (!initialPageContent) {
|
||||
return null;
|
||||
}
|
||||
>${this.renderHeading()}
|
||||
<ak-wizard-page-type-create
|
||||
${ref(this.pageTypeCreateRef)}
|
||||
slot="initial"
|
||||
.types=${this.creationTypes}
|
||||
layout=${this.layout}
|
||||
group-label=${ifPresent(this.groupLabel)}
|
||||
group-description=${ifPresent(this.groupDescription)}
|
||||
headline=${this.verboseName
|
||||
? msg(str`Choose ${this.verboseName} Type`, {
|
||||
id: "wizard.step.choose-type",
|
||||
desc: "Label for the initial step in the creation wizard where the type of the entity being created is selected. The placeholder {entity} is replaced with the singular name of the entity, for example 'Choose User Type' or 'Choose Group Type'.",
|
||||
})
|
||||
: msg("Choose type", {
|
||||
id: "wizard.step.choose-type.generic",
|
||||
desc: "Generic label for the initial step in the creation wizard where the type of the entity being created is selected, used when no singular entity name is provided.",
|
||||
})}
|
||||
@ak-type-create-select=${this.typeSelectListener}
|
||||
>
|
||||
${this.renderCreateBefore()}
|
||||
${guard([initialPageContent], () => {
|
||||
if (!initialPageContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return html`<div>
|
||||
<p>${initialPageContent}</p>
|
||||
</div>`;
|
||||
})}
|
||||
</ak-wizard-page-type-create>`,
|
||||
)}
|
||||
return html`<div>
|
||||
<p>${initialPageContent}</p>
|
||||
</div>`;
|
||||
})}
|
||||
</ak-wizard-page-type-create>
|
||||
${this.renderForms()}
|
||||
</ak-wizard>`;
|
||||
}
|
||||
@@ -350,7 +362,10 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
|
||||
const slotName = formatTypeCreateStepID(type);
|
||||
const entityLabel = selectedType?.name ?? this.verboseName ?? msg("Entity");
|
||||
|
||||
const label = msg(str`${entityLabel} Details`);
|
||||
const label = msg(str`${entityLabel} Details`, {
|
||||
id: "wizard.step.details",
|
||||
desc: `Label for the step in the creation wizard where the details of the entity being created are filled in. The placeholder {entity} is replaced with the name of the entity type, for example 'User Details' or 'Group Details'.`,
|
||||
});
|
||||
|
||||
const content = StrictUnsafe(type.component, props);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { WizardPage } from "#elements/wizard/WizardPage";
|
||||
import { TypeCreate } from "@goauthentik/api";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { css, CSSResult, html } from "lit";
|
||||
import { css, CSSResult, html, PropertyValues } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { guard } from "lit/directives/guard.js";
|
||||
@@ -89,6 +89,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public reset = () => {
|
||||
super.reset();
|
||||
|
||||
@@ -100,7 +102,13 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
|
||||
this.host.valid = !!this.selectedType;
|
||||
};
|
||||
|
||||
#selectDispatch = (type: TypeCreate) => {
|
||||
protected override updated(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.has("selectedType")) {
|
||||
this.#selectDispatch(this.selectedType);
|
||||
}
|
||||
}
|
||||
|
||||
#selectDispatch = (type: TypeCreate | null) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ak-type-create-select", {
|
||||
detail: type,
|
||||
@@ -110,6 +118,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
|
||||
);
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
//#region Grid layout
|
||||
@@ -217,9 +227,9 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
|
||||
aria-describedby=${`${inputID}-description`}
|
||||
@change=${() => {
|
||||
this.selectedType = type;
|
||||
this.#selectDispatch(type);
|
||||
}}
|
||||
?disabled=${disabled}
|
||||
.checked=${selected}
|
||||
/>
|
||||
<div
|
||||
aria-selected="${selected ? "true" : "false"}"
|
||||
|
||||
@@ -519,6 +519,7 @@ export class AKWizard<S = Record<string, unknown>> extends AKElement {
|
||||
type="button"
|
||||
?disabled=${disabled}
|
||||
@click=${() => {
|
||||
if (idx === activeStepIndex) return;
|
||||
this.activeStepElement = stepEl;
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (not (prefers-contrast: more)) {
|
||||
input {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region Modifiers */
|
||||
|
||||
@@ -174,7 +174,6 @@ export class LibraryPage extends WithSession(AKElement) {
|
||||
includeScore: true,
|
||||
shouldSort: true,
|
||||
ignoreFieldNorm: true,
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.3,
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe("Session management", () => {
|
||||
page.getByRole("heading", {
|
||||
level: 1,
|
||||
}),
|
||||
).toHaveText("My applications", {
|
||||
).toHaveText("Application Dashboard", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
+17
-2
@@ -17,8 +17,6 @@ test.describe("Session Lifecycle", () => {
|
||||
test.beforeAll(
|
||||
'Ensure "Enable Remember me on this device" is on for the default identification stage',
|
||||
async ({ browser }, { title: testName }) => {
|
||||
if (Date.now()) return;
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const navigator = new NavigatorFixture(page, testName);
|
||||
@@ -99,6 +97,23 @@ test.describe("Session Lifecycle", () => {
|
||||
await signOutLink.click();
|
||||
|
||||
await navigator.waitForPathname("/if/flow/default-authentication-flow/?next=%2F");
|
||||
await session.$identificationStage.waitFor({ state: "visible" });
|
||||
|
||||
const passwordEmbedded = await session.$passwordField.isVisible();
|
||||
|
||||
if (passwordEmbedded) {
|
||||
// Password is embedded in the identification stage, so the Not-you UI never renders.
|
||||
// Remember-me's only observable effect is the pre-filled username field.
|
||||
await expect(
|
||||
session.$usernameField,
|
||||
"Username pre-filled from remember-me",
|
||||
).toHaveValue(GOOD_USERNAME);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await session.$submitButton.click();
|
||||
await session.$passwordStage.waitFor({ state: "visible" });
|
||||
|
||||
const notYouLink = page.getByRole("link", { name: "Not you?" });
|
||||
|
||||
@@ -85,6 +85,7 @@ test.describe("Users", () => {
|
||||
const { click } = pointer;
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "New User Wizard" });
|
||||
const nextButton = dialog.getByTestId("wizard-navigation-next");
|
||||
|
||||
await expect(dialog, "Dialog is initially closed").toBeHidden();
|
||||
|
||||
@@ -101,11 +102,11 @@ test.describe("Users", () => {
|
||||
[fill, /^Username/, username, dialog],
|
||||
);
|
||||
|
||||
await dialog.getByRole("button", { name: "Next" }).click();
|
||||
await nextButton.click();
|
||||
|
||||
await expect(dialog, "Credentials page is visible").toBeVisible();
|
||||
|
||||
await dialog.getByRole("button", { name: "Finish" }).click();
|
||||
await nextButton.click();
|
||||
|
||||
await expect(dialog, "Dialog closes after creating service account").toBeHidden();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import { randomName } from "#e2e/utils/generators";
|
||||
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
import { series } from "@goauthentik/core/promises";
|
||||
|
||||
interface Names {
|
||||
providerName: string;
|
||||
appName: string;
|
||||
endpointA: string;
|
||||
endpointB: string;
|
||||
}
|
||||
|
||||
test.describe("RAC", () => {
|
||||
const fixtures = new Map<string, Names>();
|
||||
|
||||
test.beforeEach("Seed entity names", async ({}, { testId }) => {
|
||||
const seed = IDGenerator.randomID(6);
|
||||
const base = randomName(seed);
|
||||
|
||||
fixtures.set(testId, {
|
||||
providerName: `${base} RAC (${seed})`,
|
||||
appName: `${base} RAC App (${seed})`,
|
||||
endpointA: `rac-vnc-a-${seed}`,
|
||||
endpointB: `rac-vnc-b-${seed}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("Configure provider, add endpoints, attach to application, and launch from user library", async ({
|
||||
session,
|
||||
navigator,
|
||||
form,
|
||||
pointer,
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const { providerName, appName, endpointA, endpointB } = fixtures.get(testInfo.testId)!;
|
||||
const { fill, search, selectSearchValue, setRadio } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
await test.step("Authenticate", async () => {
|
||||
await session.login({ to: "/if/admin/#/core/providers" });
|
||||
});
|
||||
|
||||
//#region Create RAC provider via the wizard
|
||||
|
||||
const providerDialog = page.getByRole("dialog", { name: "New Provider Wizard" });
|
||||
|
||||
await test.step("Create RAC provider", async () => {
|
||||
await expect(providerDialog, "Provider wizard is initially closed").toBeHidden();
|
||||
|
||||
await click("New Provider", "button");
|
||||
|
||||
await expect(providerDialog, "Provider wizard opens").toBeVisible();
|
||||
|
||||
await series(
|
||||
[click, "RAC Provider", "option"],
|
||||
[fill, "Provider Name", providerName],
|
||||
[
|
||||
selectSearchValue,
|
||||
"Authorization Flow",
|
||||
/default-provider-authorization-explicit-consent/,
|
||||
],
|
||||
[click, "Create", "button", providerDialog],
|
||||
);
|
||||
|
||||
await expect(providerDialog, "Provider wizard closes after creation").toBeHidden();
|
||||
});
|
||||
|
||||
const $providerRow = await test.step("Find provider in table", () => search(providerName));
|
||||
|
||||
await expect($providerRow, "Provider row is visible").toBeVisible();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Add two endpoints from the provider detail page
|
||||
|
||||
await test.step("Open provider detail page", async () => {
|
||||
await $providerRow.getByRole("link", { name: providerName }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: providerName }),
|
||||
"Provider detail page renders",
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
const endpointDialog = page.getByRole("dialog", { name: /New RAC Endpoint/i });
|
||||
const endpointList = page.locator("ak-rac-endpoint-list");
|
||||
|
||||
for (const endpointName of [endpointA, endpointB]) {
|
||||
await test.step(`Create endpoint ${endpointName}`, async () => {
|
||||
await expect(endpointDialog, "Endpoint dialog is initially closed").toBeHidden();
|
||||
|
||||
await endpointList
|
||||
.getByRole("button", { name: "New RAC Endpoint" })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(endpointDialog, "Endpoint dialog opens").toBeVisible();
|
||||
|
||||
await series(
|
||||
[fill, "Endpoint Name", endpointName, endpointDialog],
|
||||
[setRadio, "Protocol", "VNC", endpointDialog],
|
||||
[fill, "Host", "localhost:5900", endpointDialog],
|
||||
[click, "Create RAC Endpoint", "button", endpointDialog],
|
||||
);
|
||||
|
||||
await expect(endpointDialog, "Endpoint dialog closes after creation").toBeHidden();
|
||||
|
||||
await expect(
|
||||
endpointList.getByRole("cell", { name: endpointName }),
|
||||
`Endpoint ${endpointName} appears in the sub-table`,
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Attach the provider to an application
|
||||
|
||||
await test.step("Navigate to applications", async () => {
|
||||
await navigator.navigate("/if/admin/#/core/applications");
|
||||
});
|
||||
|
||||
const appDialog = page.getByRole("dialog", { name: "New Application" });
|
||||
|
||||
await test.step("Create application with the RAC provider", async () => {
|
||||
await expect(appDialog, "Application dialog is initially closed").toBeHidden();
|
||||
|
||||
await click("New Application options", "button");
|
||||
await click("With Existing Provider...", "menuitem");
|
||||
|
||||
await expect(appDialog, "Application dialog opens").toBeVisible();
|
||||
|
||||
await series(
|
||||
[fill, /^Application Name/, appName, appDialog],
|
||||
[selectSearchValue, "Provider", providerName, appDialog],
|
||||
);
|
||||
|
||||
await appDialog.getByRole("button", { name: "Create Application" }).click();
|
||||
|
||||
await expect(appDialog, "Application dialog closes after creation").toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Verify application in admin table", async () => {
|
||||
const $appRow = await search(appName);
|
||||
|
||||
await expect($appRow, "Application is visible in the admin table").toBeVisible();
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region User library: launch the application
|
||||
|
||||
await test.step("Navigate to the user library", async () => {
|
||||
await navigator.navigate("/if/user/");
|
||||
});
|
||||
|
||||
const librarySearch = page.getByPlaceholder("Search for an application by name...");
|
||||
|
||||
await expect(librarySearch, "Library search input is ready").toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await test.step("Filter library to the new application", async () => {
|
||||
await librarySearch.fill(appName);
|
||||
});
|
||||
|
||||
// Newly created apps can take several seconds to surface in the
|
||||
// user-facing application list, so allow extra time for the card to
|
||||
// appear once the search filter is applied.
|
||||
const launchButton = page.getByRole("button", { name: `Open "${appName}"` });
|
||||
|
||||
await expect(
|
||||
launchButton,
|
||||
"Application launch button appears in the filtered library",
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await test.step("Open the endpoint launcher", async () => {
|
||||
await launchButton.click();
|
||||
});
|
||||
|
||||
const launchDialog = page.getByRole("dialog", { name: /Launch Endpoint/i });
|
||||
|
||||
await expect(launchDialog, "Launch Endpoint dialog opens").toBeVisible();
|
||||
|
||||
// Both endpoints must render on first open — no manual refresh, no
|
||||
// re-navigation. Two endpoints are required because a single endpoint
|
||||
// auto-launches and closes the modal.
|
||||
await test.step("Endpoint list populates on first open", async () => {
|
||||
await expect(
|
||||
launchDialog.getByRole("cell", { name: endpointA }),
|
||||
`Endpoint ${endpointA} is visible in the launcher`,
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
await expect(
|
||||
launchDialog.getByRole("cell", { name: endpointB }),
|
||||
`Endpoint ${endpointB} is visible in the launcher`,
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
//#endregion
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user