mirror of
https://github.com/goauthentik/authentik
synced 2026-05-01 11:57:09 +02:00
Compare commits
1 Commits
dependabot
...
a11y-user-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b9fe8267f |
@@ -1,7 +1,7 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
|
||||
import { expect, Page } from "@playwright/test";
|
||||
import { expect, Locator, Page } from "@playwright/test";
|
||||
|
||||
export class FormFixture extends PageFixture {
|
||||
static fixtureName = "Form";
|
||||
@@ -18,22 +18,62 @@ export class FormFixture extends PageFixture {
|
||||
* @param fieldName The name of the form element.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
public fill = async (
|
||||
fieldName: string,
|
||||
value: string,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent
|
||||
.getByRole("textbox", {
|
||||
name: fieldName,
|
||||
public findTextualInput = async (
|
||||
fieldName: string | RegExp,
|
||||
context: LocatorContext = this.page,
|
||||
) => {
|
||||
const control = context
|
||||
.getByLabel(fieldName, { exact: true })
|
||||
.filter({
|
||||
hasNot: context.getByRole("presentation"),
|
||||
})
|
||||
.or(
|
||||
parent.getByRole("spinbutton", {
|
||||
context.getByRole("textbox", {
|
||||
name: fieldName,
|
||||
}),
|
||||
)
|
||||
.or(
|
||||
context.getByRole("spinbutton", {
|
||||
name: fieldName,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
const role = await control.getAttribute("role");
|
||||
|
||||
let textbox: Locator;
|
||||
|
||||
if (role === "combobox") {
|
||||
// Comboboxes, such as our Query Language input need additional handling...
|
||||
const textbox = control.getByRole("textbox");
|
||||
|
||||
return textbox;
|
||||
} else {
|
||||
textbox = control;
|
||||
}
|
||||
|
||||
await expect(textbox, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
|
||||
return textbox;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a text input.
|
||||
*
|
||||
* @param target The name of the form element.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
public fill = async (
|
||||
target: string | RegExp | Locator,
|
||||
value: string,
|
||||
context: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
let control: Locator;
|
||||
|
||||
if (typeof target === "string" || target instanceof RegExp) {
|
||||
control = await this.findTextualInput(target, context);
|
||||
} else {
|
||||
control = target;
|
||||
}
|
||||
|
||||
await control.fill(value);
|
||||
};
|
||||
|
||||
@@ -18,12 +18,14 @@ export class PointerFixture extends PageFixture {
|
||||
public static fixtureName = "Pointer";
|
||||
|
||||
public click = (
|
||||
name: string,
|
||||
name: string | RegExp,
|
||||
optionsOrRole?: ARIAOptions | ARIARole,
|
||||
context: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
if (typeof optionsOrRole === "string") {
|
||||
return context.getByRole(optionsOrRole, { name }).click();
|
||||
return context.getByRole(optionsOrRole, { name }).click({
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
const options = {
|
||||
@@ -36,7 +38,9 @@ export class PointerFixture extends PageFixture {
|
||||
// ---
|
||||
.getByRole("button", options)
|
||||
.or(context.getByRole("link", options))
|
||||
.click()
|
||||
.click({
|
||||
force: true,
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
protected renderSidebarAfter(): TemplateResult {
|
||||
return html`<aside
|
||||
aria-label=${msg("Applications Documentation")}
|
||||
class="pf-c-sidebar__panel pf-m-width-25"
|
||||
class="pf-c-sidebar__panel"
|
||||
>
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__body">
|
||||
|
||||
@@ -28,7 +28,7 @@ export class EnterpriseLicenseForm extends ModelForm<License, string> {
|
||||
getSuccessMessage(): string {
|
||||
return this.instance
|
||||
? msg("Successfully updated license.")
|
||||
: msg("Successfully created license.");
|
||||
: msg("Successfully installed license.");
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
|
||||
@@ -76,27 +76,26 @@ export class MemberSelectTable extends TableModal<User> {
|
||||
this.fetch();
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group show-disabled-toggle-group">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.userListFilter === "all"}
|
||||
@change=${toggleShowDisabledUsers}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group show-disabled-toggle-group">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.userListFilter === "all"}
|
||||
@change=${toggleShowDisabledUsers}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Show inactive users")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Show inactive users")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
row(item: User): SlottedTemplateResult[] {
|
||||
|
||||
@@ -449,34 +449,33 @@ 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">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.hideServiceAccounts}
|
||||
@change=${() => {
|
||||
this.hideServiceAccounts = !this.hideServiceAccounts;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
hideServiceAccounts: this.hideServiceAccounts,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Hide service-accounts")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Hide service-accounts")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,34 +127,33 @@ export class PropertyMappingListPage extends TablePage<PropertyMapping> {
|
||||
}
|
||||
|
||||
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.hideManaged}
|
||||
@change=${() => {
|
||||
this.hideManaged = !this.hideManaged;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
hideManaged: this.hideManaged,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.hideManaged}
|
||||
@change=${() => {
|
||||
this.hideManaged = !this.hideManaged;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
hideManaged: this.hideManaged,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Hide managed mappings")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Hide managed mappings")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import "#components/ak-hidden-text-input";
|
||||
import "#elements/forms/HorizontalFormElement";
|
||||
import "#components/ak-text-input";
|
||||
import "#components/ak-radio-input";
|
||||
import "#components/ak-switch-input";
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
import { dateTimeLocal } from "#common/temporal";
|
||||
@@ -18,15 +21,29 @@ import { msg, str } from "@lit/localize";
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
@customElement("ak-user-service-account-form")
|
||||
export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
#initialExpirationValue = dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360));
|
||||
|
||||
//#region Refs
|
||||
|
||||
#expiringInputRef = createRef<HTMLInputElement>();
|
||||
#expirationDateInputRef = createRef<HTMLInputElement>();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Properties
|
||||
|
||||
@property({ attribute: false })
|
||||
result: UserServiceAccountResponse | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
group?: Group;
|
||||
|
||||
//#endregion
|
||||
|
||||
getSuccessMessage(): string {
|
||||
if (this.group) {
|
||||
return msg(str`Successfully created user and added to group ${this.group.name}`);
|
||||
@@ -58,38 +75,36 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${msg("Username")} required name="name">
|
||||
<input
|
||||
type="text"
|
||||
value=""
|
||||
class="pf-c-form-control pf-m-monospace"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("User's primary identifier. 150 characters or fewer.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="createGroup">
|
||||
<label class="pf-c-switch">
|
||||
<input class="pf-c-switch__input" type="checkbox" checked />
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Create group")}</span>
|
||||
</label>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Enabling this toggle will create a group named after the user, with the user as member.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
return html`<ak-text-input
|
||||
name="name"
|
||||
label=${msg("Username")}
|
||||
placeholder=${msg("Type a username for the user...")}
|
||||
autocomplete="off"
|
||||
value=""
|
||||
input-hint="code"
|
||||
required
|
||||
maxlength=${150}
|
||||
help=${msg(
|
||||
"The user's primary identifier used for authentication. 150 characters or fewer.",
|
||||
)}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-switch-input
|
||||
name="createGroup"
|
||||
label=${msg("Create group")}
|
||||
help=${msg("Create and assign a group with the same name as the user.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
|
||||
<ak-form-element-horizontal name="expiring">
|
||||
<label class="pf-c-switch">
|
||||
<input class="pf-c-switch__input" type="checkbox" checked />
|
||||
<input
|
||||
${ref(this.#expiringInputRef)}
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
checked
|
||||
@change=${this.expiringChangeListener}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
@@ -99,15 +114,17 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
</label>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"If this is selected, the token will expire. Upon expiration, the token will be rotated.",
|
||||
"Whether the token will expire. Upon expiration, the token will be rotated.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-element-horizontal label=${msg("Expires on")} name="expires">
|
||||
<input
|
||||
${ref(this.#expirationDateInputRef)}
|
||||
type="datetime-local"
|
||||
data-type="datetime-local"
|
||||
value="${dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360))}"
|
||||
value="${this.#initialExpirationValue}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
</ak-form-element-horizontal>`;
|
||||
@@ -120,17 +137,20 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
)}
|
||||
</p>
|
||||
<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${msg("Username")}>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value=${ifDefined(this.result?.username)}
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-text-input
|
||||
name="name"
|
||||
label=${msg("Username")}
|
||||
autocomplete="off"
|
||||
value=${ifDefined(this.result?.username)}
|
||||
input-hint="code"
|
||||
readonly
|
||||
></ak-text-input>
|
||||
|
||||
<ak-hidden-text-input
|
||||
label=${msg("Password")}
|
||||
value="${this.result?.token ?? ""}"
|
||||
input-hint="code"
|
||||
readonly
|
||||
.help=${msg(
|
||||
"Valid for 360 days, after which the password will automatically rotate. You can copy the password from the Token List.",
|
||||
)}
|
||||
@@ -139,6 +159,15 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||
</form>`;
|
||||
}
|
||||
|
||||
expiringChangeListener = () => {
|
||||
const expiringElement = this.#expiringInputRef.value;
|
||||
const expirationDateElement = this.#expirationDateInputRef.value;
|
||||
|
||||
if (!expiringElement || !expirationDateElement) return;
|
||||
|
||||
expirationDateElement.disabled = !expiringElement.checked;
|
||||
};
|
||||
|
||||
renderFormWrapper(): TemplateResult {
|
||||
if (this.result) {
|
||||
return this.renderResponseForm();
|
||||
|
||||
@@ -2,11 +2,15 @@ import "#admin/users/GroupSelectModal";
|
||||
import "#elements/CodeMirror";
|
||||
import "#elements/forms/HorizontalFormElement";
|
||||
import "#elements/forms/Radio";
|
||||
import "#components/ak-text-input";
|
||||
import "#components/ak-radio-input";
|
||||
import "#components/ak-switch-input";
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { CodeMirrorMode } from "#elements/CodeMirror";
|
||||
import { ModelForm } from "#elements/forms/ModelForm";
|
||||
import { RadioOption } from "#elements/forms/Radio";
|
||||
|
||||
import { CoreApi, Group, User, UserTypeEnum } from "@goauthentik/api";
|
||||
|
||||
@@ -17,6 +21,28 @@ import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
const UserTypeOptions: readonly RadioOption<UserTypeEnum>[] = [
|
||||
{
|
||||
label: msg("Internal"),
|
||||
value: UserTypeEnum.Internal,
|
||||
default: true,
|
||||
description: html`${msg(
|
||||
"Company employees with access to the full enterprise feature set.",
|
||||
)}`,
|
||||
},
|
||||
{
|
||||
label: msg("External"),
|
||||
value: UserTypeEnum.External,
|
||||
description: html`${msg(
|
||||
"External consultants or B2C customers without access to enterprise features.",
|
||||
)}`,
|
||||
},
|
||||
{
|
||||
label: msg("Service account"),
|
||||
value: UserTypeEnum.ServiceAccount,
|
||||
description: html`${msg("Machine-to-machine authentication or other automations.")}`,
|
||||
},
|
||||
];
|
||||
@customElement("ak-user-form")
|
||||
export class UserForm extends ModelForm<User, number> {
|
||||
@property({ attribute: false })
|
||||
@@ -85,101 +111,92 @@ export class UserForm extends ModelForm<User, number> {
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<ak-form-element-horizontal label=${msg("Username")} required name="username">
|
||||
<input
|
||||
type="text"
|
||||
value="${ifDefined(this.instance?.username)}"
|
||||
class="pf-c-form-control pf-m-monospace"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("User's primary identifier. 150 characters or fewer.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Name")} name="name">
|
||||
<input
|
||||
type="text"
|
||||
value="${ifDefined(this.instance?.name)}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
<p class="pf-c-form__helper-text">${msg("User's display name.")}</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("User type")} required name="type">
|
||||
<ak-radio
|
||||
.options=${[
|
||||
{
|
||||
label: msg("Internal"),
|
||||
value: UserTypeEnum.Internal,
|
||||
default: true,
|
||||
description: html`${msg(
|
||||
"Internal users might be users such as company employees, which will get access to the full Enterprise feature set.",
|
||||
)}`,
|
||||
},
|
||||
{
|
||||
label: msg("External"),
|
||||
value: UserTypeEnum.External,
|
||||
description: html`${msg(
|
||||
"External users might be external consultants or B2C customers. These users don't get access to enterprise features.",
|
||||
)}`,
|
||||
},
|
||||
{
|
||||
label: msg("Service account"),
|
||||
value: UserTypeEnum.ServiceAccount,
|
||||
description: html`${msg(
|
||||
"Service accounts should be used for machine-to-machine authentication or other automations.",
|
||||
)}`,
|
||||
},
|
||||
{
|
||||
label: msg("Internal Service account"),
|
||||
value: UserTypeEnum.InternalServiceAccount,
|
||||
disabled: true,
|
||||
description: html`${msg(
|
||||
"Internal Service accounts are created and managed by authentik and cannot be created manually.",
|
||||
)}`,
|
||||
},
|
||||
]}
|
||||
.value=${this.instance?.type}
|
||||
>
|
||||
</ak-radio>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Email")} name="email">
|
||||
<input
|
||||
type="email"
|
||||
autocomplete="off"
|
||||
value="${ifDefined(this.instance?.email)}"
|
||||
class="pf-c-form-control"
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="isActive">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.instance?.isActive ?? true}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Is active")}</span>
|
||||
</label>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Path")} required name="path">
|
||||
<input
|
||||
type="text"
|
||||
value="${this.instance?.path ?? this.defaultPath}"
|
||||
class="pf-c-form-control"
|
||||
required
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
return html` <ak-text-input
|
||||
name="username"
|
||||
label=${msg("Username")}
|
||||
placeholder=${msg("Type a username for the user...")}
|
||||
autocomplete="off"
|
||||
value="${ifDefined(this.instance?.username)}"
|
||||
input-hint="code"
|
||||
required
|
||||
maxlength=${150}
|
||||
help=${msg(
|
||||
"The user's primary identifier used for authentication. 150 characters or fewer.",
|
||||
)}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-text-input
|
||||
name="name"
|
||||
label=${msg("Display Name")}
|
||||
placeholder=${msg("Type an optional display name...")}
|
||||
autocomplete="off"
|
||||
value="${ifDefined(this.instance?.name)}"
|
||||
input-hint="code"
|
||||
maxlength=${150}
|
||||
help=${msg("The user's display name. 150 characters or fewer.")}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-radio-input
|
||||
label=${msg("User type")}
|
||||
required
|
||||
name="type"
|
||||
.value=${this.instance?.type}
|
||||
.options=${[
|
||||
...UserTypeOptions,
|
||||
...(this.instance
|
||||
? [
|
||||
{
|
||||
label: msg("Internal Service account"),
|
||||
value: UserTypeEnum.InternalServiceAccount,
|
||||
disabled: true,
|
||||
description: html`${msg(
|
||||
"Managed by authentik and cannot be assigned manually.",
|
||||
)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] satisfies RadioOption<UserTypeEnum>[]}
|
||||
>
|
||||
</ak-radio-input>
|
||||
<ak-text-input
|
||||
name="email"
|
||||
label=${msg("Email Address")}
|
||||
placeholder=${msg("Type an optional email address...")}
|
||||
autocomplete="off"
|
||||
value="${ifDefined(this.instance?.email)}"
|
||||
input-hint="code"
|
||||
></ak-text-input>
|
||||
|
||||
<ak-switch-input
|
||||
name="isActive"
|
||||
label=${msg("Active")}
|
||||
?checked=${this.instance?.isActive ?? true}
|
||||
help=${msg(
|
||||
"Whether this user is active and allowed to authenticate. Setting this to inactive can be used to temporarily disable a user without deleting their account.",
|
||||
)}
|
||||
>
|
||||
</ak-switch-input>
|
||||
|
||||
<ak-text-input
|
||||
name="path"
|
||||
label=${msg("Path")}
|
||||
placeholder=${msg("Type a path for the user...")}
|
||||
autocomplete="off"
|
||||
value="${this.instance?.path ?? this.defaultPath}"
|
||||
input-hint="code"
|
||||
required
|
||||
.bighelp=${html`<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Paths can be used to organize users into folders depending on which source created them or organizational structure.",
|
||||
)}
|
||||
</p>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Paths may not start or end with a slash, but they can contain any other character as path segments. The paths are currently purely used for organization, it does not affect their permissions, group memberships, or anything else.",
|
||||
)}
|
||||
</p>`}
|
||||
></ak-text-input>
|
||||
|
||||
<ak-form-element-horizontal label=${msg("Attributes")} name="attributes">
|
||||
<ak-codemirror
|
||||
mode=${CodeMirrorMode.YAML}
|
||||
|
||||
@@ -91,6 +91,9 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
supportsQL = true;
|
||||
|
||||
protected override searchEnabled = true;
|
||||
public override searchPlaceholder = msg("Search by username, email, etc...");
|
||||
public override searchLabel = msg("User Search");
|
||||
|
||||
public pageTitle = msg("Users");
|
||||
public pageDescription = "";
|
||||
public pageIcon = "pf-icon pf-icon-user";
|
||||
@@ -207,34 +210,33 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
}
|
||||
|
||||
renderToolbarAfter(): TemplateResult {
|
||||
return html`
|
||||
<div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.hideDeactivated}
|
||||
@change=${() => {
|
||||
this.hideDeactivated = !this.hideDeactivated;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
hideDeactivated: this.hideDeactivated,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${!this.hideDeactivated}
|
||||
@change=${() => {
|
||||
this.hideDeactivated = !this.hideDeactivated;
|
||||
this.page = 1;
|
||||
this.fetch();
|
||||
updateURLParams({
|
||||
hideDeactivated: this.hideDeactivated,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Hide deactivated user")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Show deactivated users")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
row(item: User): SlottedTemplateResult[] {
|
||||
@@ -392,27 +394,24 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
renderObjectCreate(): TemplateResult {
|
||||
return html`
|
||||
<ak-forms-modal>
|
||||
<span slot="submit">${msg("Create")}</span>
|
||||
<span slot="header">${msg("Create User")}</span>
|
||||
<span slot="submit">${msg("Create User")}</span>
|
||||
<span slot="header">${msg("New User")}</span>
|
||||
<ak-user-form defaultPath=${this.activePath} slot="form"> </ak-user-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("New User")}</button>
|
||||
</ak-forms-modal>
|
||||
<ak-forms-modal .closeAfterSuccessfulSubmit=${false} .cancelText=${msg("Close")}>
|
||||
<span slot="submit">${msg("Create")}</span>
|
||||
<span slot="header">${msg("Create Service account")}</span>
|
||||
<span slot="submit">${msg("Create Service Account")}</span>
|
||||
<span slot="header">${msg("New Service Account")}</span>
|
||||
<ak-user-service-account-form slot="form"> </ak-user-service-account-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-secondary">
|
||||
${msg("Create Service account")}
|
||||
${msg("New Service Account")}
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
protected renderSidebarBefore(): TemplateResult {
|
||||
return html`<aside
|
||||
aria-labelledby="sidebar-left-panel-header"
|
||||
class="pf-c-sidebar__panel pf-m-width-25"
|
||||
>
|
||||
return html`<aside aria-labelledby="sidebar-left-panel-header" class="pf-c-sidebar__panel">
|
||||
<div class="pf-c-card">
|
||||
<div
|
||||
role="heading"
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pf-c-form__label[aria-required] .pf-c-form__label-text::after {
|
||||
content: "*" / "Required";
|
||||
.pf-c-form__label[aria-required="true"] .pf-c-form__label-text::after {
|
||||
content: "*" / "";
|
||||
user-select: none;
|
||||
margin-left: var(--pf-c-form__label-required--MarginLeft);
|
||||
font-size: var(--pf-c-form__label-required--FontSize);
|
||||
@@ -219,12 +219,24 @@ html > form > input {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pf-c-form__helper-text {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region Fonts */
|
||||
|
||||
.pf-m-monospace {
|
||||
font-family: var(--pf-global--FontFamily--monospace);
|
||||
|
||||
&::placeholder {
|
||||
font-family: var(--pf-global--FontFamily--sans-serif);
|
||||
}
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
@@ -225,10 +225,6 @@ select.pf-c-form-control {
|
||||
.pf-c-switch__input:checked ~ .pf-c-switch__label {
|
||||
--pf-c-switch__input--checked__label--Color: var(--ak-dark-foreground);
|
||||
}
|
||||
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* select toggle */
|
||||
.pf-c-select__toggle::before {
|
||||
|
||||
@@ -40,6 +40,8 @@ export abstract class HorizontalLightComponent<T>
|
||||
return this;
|
||||
}
|
||||
|
||||
public override role = "presentation";
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
@@ -56,26 +58,7 @@ export abstract class HorizontalLightComponent<T>
|
||||
* @attribute
|
||||
*/
|
||||
@property({ type: String })
|
||||
public get label() {
|
||||
return this.ariaLabel;
|
||||
}
|
||||
|
||||
public set label(value: string | null) {
|
||||
this.ariaLabel = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ARIA role for the input control
|
||||
* @property
|
||||
* @attribute
|
||||
*/
|
||||
public get role() {
|
||||
return super.role || "group";
|
||||
}
|
||||
|
||||
public set role(value: string | null) {
|
||||
super.role = value;
|
||||
}
|
||||
label: string | null = null;
|
||||
|
||||
/**
|
||||
* @property
|
||||
@@ -146,15 +129,32 @@ export abstract class HorizontalLightComponent<T>
|
||||
@property({ type: String, attribute: "input-hint" })
|
||||
inputHint?: string;
|
||||
|
||||
#fieldID = IDGenerator.elementID().toString();
|
||||
protected helpID = `field-help-${this.#fieldID}`;
|
||||
protected labelID = `field-label-${this.#fieldID}`;
|
||||
|
||||
/**
|
||||
* A unique ID to associate with the input and label.
|
||||
* @property
|
||||
*/
|
||||
@property({ type: String, reflect: false })
|
||||
public fieldID?: string = IDGenerator.elementID().toString();
|
||||
public get fieldID() {
|
||||
return this.#fieldID;
|
||||
}
|
||||
|
||||
protected get helpID() {
|
||||
return this.fieldID ? `field-help-${this.fieldID}` : "field-help";
|
||||
public set fieldID(value: string) {
|
||||
this.#fieldID = value;
|
||||
this.helpID = `field-help-${this.#fieldID}`;
|
||||
this.labelID = `field-label-${this.#fieldID}`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.setAttribute("aria-labelledby", this.labelID);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
@@ -187,7 +187,14 @@ export abstract class HorizontalLightComponent<T>
|
||||
.errorMessages=${this.errorMessages}
|
||||
>
|
||||
<div slot="label" class="pf-c-form__group-label">
|
||||
${AKLabel({ htmlFor: this.fieldID, required: this.required }, this.label || "")}
|
||||
${AKLabel(
|
||||
{
|
||||
id: this.labelID,
|
||||
htmlFor: this.fieldID,
|
||||
required: this.required,
|
||||
},
|
||||
this.label || "",
|
||||
)}
|
||||
</div>
|
||||
|
||||
${this.renderControl()}
|
||||
|
||||
@@ -82,6 +82,9 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
@property({ type: String })
|
||||
public autocomplete?: AutoFill;
|
||||
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
public readOnly: boolean = false;
|
||||
|
||||
/**
|
||||
* @property
|
||||
* @attribute
|
||||
@@ -117,7 +120,10 @@ export class AkHiddenTextInput<T extends InputLike = HTMLInputElement>
|
||||
"pf-m-monospace": code,
|
||||
})}"
|
||||
spellcheck=${code ? "false" : "true"}
|
||||
id=${ifPresent(this.fieldID)}
|
||||
aria-describedby=${this.helpID}
|
||||
?required=${this.required}
|
||||
?readonly=${this.readOnly}
|
||||
/>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { LitFC } from "#elements/types";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
import type { LabelHTMLAttributes } from "react";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
export interface FormLabelProps extends LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean;
|
||||
@@ -18,10 +19,14 @@ export const AKLabel: LitFC<FormLabelProps> = (
|
||||
|
||||
return html`<label
|
||||
class="pf-c-form__label"
|
||||
for=${ifDefined(htmlFor)}
|
||||
?aria-required=${required}
|
||||
for=${ifPresent(htmlFor)}
|
||||
aria-required=${required ? "true" : "false"}
|
||||
${spread(labelAttributes)}
|
||||
>
|
||||
<span class="pf-c-form__label-text">${children}</span>
|
||||
<span
|
||||
class="pf-c-form__label-text"
|
||||
data-required-label=${required ? msg("Required") : nothing}
|
||||
>${children}</span
|
||||
>
|
||||
</label>`;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("ak-radio-input")
|
||||
export class AkRadioInput<T> extends HorizontalLightComponent<T> {
|
||||
public override role = "radiogroup";
|
||||
|
||||
@property({ type: Object })
|
||||
value!: T;
|
||||
|
||||
@@ -22,11 +24,6 @@ export class AkRadioInput<T> extends HorizontalLightComponent<T> {
|
||||
}
|
||||
}
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.role = "radiogroup";
|
||||
}
|
||||
|
||||
protected override renderHelp(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { rootInterface } from "#common/theme";
|
||||
|
||||
import { FormAssociated, FormAssociatedElement } from "#elements/forms/form-associated-element";
|
||||
import { PaginatedResponse } from "#elements/table/Table";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import DjangoQL, { Introspections } from "@mrmarble/djangoql-completion";
|
||||
|
||||
@@ -45,6 +46,8 @@ function torusIndex(lengthLike: number | ArrayLike<number>, delta: number): numb
|
||||
|
||||
@customElement("ak-search-ql")
|
||||
export class QLSearch extends FormAssociatedElement<string> implements FormAssociated {
|
||||
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
|
||||
|
||||
declare anchorRef: Ref<HTMLTextAreaElement>;
|
||||
declare anchor: HTMLTextAreaElement | null;
|
||||
|
||||
@@ -53,10 +56,6 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
PFFormControl,
|
||||
PFSearchInput,
|
||||
css`
|
||||
::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ql.pf-c-form-control {
|
||||
--input-height: 2.25em;
|
||||
|
||||
@@ -64,12 +63,33 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
min-height: var(--input-height);
|
||||
max-height: calc(var(--input-height) * 6);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pf-c-search-input[aria-expanded="true"] {
|
||||
.ql.pf-c-form-control {
|
||||
--pf-c-form-control--BorderBottomColor: var(
|
||||
--pf-c-form-control--focus--BorderBottomColor
|
||||
);
|
||||
}
|
||||
.pf-c-search-input__text::after {
|
||||
--pf-c-search-input__text--after--BorderBottomWidth: var(
|
||||
--pf-c-search-input__text--focus-within--after--BorderBottomWidth
|
||||
);
|
||||
--pf-c-search-input__text--after--BorderBottomColor: var(
|
||||
--pf-c-search-input__text--focus-within--after--BorderBottomColor
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: var(--pf-c-search-input__menu-item--hover--BackgroundColor);
|
||||
}
|
||||
|
||||
.pf-c-search-input__menu-list {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:host([theme="dark"]) {
|
||||
.pf-c-search-input__menu {
|
||||
--pf-c-search-input__menu--BackgroundColor: var(--ak-dark-background-light-ish);
|
||||
@@ -108,6 +128,12 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
|
||||
//#region Properties
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder = msg("Search...");
|
||||
|
||||
@property({ type: String })
|
||||
public label: string | null = msg("Search");
|
||||
|
||||
@property({ type: Boolean })
|
||||
public open = false;
|
||||
|
||||
@@ -123,8 +149,16 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
|
||||
public set value(value: unknown) {
|
||||
const parsed = typeof value === "string" ? value : "";
|
||||
const trimmed = parsed.trim();
|
||||
|
||||
this.setFormValue(trimmed, parsed);
|
||||
|
||||
if (trimmed) {
|
||||
this.internals.states.add("present");
|
||||
} else {
|
||||
this.internals.states.delete("present");
|
||||
}
|
||||
|
||||
this.setFormValue(parsed.trim(), parsed);
|
||||
this.#value = parsed;
|
||||
|
||||
if (this.anchor) {
|
||||
@@ -134,6 +168,21 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public API
|
||||
|
||||
public submit() {
|
||||
if (!this.form) return;
|
||||
|
||||
const submitEvent = new SubmitEvent("submit", {
|
||||
submitter: this,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
this.form.dispatchEvent(submitEvent);
|
||||
}
|
||||
|
||||
//#region State
|
||||
|
||||
#menuRef = createRef<HTMLDivElement>();
|
||||
@@ -158,10 +207,6 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.internals.ariaAutoComplete = "list";
|
||||
this.internals.role = "combobox";
|
||||
this.internals.ariaHasPopup = "listbox";
|
||||
|
||||
this.#scrollContainer =
|
||||
rootInterface<LitElement>().renderRoot.querySelector("#main-content");
|
||||
|
||||
@@ -170,6 +215,8 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
});
|
||||
|
||||
this.tabIndex = 0;
|
||||
|
||||
this.addEventListener("focus", this.#delegateFocusListener);
|
||||
}
|
||||
|
||||
public override disconnectedCallback() {
|
||||
@@ -191,15 +238,9 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
}
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("open")) {
|
||||
this.internals.ariaExpanded = this.open ? "true" : "false";
|
||||
}
|
||||
|
||||
if (changedProperties.has("selectionIndex")) {
|
||||
const id = `suggestion-${this.selectionIndex}`;
|
||||
|
||||
this.setAttribute("aria-activedescendant", this.selectionIndex === -1 ? "" : id);
|
||||
|
||||
this.renderRoot.querySelector(`#${id}`)?.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
@@ -326,14 +367,7 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
const suggestionsLength = this.#ql?.suggestions.length;
|
||||
|
||||
if (event.key === "Enter" && !this.open && this.form) {
|
||||
const submitEvent = new SubmitEvent("submit", {
|
||||
submitter: this,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
this.form.dispatchEvent(submitEvent);
|
||||
this.submit();
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -419,6 +453,10 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
this.#refreshCompletions();
|
||||
};
|
||||
|
||||
#delegateFocusListener = () => {
|
||||
this.anchorRef?.value?.focus();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Render
|
||||
@@ -469,7 +507,17 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
}
|
||||
|
||||
public override render(): TemplateResult {
|
||||
return html`<div class="pf-c-search-input">
|
||||
return html`<div
|
||||
class="pf-c-search-input"
|
||||
aria-expanded=${this.open ? "true" : "false"}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-label=${ifPresent(this.label)}
|
||||
aria-has-popup="listbox"
|
||||
aria-activedescendant=${this.selectionIndex === -1
|
||||
? ""
|
||||
: `suggestion-${this.selectionIndex}`}
|
||||
>
|
||||
<div class="pf-c-search-input__bar">
|
||||
<span class="pf-c-search-input__text">
|
||||
<textarea
|
||||
@@ -479,7 +527,8 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
|
||||
autocomplete="off"
|
||||
aria-controls="ql-suggestions"
|
||||
?required=${this.required}
|
||||
placeholder=${msg("Search...")}
|
||||
placeholder=${ifPresent(this.placeholder)}
|
||||
aria-label=${msg("Query input")}
|
||||
spellcheck="false"
|
||||
@input=${this.#refreshCompletions}
|
||||
@focus=${this.#focusListener}
|
||||
|
||||
@@ -106,7 +106,7 @@ export class AkStatusLabel extends AKElement {
|
||||
"pf-m-compact": this.compact,
|
||||
};
|
||||
|
||||
return html`<span class="${classMap(classes)}">
|
||||
return html`<span class="${classMap(classes)}" aria-label=${label} role="status">
|
||||
<span class="pf-c-label__content">
|
||||
<span class="pf-c-label__icon">
|
||||
<i class="fas fa-fw ${icon}" aria-hidden="true"></i> </span
|
||||
|
||||
@@ -18,15 +18,27 @@ export class AkTextInput extends HorizontalLightComponent<string> {
|
||||
@property({ type: String })
|
||||
public placeholder: string | null = null;
|
||||
|
||||
@property({ type: Number, attribute: "maxlength" })
|
||||
public maxLength?: number;
|
||||
|
||||
@property({ type: Number, attribute: "minlength" })
|
||||
public minLength?: number;
|
||||
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
public readOnly: boolean = false;
|
||||
|
||||
@property({ type: String })
|
||||
public type: "text" | "email" = "text";
|
||||
|
||||
#inputListener(ev: InputEvent) {
|
||||
this.value = (ev.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
public override renderControl() {
|
||||
const code = this.inputHint === "code";
|
||||
|
||||
return html` <input
|
||||
type="text"
|
||||
role="textbox"
|
||||
type=${this.type}
|
||||
id=${ifDefined(this.fieldID)}
|
||||
@input=${this.#inputListener}
|
||||
value=${ifDefined(this.value)}
|
||||
@@ -34,9 +46,10 @@ export class AkTextInput extends HorizontalLightComponent<string> {
|
||||
"pf-c-form-control": true,
|
||||
"pf-m-monospace": code,
|
||||
})}"
|
||||
maxlength=${ifPresent(this.maxLength)}
|
||||
minlength=${ifPresent(this.minLength)}
|
||||
autocomplete=${ifPresent(code ? "off" : this.autocomplete)}
|
||||
spellcheck=${ifPresent(code ? "false" : this.spellcheck)}
|
||||
aria-label=${ifPresent(this.placeholder || this.label)}
|
||||
aria-describedby=${this.helpID}
|
||||
placeholder=${ifPresent(this.placeholder)}
|
||||
?required=${this.required}
|
||||
|
||||
@@ -71,6 +71,10 @@ export abstract class ModalButton extends AKElement {
|
||||
.pf-c-modal-box.pf-m-xl {
|
||||
--pf-c-modal-box--Width: calc(1.5 * var(--pf-c-modal-box--m-lg--lg--MaxWidth));
|
||||
}
|
||||
|
||||
.pf-c-modal-box__footer {
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ export class ModalForm extends ModalButton {
|
||||
: nothing}
|
||||
<section class="pf-c-modal-box__header pf-c-page__main-section pf-m-light">
|
||||
<div class="pf-c-content">
|
||||
<h1 class="pf-c-title pf-m-2xl">
|
||||
<h1 id="modal-title" class="pf-c-title pf-m-2xl">
|
||||
<slot name="header"></slot>
|
||||
</h1>
|
||||
</div>
|
||||
@@ -117,13 +117,13 @@ export class ModalForm extends ModalButton {
|
||||
</section>
|
||||
<footer class="pf-c-modal-box__footer">
|
||||
${this.showSubmitButton
|
||||
? html`<ak-spinner-button .callAction=${this.#confirm} class="pf-m-primary">
|
||||
<slot name="submit"></slot> </ak-spinner-button
|
||||
> `
|
||||
? html`<button @click=${this.#confirm} class="pf-c-button pf-m-primary">
|
||||
<slot name="submit"></slot>
|
||||
</button>`
|
||||
: nothing}
|
||||
<ak-spinner-button .callAction=${this.#cancel} class="pf-m-secondary">
|
||||
<button @click=${this.#cancel} class="pf-c-button pf-m-secondary">
|
||||
${this.cancelText}
|
||||
</ak-spinner-button>
|
||||
</button>
|
||||
</footer>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { AKElement } from "#elements/Base";
|
||||
import { WithLicenseSummary } from "#elements/mixins/license";
|
||||
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import { Pagination } from "@goauthentik/api";
|
||||
|
||||
@@ -74,7 +75,7 @@ export abstract class Table<T extends object>
|
||||
.presentational {
|
||||
--pf-c-table--cell--MinWidth: 0;
|
||||
}
|
||||
@container (width > 600px) {
|
||||
@container (width > 1200px) {
|
||||
--pf-c-table--cell--MinWidth: 9em;
|
||||
}
|
||||
}
|
||||
@@ -122,11 +123,8 @@ export abstract class Table<T extends object>
|
||||
}
|
||||
}
|
||||
|
||||
.pf-c-toolbar__group.pf-m-search-filter.ql {
|
||||
flex-grow: 1;
|
||||
}
|
||||
ak-table-search.ql {
|
||||
width: 100% !important;
|
||||
.pf-m-search-filter {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.pf-c-table thead .pf-c-table__check {
|
||||
min-width: 3rem;
|
||||
@@ -134,11 +132,13 @@ export abstract class Table<T extends object>
|
||||
.pf-c-table tbody .pf-c-table__check input {
|
||||
margin-top: calc(var(--pf-c-table__check--input--MarginTop) + 1px);
|
||||
}
|
||||
|
||||
.pf-c-toolbar__content {
|
||||
row-gap: var(--pf-global--spacer--sm);
|
||||
justify-content: space-between;
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
}
|
||||
.pf-c-toolbar__item .pf-c-input-group {
|
||||
padding: 0 var(--pf-global--spacer--sm);
|
||||
.pf-c-toolbar__group {
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
}
|
||||
|
||||
.pf-c-table {
|
||||
@@ -155,6 +155,10 @@ export abstract class Table<T extends object>
|
||||
.pf-c-table tr.pf-m-hoverable {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
time {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -173,7 +177,8 @@ export abstract class Table<T extends object>
|
||||
*/
|
||||
protected abstract row(item: T): SlottedTemplateResult[];
|
||||
|
||||
#loading = false;
|
||||
@state()
|
||||
protected loading = false;
|
||||
|
||||
#pageParam = `${this.tagName.toLowerCase()}-page`;
|
||||
#searchParam = `${this.tagName.toLowerCase()}-search`;
|
||||
@@ -329,11 +334,11 @@ export abstract class Table<T extends object>
|
||||
}
|
||||
|
||||
public fetch(): Promise<void> {
|
||||
if (this.#loading) {
|
||||
if (this.loading) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.#loading = true;
|
||||
this.loading = true;
|
||||
|
||||
return this.apiEndpoint()
|
||||
.then((data) => {
|
||||
@@ -364,7 +369,7 @@ export abstract class Table<T extends object>
|
||||
this.error = await parseAPIResponseError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.#loading = false;
|
||||
this.loading = false;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
@@ -438,7 +443,7 @@ export abstract class Table<T extends object>
|
||||
return this.renderEmpty(this.renderError());
|
||||
}
|
||||
|
||||
if (!this.data || this.#loading) {
|
||||
if (!this.data || this.loading) {
|
||||
return this.renderLoading();
|
||||
}
|
||||
|
||||
@@ -673,26 +678,24 @@ export abstract class Table<T extends object>
|
||||
return nothing;
|
||||
}
|
||||
|
||||
protected renderToolbarAfter(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
protected renderToolbarAfter?(): SlottedTemplateResult;
|
||||
|
||||
protected renderToolbarContainer(): SlottedTemplateResult {
|
||||
const label = this.toolbarLabel ?? msg(str`${this.label ?? "Table"} actions`);
|
||||
|
||||
return html`<header class="pf-c-toolbar" role="toolbar" aria-label="${label}">
|
||||
<div role="presentation" class="pf-c-toolbar__content">
|
||||
<div class="pf-c-toolbar__content">
|
||||
${this.renderSearch()}
|
||||
<div role="presentation" class="pf-c-toolbar__bulk-select">
|
||||
${this.renderToolbar()}
|
||||
${this.renderToolbarAfter
|
||||
? html`<div class="pf-c-toolbar__group">${this.renderToolbarAfter()}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
<div class="pf-c-toolbar__content">
|
||||
<div class="pf-c-toolbar__group">
|
||||
${this.renderToolbar()} ${this.renderToolbarSelected()}
|
||||
</div>
|
||||
<div role="presentation" class="pf-c-toolbar__group">
|
||||
${this.renderToolbarAfter()}
|
||||
</div>
|
||||
<div role="presentation" class="pf-c-toolbar__group">
|
||||
${this.renderToolbarSelected()}
|
||||
</div>
|
||||
${this.paginated ? this.renderTablePagination() : nothing}
|
||||
${this.renderTablePagination()}
|
||||
</div>
|
||||
</header>`;
|
||||
}
|
||||
@@ -716,18 +719,16 @@ export abstract class Table<T extends object>
|
||||
|
||||
const isQL = this.supportsQL && this.hasEnterpriseLicense;
|
||||
|
||||
return html`<div class="pf-c-toolbar__group pf-m-search-filter ${isQL ? "ql" : ""}">
|
||||
<ak-table-search
|
||||
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
|
||||
.defaultValue=${this.search}
|
||||
label=${ifDefined(this.searchLabel)}
|
||||
placeholder=${ifDefined(this.searchPlaceholder)}
|
||||
.onSearch=${this.#searchListener}
|
||||
.supportsQL=${this.supportsQL}
|
||||
.apiResponse=${this.data}
|
||||
>
|
||||
</ak-table-search>
|
||||
</div>`;
|
||||
return html` <ak-table-search
|
||||
class="pf-c-toolbar__item pf-m-search-filter ${isQL ? "ql" : ""}"
|
||||
.defaultValue=${this.search}
|
||||
label=${ifDefined(this.searchLabel)}
|
||||
placeholder=${ifDefined(this.searchPlaceholder)}
|
||||
.onSearch=${this.#searchListener}
|
||||
.supportsQL=${this.supportsQL}
|
||||
.apiResponse=${this.data}
|
||||
>
|
||||
</ak-table-search>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
@@ -816,6 +817,8 @@ export abstract class Table<T extends object>
|
||||
* A simple pagination display, shown at both the top and bottom of the page.
|
||||
*/
|
||||
protected renderTablePagination(): SlottedTemplateResult {
|
||||
if (!this.paginated) return nothing;
|
||||
|
||||
const handler = (page: number) => {
|
||||
this.page = page;
|
||||
this.fetch();
|
||||
@@ -823,7 +826,8 @@ export abstract class Table<T extends object>
|
||||
|
||||
return html`
|
||||
<ak-table-pagination
|
||||
label=${ifDefined(this.label || undefined)}
|
||||
?loading=${this.loading}
|
||||
label=${ifPresent(this.label)}
|
||||
class="pf-c-toolbar__item pf-m-pagination"
|
||||
.pages=${this.data?.pagination}
|
||||
.onPageChange=${handler}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SlottedTemplateResult } from "#elements/types";
|
||||
import { setPageDetails } from "#components/ak-page-navbar";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
|
||||
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
@@ -18,6 +18,14 @@ export abstract class TablePage<T extends object> extends Table<T> {
|
||||
PFPage,
|
||||
PFContent,
|
||||
PFSidebar,
|
||||
css`
|
||||
.pf-c-sidebar__panel {
|
||||
flex: 0 1 25%;
|
||||
}
|
||||
.pf-c-sidebar__content {
|
||||
flex: 1 1 75%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
//#region Abstract properties
|
||||
|
||||
@@ -20,6 +20,9 @@ export class TablePagination extends AKElement {
|
||||
@property({ attribute: false })
|
||||
public pages?: Pagination;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public loading = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public onPageChange?: TablePageChangeListener;
|
||||
|
||||
@@ -28,12 +31,25 @@ export class TablePagination extends AKElement {
|
||||
PFButton,
|
||||
PFPagination,
|
||||
css`
|
||||
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button {
|
||||
color: var(--pf-c-button--m-plain--disabled--Color);
|
||||
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
|
||||
.pf-c-pagination {
|
||||
min-width: 8rem;
|
||||
|
||||
&[inert] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
:host([theme="dark"]) .pf-c-pagination__nav-control .pf-c-button:disabled {
|
||||
color: var(--pf-c-button--disabled--Color);
|
||||
|
||||
:host([theme="dark"]) {
|
||||
.pf-c-pagination__nav-control {
|
||||
.pf-c-button {
|
||||
color: var(--pf-c-button--m-plain--disabled--Color);
|
||||
--pf-c-button--disabled--Color: var(--pf-c-button--m-plain--Color);
|
||||
|
||||
&:disabled {
|
||||
color: var(--pf-c-button--disabled--Color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
@@ -47,21 +63,24 @@ export class TablePagination extends AKElement {
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.pages) {
|
||||
if (!this.pages && !this.loading) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const startIndex = this.pages?.startIndex || 1;
|
||||
const endIndex = this.pages?.endIndex || 1;
|
||||
const pageCount = this.pages?.count || 1;
|
||||
|
||||
return html` <nav
|
||||
aria-label=${msg(str`${this.label || ""} table pagination`)}
|
||||
class="pf-c-pagination pf-m-compact pf-m-hidden pf-m-visible-on-md"
|
||||
?inert=${this.loading}
|
||||
>
|
||||
<div class="pf-c-pagination pf-m-compact pf-m-compact pf-m-hidden pf-m-visible-on-md">
|
||||
<div class="pf-c-options-menu">
|
||||
<div class="pf-c-options-menu__toggle pf-m-text pf-m-plain">
|
||||
<span role="heading" aria-level="4" class="pf-c-options-menu__toggle-text">
|
||||
${msg(
|
||||
str`${this.pages?.startIndex} - ${this.pages?.endIndex} of ${this.pages?.count}`,
|
||||
)}
|
||||
${msg(str`${startIndex} - ${endIndex} of ${pageCount}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,11 @@ import "#components/ak-search-ql/index";
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { WithLicenseSummary } from "#elements/mixins/license";
|
||||
import { PaginatedResponse } from "#elements/table/Table";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
@@ -43,12 +43,33 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
|
||||
PFInputGroup,
|
||||
PFFormControl,
|
||||
css`
|
||||
::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
form.pf-c-input-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ak-search-ql {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button[type="reset"] {
|
||||
position: absolute;
|
||||
inset-inline-end: 0.25em;
|
||||
inset-block-start: 0.25em;
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
line-height: 1;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: initial;
|
||||
color: ButtonText;
|
||||
z-index: var(--pf-global--ZIndex--xs);
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
|
||||
ak-search-ql:state(present) + button[type="reset"] {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -76,24 +97,25 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
|
||||
this.onSearch(value);
|
||||
};
|
||||
|
||||
renderInput(): TemplateResult {
|
||||
protected renderInput(): TemplateResult {
|
||||
if (this.supportsQL && this.hasEnterpriseLicense) {
|
||||
return html`<ak-search-ql
|
||||
aria-label=${ifDefined(this.label)}
|
||||
name="search"
|
||||
required
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
value=${ifDefined(this.defaultValue)}
|
||||
.apiResponse=${this.apiResponse}
|
||||
></ak-search-ql>`;
|
||||
label=${ifPresent(this.label)}
|
||||
role="presentation"
|
||||
name="search"
|
||||
placeholder=${ifPresent(this.placeholder)}
|
||||
value=${ifPresent(this.defaultValue)}
|
||||
.apiResponse=${this.apiResponse}
|
||||
></ak-search-ql>
|
||||
<button type="reset" aria-label=${msg("Clear search")}>×</button>`;
|
||||
}
|
||||
|
||||
return html`<input
|
||||
aria-label=${ifDefined(this.label)}
|
||||
aria-label=${ifPresent(this.label)}
|
||||
name="search"
|
||||
required
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
value=${ifDefined(this.defaultValue)}
|
||||
type="search"
|
||||
placeholder=${ifPresent(this.placeholder)}
|
||||
value=${ifPresent(this.defaultValue)}
|
||||
class="pf-c-form-control"
|
||||
/>`;
|
||||
}
|
||||
@@ -103,19 +125,9 @@ export class TableSearchForm extends WithLicenseSummary(AKElement) {
|
||||
${ref(this.#formRef)}
|
||||
class="pf-c-input-group"
|
||||
@submit=${this.#submitListener}
|
||||
@reset=${this.reset}
|
||||
>
|
||||
${this.renderInput()}
|
||||
<button
|
||||
aria-label=${msg("Clear search")}
|
||||
class="pf-c-button pf-m-control"
|
||||
type="reset"
|
||||
@click=${this.reset}
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button aria-label=${msg("Search")} type="submit" class="pf-c-button pf-m-control">
|
||||
<i class="fas fa-search" aria-hidden="true"></i>
|
||||
</button>
|
||||
</form>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,10 @@ export function Timestamp(timestamp?: Date | null): TemplateResult {
|
||||
}
|
||||
|
||||
const elapsed = formatElapsedTime(timestamp);
|
||||
const title = timestamp.toLocaleString();
|
||||
|
||||
return html` <time datetime=${timestamp.toISOString()} aria-label=${elapsed}>
|
||||
return html` <time datetime=${timestamp.toISOString()} aria-label=${elapsed} title=${title}>
|
||||
<div>${elapsed}</div>
|
||||
<small>${timestamp.toLocaleString()}</small>
|
||||
<small>${timestamp.toLocaleDateString()}</small>
|
||||
</time>`;
|
||||
}
|
||||
|
||||
@@ -83,29 +83,28 @@ export class ScheduleList extends Table<Schedule> {
|
||||
if (this.relObjId !== undefined) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.showOnlyStandalone}
|
||||
@change=${this.#toggleShowOnlyStandalone}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"> </i>
|
||||
</span>
|
||||
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.showOnlyStandalone}
|
||||
@change=${this.#toggleShowOnlyStandalone}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"> </i>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">
|
||||
${msg("Show only standalone schedules")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">
|
||||
${msg("Show only standalone schedules")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
row(item: Schedule): SlottedTemplateResult[] {
|
||||
|
||||
@@ -106,47 +106,44 @@ export class TaskList extends Table<Task> {
|
||||
];
|
||||
|
||||
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">
|
||||
${this.relObjId === undefined
|
||||
? html` <label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.showOnlyStandalone}
|
||||
@change=${this.#toggleShowOnlyStandalone}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"> </i>
|
||||
</span>
|
||||
return html` <div class="pf-c-toolbar__group pf-m-filter-group">
|
||||
<div class="pf-c-toolbar__item pf-m-search-filter">
|
||||
<div class="pf-c-input-group">
|
||||
${this.relObjId === undefined
|
||||
? html` <label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.showOnlyStandalone}
|
||||
@change=${this.#toggleShowOnlyStandalone}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"> </i>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">
|
||||
${msg("Show only standalone tasks")}
|
||||
</span>
|
||||
</label>`
|
||||
: nothing}
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.excludeSuccessful}
|
||||
@change=${this.#toggleExcludeSuccessful}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"> </i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">
|
||||
${msg("Show only standalone tasks")}
|
||||
</span>
|
||||
</label>`
|
||||
: nothing}
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.excludeSuccessful}
|
||||
@change=${this.#toggleExcludeSuccessful}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"> </i>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">
|
||||
${msg("Exclude successful tasks")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</span>
|
||||
<span class="pf-c-switch__label"> ${msg("Exclude successful tasks")} </span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
row(item: Task): SlottedTemplateResult[] {
|
||||
|
||||
@@ -45,15 +45,14 @@ test.describe("Provider Wizard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach("Verification", async ({ page }, { testId }) => {
|
||||
test.afterEach("Verification", async ({ page, form }, { testId }) => {
|
||||
//#region Confirm provider
|
||||
|
||||
const providerName = providerNames.get(testId)!;
|
||||
const { fill, findTextualInput } = form;
|
||||
|
||||
const $provider = await test.step("Find provider via search", async () => {
|
||||
const searchInput = page.getByLabel("Provider Search");
|
||||
|
||||
await searchInput.fill(providerName);
|
||||
const providerSearch = await findTextualInput("Provider Search");
|
||||
|
||||
// We have to wait for the provider to appear in the table,
|
||||
// but several UI elements will be rendered asynchronously.
|
||||
@@ -63,8 +62,8 @@ test.describe("Provider Wizard", () => {
|
||||
let found = false;
|
||||
|
||||
for (let i = 0; i < tries; i++) {
|
||||
await searchInput.press("Enter");
|
||||
await searchInput.blur();
|
||||
await fill(providerSearch, providerName);
|
||||
await providerSearch.press("Enter");
|
||||
|
||||
const $rowEntry = page.getByRole("row", {
|
||||
name: providerName,
|
||||
|
||||
113
web/test/browser/users.test.ts
Normal file
113
web/test/browser/users.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import { randomName } from "#e2e/utils/generators";
|
||||
import { ConsoleLogger } from "#logger/node";
|
||||
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
import { series } from "@goauthentik/core/promises";
|
||||
|
||||
import { snakeCase } from "change-case";
|
||||
|
||||
test.describe("Users", () => {
|
||||
const usernames = new Map<string, string>();
|
||||
const displayNames = new Map<string, string>();
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
test.beforeEach("Prepare user", async ({ page, session }, { testId }) => {
|
||||
const seed = IDGenerator.randomID(6);
|
||||
const displayName = `${randomName(seed)} (${seed})`;
|
||||
|
||||
displayNames.set(testId, displayName);
|
||||
usernames.set(testId, snakeCase(displayName));
|
||||
|
||||
const wizard = page.getByRole("dialog", { name: "New User" });
|
||||
|
||||
await test.step("Authenticate", async () => {
|
||||
await session.login({
|
||||
to: "/if/admin/#/identity/users",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Navigate to new user wizard", async () => {
|
||||
await expect(wizard, "Wizard is initially closed").toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "New User" }).click();
|
||||
|
||||
await expect(wizard, "Wizard opens after clicking on New User").toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach("Verification", async ({ page, form }, { testId }) => {
|
||||
//#region Confirm user
|
||||
|
||||
const username = usernames.get(testId)!;
|
||||
const { fill, findTextualInput } = form;
|
||||
|
||||
const $user = await test.step("Find user via search", async () => {
|
||||
const userSearch = await findTextualInput("User Search");
|
||||
// We have to wait for the user to appear in the table,
|
||||
// but several UI elements will be rendered asynchronously.
|
||||
// We attempt several times to find the user to avoid flakiness.
|
||||
|
||||
const tries = 10;
|
||||
let found = false;
|
||||
|
||||
for (let i = 0; i < tries; i++) {
|
||||
await fill(userSearch, username);
|
||||
await userSearch.press("Enter");
|
||||
|
||||
const $rowEntry = page.getByRole("row", {
|
||||
name: username,
|
||||
});
|
||||
|
||||
ConsoleLogger.info(
|
||||
`${i + 1}/${tries} Waiting for user ${username} to appear in the table`,
|
||||
);
|
||||
|
||||
found = await $rowEntry
|
||||
.waitFor({
|
||||
timeout: 1500,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (found) {
|
||||
ConsoleLogger.info(`User ${username} found in the table`);
|
||||
return $rowEntry;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`User ${username} not found in the table`);
|
||||
});
|
||||
|
||||
await expect($user, "User is visible").toBeVisible();
|
||||
|
||||
//#endregion
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Tests
|
||||
|
||||
test("Simple user", async ({ form, pointer, page }, testInfo) => {
|
||||
const displayName = displayNames.get(testInfo.testId)!;
|
||||
const username = usernames.get(testInfo.testId)!;
|
||||
const { fill } = form;
|
||||
const { click } = pointer;
|
||||
const wizard = page.getByRole("dialog", { name: "New User" });
|
||||
|
||||
await expect(wizard, "Wizard is open at start of test").toBeVisible();
|
||||
|
||||
await series(
|
||||
[fill, /^Username/, username],
|
||||
[fill, /^Display Name/, displayName],
|
||||
[fill, /^Email Address/, `${username}@example.com`],
|
||||
// [click, /^Create User/, "button"],
|
||||
);
|
||||
|
||||
await click("Create User", "button");
|
||||
await expect(wizard, "Wizard closes after creating user").toBeHidden();
|
||||
});
|
||||
|
||||
//#endregion
|
||||
});
|
||||
Reference in New Issue
Block a user