web: Close modal on route navigation (#21622)

* Close dialog on navigation.

* web: update dialog, form, and sidebar styles with logical properties and scroll shadows

Migrate dialog padding CSS variables from physical (top/right/bottom/left) to logical
(block-start/inline-end/block-end/inline-start) naming. Add scroll shadow utility
class (ak-m-scroll-shadows) for scrollable regions. Rework radio input and form
control styles including transparent backgrounds, checkbox-style indicators, and
improved hover states. Refactor FormGroup marker to use CSS custom properties for
open/closed states. Move sidebar padding from nav container to scrollable list.

* web: refine elements and components for accessibility, type safety, and consistency

Add ARIA role and label to dialog body, apply scroll shadow classes to modal body,
sidebar nav, and wizard main. Update ak-status-label to support tri-state
(good/bad/null) rendering with ts-pattern matching and a neutral label. Simplify
FormGroup by removing wrapper div around default slot, adding part attributes for
header styling, and changing description to nullable type. Clean up LogViewer and
StaticTable with proper access modifiers, override annotations, and nullable item
types. Simplify ak-switch-input checked binding and remove unused slot attribute
from ak-radio-input help text.

* web: modernize application pages with modalInvoker and updated form patterns

Refactor ApplicationCheckAccessForm to use static form metadata properties
(verboseName, submitVerb, createLabel), formatAPISuccessMessage, and a private
CoreApi instance. Migrate ApplicationViewPage from ak-forms-modal slots to the
modalInvoker directive for both edit and check-access actions. Accept nullable
input in createPaginatedResponse for better null-safety. Fix casing of dropdown
menu items in ApplicationListPage.

* web: migrate remaining view pages to modalInvoker (#21592)

* Fix visibility check, search params.

* Add scroll shadow.

* Partial revert of input layout.

* Tidy groups.

* Fix check access form invoker, styles.

* Optional sizing.

* Lowercase

* Revise checkbox style.

* Close dialog on navigation.

* Fix padding.

* Touch up shadow heights.

* Migrate remaining view pages to modalInvoker, add e2e coverage.

* Fix alignment.

* Fix click handler, add placeholders.

* Fix issue where form field is not serialized.
This commit is contained in:
Teffen Ellis
2026-04-16 19:04:29 +02:00
committed by GitHub
parent 05bb1d1fdd
commit b6496950bf
43 changed files with 740 additions and 442 deletions

View File

@@ -4,8 +4,12 @@ import "#elements/forms/HorizontalFormElement";
import "#elements/forms/SearchSelect/index"; import "#elements/forms/SearchSelect/index";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { PFSize } from "#common/enums";
import { APIMessage, MessageLevel } from "#common/messages";
import { Form } from "#elements/forms/Form"; import { Form } from "#elements/forms/Form";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { import {
Application, Application,
@@ -16,13 +20,24 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit"; import { CSSResult, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
@customElement("ak-application-check-access-form") @customElement("ak-application-check-access-form")
export class ApplicationCheckAccessForm extends Form<{ forUser: number }> { export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
public static override verboseName = msg("Access");
public static override submitVerb = msg("Check");
public static override createLabel = msg("Check");
public static override submittingVerb = msg("Checking");
static styles: CSSResult[] = [...super.styles, PFDescriptionList];
#api = new CoreApi(DEFAULT_CONFIG);
public override size = PFSize.XLarge;
@property({ attribute: false }) @property({ attribute: false })
public application!: Application; public application!: Application;
@@ -30,19 +45,27 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
public result: PolicyTestResult | null = null; public result: PolicyTestResult | null = null;
@property({ attribute: false }) @property({ attribute: false })
public request?: number; public request: number | null = null;
getSuccessMessage(): string { public override formatAPISuccessMessage(): APIMessage {
return msg("Successfully sent test-request."); return {
level: MessageLevel.success,
message: msg("Successfully sent test-request."),
};
} }
async send(data: { forUser: number }): Promise<PolicyTestResult> { protected override send(data: { forUser: number }): Promise<PolicyTestResult> {
this.request = data.forUser; this.request = data.forUser;
const result = await new CoreApi(DEFAULT_CONFIG).coreApplicationsCheckAccessRetrieve({
slug: this.application?.slug, return this.#api
forUser: data.forUser, .coreApplicationsCheckAccessRetrieve({
}); slug: this.application?.slug,
return (this.result = result); forUser: data.forUser,
})
.then((result) => {
this.result = result;
return result;
});
} }
public override reset(): void { public override reset(): void {
@@ -50,15 +73,14 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
this.result = null; this.result = null;
} }
static styles: CSSResult[] = [...super.styles, PFDescriptionList]; protected renderResult(): SlottedTemplateResult {
const { passing, messages = [], logMessages = [] } = this.result || {};
renderResult(): TemplateResult { return html`<ak-form-element-horizontal label=${msg("Passing")}>
return html`
<ak-form-element-horizontal label=${msg("Passing")}>
<div class="pf-c-form__group-label"> <div class="pf-c-form__group-label">
<div class="c-form__horizontal-group"> <div class="c-form__horizontal-group">
<span class="pf-c-form__label-text"> <span class="pf-c-form__label-text">
<ak-status-label ?good=${this.result?.passing}></ak-status-label> <ak-status-label ?good=${ifPresent(passing)}></ak-status-label>
</span> </span>
</div> </div>
</div> </div>
@@ -67,54 +89,56 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
<div class="pf-c-form__group-label"> <div class="pf-c-form__group-label">
<div class="c-form__horizontal-group"> <div class="c-form__horizontal-group">
<ul> <ul>
${(this.result?.messages || []).length > 0 ${messages.map((m) => {
? this.result?.messages?.map((m) => { return html`<li>
return html`<li> <span class="pf-c-form__label-text">${m}</span>
<span class="pf-c-form__label-text">${m}</span> </li>`;
</li>`; })}
})
: html`<li>
<span class="pf-c-form__label-text">-</span>
</li>`}
</ul> </ul>
</div> </div>
</div> </div>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Log messages")}> <ak-form-element-horizontal label=${msg("Log messages")}>
<ak-log-viewer .items=${this.result?.logMessages}></ak-log-viewer> <ak-log-viewer .items=${logMessages}></ak-log-viewer>
</ak-form-element-horizontal> </ak-form-element-horizontal>`;
`;
} }
protected override renderForm(): TemplateResult { protected override renderForm(): SlottedTemplateResult {
return html`<ak-form-element-horizontal label=${msg("User")} required name="forUser"> return html`<ak-form-element-horizontal label=${msg("User")} required name="forUser">
<ak-search-select <ak-search-select
placeholder=${msg("Select a user...")}
.fetchObjects=${async (query?: string): Promise<User[]> => { .fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = { const args: CoreUsersListRequest = {
ordering: "username", ordering: "username",
}; };
if (query !== undefined) {
if (query) {
args.search = query; args.search = query;
} }
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args);
const users = await this.#api.coreUsersList(args);
return users.results; return users.results;
}} }}
.renderElement=${(user: User): string => { .renderElement=${(user: User): string => {
return user.username; return user.username;
}} }}
.renderDescription=${(user: User): TemplateResult => { .renderDescription=${(user: User): SlottedTemplateResult => {
return html`${user.name}`; return html`${user.name}`;
}} }}
.value=${(user: User | undefined): number | undefined => { .value=${(user: User | undefined): number | undefined => {
return user?.pk; return user?.pk;
}} }}
.selected=${(user: User): boolean => { .selected=${(user: User): boolean => {
return user.pk.toString() === this.request?.toString(); return (
typeof this.request === "number" &&
user.pk.toString() === this.request.toString()
);
}} }}
> >
</ak-search-select> </ak-search-select>
</ak-form-element-horizontal> </ak-form-element-horizontal>
${this.result ? this.renderResult() : nothing}`; ${this.renderResult()}`;
} }
} }

View File

@@ -201,7 +201,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
"Opens the new application wizard, which will guide you through creating a new application with an existing provider.", "Opens the new application wizard, which will guide you through creating a new application with an existing provider.",
)} )}
> >
${msg("With New Provider...")} ${msg("with New Provider...")}
</button> </button>
</li> </li>
<li role="presentation"> <li role="presentation">
@@ -214,7 +214,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
"Opens the new application form, which will guide you through creating a new application with an existing provider.", "Opens the new application form, which will guide you through creating a new application with an existing provider.",
)} )}
> >
${msg("With Existing Provider...")} ${msg("with Existing Provider...")}
</button> </button>
</li> </li>
</menu> </menu>

View File

@@ -16,11 +16,15 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network"; import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { WithLicenseSummary } from "#elements/mixins/license"; import { WithLicenseSummary } from "#elements/mixins/license";
import { setPageDetails } from "#components/ak-page-navbar"; import { setPageDetails } from "#components/ak-page-navbar";
import renderDescriptionList from "#components/DescriptionList"; import renderDescriptionList from "#components/DescriptionList";
import { ApplicationCheckAccessForm } from "#admin/applications/ApplicationCheckAccessForm";
import { ApplicationForm } from "#admin/applications/ApplicationForm";
import { import {
Application, Application,
ContentTypeEnum, ContentTypeEnum,
@@ -181,36 +185,28 @@ export class ApplicationViewPage extends WithLicenseSummary(AKElement) {
], ],
[ [
msg("Related actions"), msg("Related actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-secondary pf-m-block"
<span slot="header"> ${msg("Update Application")} </span> ${modalInvoker(ApplicationForm, {
<ak-application-form instancePk: this.application.slug,
slot="form" })}
.instancePk=${this.application.slug} >
> ${msg("Edit")}
</ak-application-form> </button>
<button <button
slot="trigger" class="pf-c-button pf-m-secondary pf-m-block"
class="pf-c-button pf-m-secondary pf-m-block" ${modalInvoker(
> ApplicationCheckAccessForm,
${msg("Edit")} {
</button> application: this.application,
</ak-forms-modal> },
<ak-forms-modal .closeAfterSuccessfulSubmit=${false}> {
<span slot="submit">${msg("Check")}</span> closedBy: "closerequest",
<span slot="header"> ${msg("Check Application access")} </span> },
<ak-application-check-access-form )}
slot="form" >
.application=${this.application} ${msg("Check access")}
> </button>
</ak-application-check-access-form>
<button
slot="trigger"
class="pf-c-button pf-m-secondary pf-m-block"
>
${msg("Check access")}
</button>
</ak-forms-modal>
${this.application.launchUrl ${this.application.launchUrl
? html`<a ? html`<a
target="_blank" target="_blank"
@@ -220,7 +216,7 @@ export class ApplicationViewPage extends WithLicenseSummary(AKElement) {
> >
${msg("Launch")} ${msg("Launch")}
</a>` </a>`
: nothing}`, : null}`,
], ],
])} ])}
</div> </div>

View File

@@ -8,7 +8,6 @@ import { SlottedTemplateResult } from "#elements/types";
import { Provider, ProvidersApi } from "@goauthentik/api"; import { Provider, ProvidersApi } from "@goauthentik/api";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
@customElement("ak-provider-table") @customElement("ak-provider-table")
@@ -23,29 +22,24 @@ export class ProviderTable extends Table<Provider> {
public override order = "name"; public override order = "name";
protected async apiEndpoint(): Promise<PaginatedResponse<Provider>> { protected override async apiEndpoint(): Promise<PaginatedResponse<Provider>> {
return new ProvidersApi(DEFAULT_CONFIG).providersAllList({ return new ProvidersApi(DEFAULT_CONFIG).providersAllList({
...(await this.defaultEndpointConfig()), ...(await this.defaultEndpointConfig()),
backchannel: this.backchannel, backchannel: this.backchannel,
}); });
} }
protected columns: TableColumn[] = [ protected override columns: TableColumn[] = [
// --- // ---
[msg("Name"), "username"], [msg("Name"), "username"],
[msg("Type")], [msg("Type")],
]; ];
protected row(item: Provider): SlottedTemplateResult[] { protected override row(item: Provider): SlottedTemplateResult[] {
return [ return [item.name, item.verboseName];
html`<div>
<div>${item.name}</div>
</div>`,
html`${item.verboseName}`,
];
} }
protected renderSelectedChip(item: Provider): SlottedTemplateResult { protected override renderSelectedChip(item: Provider): SlottedTemplateResult {
return item.name; return item.name;
} }
} }

View File

@@ -73,7 +73,6 @@ export class AkBackchannelProvidersInput extends AKElement {
}} }}
> >
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing} ${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
<ak-provider-table backchannel></ak-provider-table> <ak-provider-table backchannel></ak-provider-table>
</ak-form> </ak-form>
`); `);
@@ -100,7 +99,11 @@ export class AkBackchannelProvidersInput extends AKElement {
<i class="fas fa-plus" aria-hidden="true"></i> <i class="fas fa-plus" aria-hidden="true"></i>
</button> </button>
<div class="pf-c-form-control"> <div class="pf-c-form-control">
<ak-chip-group>${map(this.providers, renderOneChip)}</ak-chip-group> <ak-chip-group
@click=${this.openSelectBackchannelProvidersModal}
placeholder=${msg("Select one or more backchannel providers...")}
>${map(this.providers, renderOneChip)}</ak-chip-group
>
</div> </div>
</div> </div>
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing} ${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}

View File

@@ -20,6 +20,8 @@ import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-endpoints-device-form") @customElement("ak-endpoints-device-form")
export class EndpointDeviceForm extends ModelForm<EndpointDevice, string> { export class EndpointDeviceForm extends ModelForm<EndpointDevice, string> {
public static override verboseName = msg("Device");
public static override verboseNamePlural = msg("Devices");
loadInstance(pk: string): Promise<EndpointDevice> { loadInstance(pk: string): Promise<EndpointDevice> {
return new EndpointsApi(DEFAULT_CONFIG).endpointsDevicesRetrieve({ return new EndpointsApi(DEFAULT_CONFIG).endpointsDevicesRetrieve({
deviceUuid: pk, deviceUuid: pk,

View File

@@ -1,15 +1,16 @@
import "#elements/cards/AggregateCard"; import "#elements/cards/AggregateCard";
import "#elements/forms/DeleteBulkForm"; import "#elements/forms/DeleteBulkForm";
import "#admin/endpoints/devices/DeviceForm";
import "#admin/endpoints/devices/DeviceAddHowTo"; import "#admin/endpoints/devices/DeviceAddHowTo";
import "#elements/forms/ModalForm";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { modalInvoker } from "#elements/dialogs";
import { PaginatedResponse, TableColumn, Timestamp } from "#elements/table/Table"; import { PaginatedResponse, TableColumn, Timestamp } from "#elements/table/Table";
import { TablePage } from "#elements/table/TablePage"; import { TablePage } from "#elements/table/TablePage";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import { EndpointDeviceForm } from "#admin/endpoints/devices/DeviceForm";
import { DeviceSummary, EndpointDevice, EndpointsApi } from "@goauthentik/api"; import { DeviceSummary, EndpointDevice, EndpointsApi } from "@goauthentik/api";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@@ -132,17 +133,14 @@ export class DeviceListPage extends TablePage<EndpointDevice> {
html`${item.facts.data.os?.name} ${item.facts.data.os?.version}`, html`${item.facts.data.os?.name} ${item.facts.data.os?.version}`,
html`${item.accessGroupObj?.name || "-"}`, html`${item.accessGroupObj?.name || "-"}`,
item.facts.created ? Timestamp(item.facts.created) : html`-`, item.facts.created ? Timestamp(item.facts.created) : html`-`,
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-plain"
<span slot="header">${msg("Update Device")}</span> ${modalInvoker(EndpointDeviceForm, { instancePk: item.deviceUuid })}
<ak-endpoints-device-form slot="form" .instancePk=${item.deviceUuid}> >
</ak-endpoints-device-form> <pf-tooltip position="top" content=${msg("Edit")}>
<button slot="trigger" class="pf-c-button pf-m-plain"> <i class="fas fa-edit" aria-hidden="true"></i>
<pf-tooltip position="top" content=${msg("Edit")}> </pf-tooltip>
<i class="fas fa-edit" aria-hidden="true"></i> </button>`,
</pf-tooltip>
</button>
</ak-forms-modal>`,
]; ];
} }

View File

@@ -5,20 +5,20 @@ import "#admin/endpoints/devices/facts/DeviceProcessTable";
import "#admin/endpoints/devices/facts/DeviceUserTable"; import "#admin/endpoints/devices/facts/DeviceUserTable";
import "#admin/endpoints/devices/facts/DeviceSoftwareTable"; import "#admin/endpoints/devices/facts/DeviceSoftwareTable";
import "#admin/endpoints/devices/facts/DeviceGroupTable"; import "#admin/endpoints/devices/facts/DeviceGroupTable";
import "#admin/endpoints/devices/DeviceForm";
import "#admin/endpoints/devices/DeviceEvents"; import "#admin/endpoints/devices/DeviceEvents";
import "#elements/forms/ModalForm";
import "#elements/Tabs"; import "#elements/Tabs";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { APIError, parseAPIResponseError } from "#common/errors/network"; import { APIError, parseAPIResponseError } from "#common/errors/network";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { Timestamp } from "#elements/table/shared"; import { Timestamp } from "#elements/table/shared";
import { setPageDetails } from "#components/ak-page-navbar"; import { setPageDetails } from "#components/ak-page-navbar";
import renderDescriptionList, { DescriptionPair } from "#components/DescriptionList"; import renderDescriptionList, { DescriptionPair } from "#components/DescriptionList";
import { EndpointDeviceForm } from "#admin/endpoints/devices/DeviceForm";
import { getSize, osFamilyToLabel, trySortNumerical } from "#admin/endpoints/devices/utils"; import { getSize, osFamilyToLabel, trySortNumerical } from "#admin/endpoints/devices/utils";
import { DeviceConnection, Disk, EndpointDeviceDetails, EndpointsApi } from "@goauthentik/api"; import { DeviceConnection, Disk, EndpointDeviceDetails, EndpointsApi } from "@goauthentik/api";
@@ -124,18 +124,14 @@ export class DeviceViewPage extends AKElement {
[msg("Device access group"), this.device?.accessGroupObj?.name ?? "-"], [msg("Device access group"), this.device?.accessGroupObj?.name ?? "-"],
[ [
msg("Actions"), msg("Actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-primary"
<span slot="header">${msg("Update Device")}</span> ${modalInvoker(EndpointDeviceForm, {
<ak-endpoints-device-form instancePk: this.device?.deviceUuid,
slot="form" })}
.instancePk=${this.device?.deviceUuid} >
> ${msg("Edit")}
</ak-endpoints-device-form> </button>`,
<button slot="trigger" class="pf-c-button pf-m-primary">
${msg("Edit")}
</button>
</ak-forms-modal>`,
], ],
], ],
{ horizontal: true }, { horizontal: true },

View File

@@ -1,6 +1,5 @@
import "#admin/flows/BoundStagesList"; import "#admin/flows/BoundStagesList";
import "#admin/flows/FlowDiagram"; import "#admin/flows/FlowDiagram";
import "#admin/flows/FlowForm";
import "#admin/policies/BoundPoliciesList"; import "#admin/policies/BoundPoliciesList";
import "#admin/rbac/ak-rbac-object-permission-page"; import "#admin/rbac/ak-rbac-object-permission-page";
import "#admin/events/ObjectChangelog"; import "#admin/events/ObjectChangelog";
@@ -11,11 +10,13 @@ import { AndNext, DEFAULT_CONFIG } from "#common/api/config";
import { isResponseErrorLike } from "#common/errors/network"; import { isResponseErrorLike } from "#common/errors/network";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import { setPageDetails } from "#components/ak-page-navbar"; import { setPageDetails } from "#components/ak-page-navbar";
import renderDescriptionList from "#components/DescriptionList"; import renderDescriptionList from "#components/DescriptionList";
import { FlowForm } from "#admin/flows/FlowForm";
import { DesignationToLabel } from "#admin/flows/utils"; import { DesignationToLabel } from "#admin/flows/utils";
import { Flow, FlowsApi, ModelEnum } from "@goauthentik/api"; import { Flow, FlowsApi, ModelEnum } from "@goauthentik/api";
@@ -97,21 +98,14 @@ export class FlowViewPage extends AKElement {
], ],
[ [
msg("Related actions"), msg("Related actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-block pf-m-secondary"
<span slot="header"> ${msg("Update Flow")} </span> ${modalInvoker(FlowForm, {
<ak-flow-form instancePk: this.flow.slug,
slot="form" })}
.instancePk=${this.flow.slug} >
> ${msg("Edit")}
</ak-flow-form> </button>
<button
slot="trigger"
class="pf-c-button pf-m-block pf-m-secondary"
>
${msg("Edit")}
</button>
</ak-forms-modal>
<a <a
class="pf-c-button pf-m-block pf-m-secondary" class="pf-c-button pf-m-block pf-m-secondary"
href=${this.flow.exportUrl} href=${this.flow.exportUrl}

View File

@@ -1,4 +1,3 @@
import "#admin/groups/ak-group-form";
import "#admin/groups/RelatedUserList"; import "#admin/groups/RelatedUserList";
import "#admin/rbac/ak-rbac-object-permission-page"; import "#admin/rbac/ak-rbac-object-permission-page";
import "#admin/roles/ak-related-role-table"; import "#admin/roles/ak-related-role-table";
@@ -10,19 +9,21 @@ import "#elements/CodeMirror";
import "#elements/Tabs"; import "#elements/Tabs";
import "#elements/buttons/ActionButton/index"; import "#elements/buttons/ActionButton/index";
import "#elements/buttons/SpinnerButton/index"; import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/ModalForm";
import "#elements/ak-mdx/ak-mdx"; import "#elements/ak-mdx/ak-mdx";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { WithLicenseSummary } from "#elements/mixins/license"; import { WithLicenseSummary } from "#elements/mixins/license";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import { setPageDetails } from "#components/ak-page-navbar"; import { setPageDetails } from "#components/ak-page-navbar";
import renderDescriptionList from "#components/DescriptionList"; import renderDescriptionList from "#components/DescriptionList";
import { GroupForm } from "#admin/groups/ak-group-form";
import { ContentTypeEnum, CoreApi, Group, ModelEnum } from "@goauthentik/api"; import { ContentTypeEnum, CoreApi, Group, ModelEnum } from "@goauthentik/api";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
@@ -150,18 +151,14 @@ export class GroupViewPage extends WithLicenseSummary(AKElement) {
], ],
[ [
msg("Related actions"), msg("Related actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-primary pf-m-block"
<span slot="header">${msg("Update Group")}</span> ${modalInvoker(GroupForm, {
<ak-group-form slot="form" .instancePk=${this.group.pk}> instancePk: this.group.pk,
</ak-group-form> })}
<button >
slot="trigger" ${msg("Edit")}
class="pf-m-primary pf-c-button pf-m-block" </button>`,
>
${msg("Edit")}
</button>
</ak-forms-modal>`,
], ],
])} ])}
</div> </div>

View File

@@ -1,19 +1,19 @@
import "#admin/groups/ak-group-form";
import "#admin/users/ak-user-group-table"; import "#admin/users/ak-user-group-table";
import "#components/ak-status-label"; import "#components/ak-status-label";
import "#elements/buttons/SpinnerButton/index"; import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/DeleteBulkForm"; import "#elements/forms/DeleteBulkForm";
import "#elements/forms/HorizontalFormElement"; import "#elements/forms/HorizontalFormElement";
import "#elements/forms/ModalForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { renderModal } from "#elements/dialogs"; import { modalInvoker, renderModal } from "#elements/dialogs";
import { AKFormSubmitEvent, Form } from "#elements/forms/Form"; import { AKFormSubmitEvent, Form } from "#elements/forms/Form";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table"; import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import { GroupForm } from "#admin/groups/ak-group-form";
import { CoreApi, Group, User } from "@goauthentik/api"; import { CoreApi, Group, User } from "@goauthentik/api";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
@@ -23,6 +23,10 @@ import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-group-related-add") @customElement("ak-group-related-add")
export class RelatedGroupAdd extends Form<{ groups: string[] }> { export class RelatedGroupAdd extends Form<{ groups: string[] }> {
public static override verboseName = msg("Group");
public static override submitVerb = msg("Add");
public static override createLabel = msg("Add");
@property({ attribute: false }) @property({ attribute: false })
public user?: User; public user?: User;
@@ -74,7 +78,10 @@ export class RelatedGroupAdd extends Form<{ groups: string[] }> {
</pf-tooltip> </pf-tooltip>
</button> </button>
<div class="pf-c-form-control"> <div class="pf-c-form-control">
<ak-chip-group> <ak-chip-group
@click=${this.openUserGroupSelectModal}
placeholder=${msg("Select one or more groups...")}
>
${this.groupsToAdd.map((group) => { ${this.groupsToAdd.map((group) => {
return html`<ak-chip return html`<ak-chip
removable removable
@@ -151,40 +158,30 @@ export class RelatedGroupList extends Table<Group> {
return [ return [
html`<a href="#/identity/groups/${item.pk}">${item.name}</a>`, html`<a href="#/identity/groups/${item.pk}">${item.name}</a>`,
html`<ak-status-label type="neutral" ?good=${item.isSuperuser}></ak-status-label>`, html`<ak-status-label type="neutral" ?good=${item.isSuperuser}></ak-status-label>`,
html` <ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-plain"
<span slot="header">${msg("Update Group")}</span> ${modalInvoker(GroupForm, { instancePk: item.pk })}
<ak-group-form slot="form" .instancePk=${item.pk}> </ak-group-form> >
<button slot="trigger" class="pf-c-button pf-m-plain"> <pf-tooltip position="top" content=${msg("Edit")}>
<pf-tooltip position="top" content=${msg("Edit")}> <i class="fas fa-edit" aria-hidden="true"></i>
<i class="fas fa-edit" aria-hidden="true"></i> </pf-tooltip>
</pf-tooltip> </button>`,
</button>
</ak-forms-modal>`,
]; ];
} }
renderToolbar(): TemplateResult { renderToolbar(): TemplateResult {
return html` return html`
${this.targetUser ${this.targetUser
? html`<ak-forms-modal> ? html`<button
<span slot="submit">${msg("Add")}</span> class="pf-c-button pf-m-primary"
<span slot="header">${msg("Add Group")}</span> ${modalInvoker(RelatedGroupAdd, { user: this.targetUser })}
<ak-group-related-add .user=${this.targetUser} slot="form"> >
</ak-group-related-add> ${msg("Add to existing group")}
<button slot="trigger" class="pf-c-button pf-m-primary"> </button>`
${msg("Add to existing group")}
</button>
</ak-forms-modal>`
: nothing} : nothing}
<ak-forms-modal> <button class="pf-c-button pf-m-secondary" ${modalInvoker(GroupForm)}>
<span slot="submit">${msg("Create")}</span> ${msg("Add new group")}
<span slot="header">${msg("Create Group")}</span> </button>
<ak-group-form slot="form"> </ak-group-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Add new group")}
</button>
</ak-forms-modal>
${super.renderToolbar()} ${super.renderToolbar()}
`; `;
} }

View File

@@ -1,5 +1,4 @@
import "#admin/providers/RelatedApplicationButton"; import "#admin/providers/RelatedApplicationButton";
import "#admin/providers/oauth2/OAuth2ProviderForm";
import "#admin/events/ObjectChangelog"; import "#admin/events/ObjectChangelog";
import "#admin/rbac/ak-rbac-object-permission-page"; import "#admin/rbac/ak-rbac-object-permission-page";
import "#admin/rbac/ObjectPermissionModal"; import "#admin/rbac/ObjectPermissionModal";
@@ -15,10 +14,13 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import renderDescriptionList from "#components/DescriptionList"; import renderDescriptionList from "#components/DescriptionList";
import { OAuth2ProviderFormPage } from "#admin/providers/oauth2/OAuth2ProviderForm";
import { import {
ClientTypeEnum, ClientTypeEnum,
CoreApi, CoreApi,
@@ -238,21 +240,14 @@ export class OAuth2ProviderViewPage extends AKElement {
], ],
[ [
msg("Related actions"), msg("Related actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-primary pf-m-block"
<span slot="header">${msg("Update OAuth2 Provider")}</span> ${modalInvoker(OAuth2ProviderFormPage, {
<ak-provider-oauth2-form instancePk: this.provider?.pk || 0,
slot="form" })}
.instancePk=${this.provider?.pk || 0} >
> ${msg("Edit")}
</ak-provider-oauth2-form> </button>`,
<button
slot="trigger"
class="pf-c-button pf-m-primary pf-m-block"
>
${msg("Edit")}
</button>
</ak-forms-modal>`,
], ],
])} ])}
</div> </div>

View File

@@ -1,5 +1,4 @@
import "#admin/providers/RelatedApplicationButton"; import "#admin/providers/RelatedApplicationButton";
import "#admin/providers/ssf/SSFProviderFormPage";
import "#admin/providers/ssf/StreamTable"; import "#admin/providers/ssf/StreamTable";
import "#admin/events/ObjectChangelog"; import "#admin/events/ObjectChangelog";
import "#admin/rbac/ObjectPermissionModal"; import "#admin/rbac/ObjectPermissionModal";
@@ -14,10 +13,13 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import renderDescriptionList from "#components/DescriptionList"; import renderDescriptionList from "#components/DescriptionList";
import { SSFProviderFormPage } from "#admin/providers/ssf/SSFProviderFormPage";
import { ModelEnum, ProvidersApi, SSFProvider } from "@goauthentik/api"; import { ModelEnum, ProvidersApi, SSFProvider } from "@goauthentik/api";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@@ -155,15 +157,14 @@ export class SSFProviderViewPage extends AKElement {
], ],
[ [
msg("Related actions"), msg("Related actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-primary pf-m-block"
<span slot="header">${msg("Update SSF Provider")}</span> ${modalInvoker(SSFProviderFormPage, {
<ak-provider-ssf-form slot="form" .instancePk=${this.provider.pk}> instancePk: this.provider.pk,
</ak-provider-ssf-form> })}
<button slot="trigger" class="pf-c-button pf-m-primary pf-m-block"> >
${msg("Edit")} ${msg("Edit")}
</button> </button>`,
</ak-forms-modal>`,
], ],
])} ])}
</div> </div>

View File

@@ -83,7 +83,10 @@ export class AddRelatedRoleForm extends Form<{ roles: string[] }> {
</pf-tooltip> </pf-tooltip>
</button> </button>
<div class="pf-c-form-control"> <div class="pf-c-form-control">
<ak-chip-group> <ak-chip-group
@click=${this.openRolesSelectionModal}
placeholder=${msg("Select one or more roles...")}
>
${this.rolesToAdd.map((role) => { ${this.rolesToAdd.map((role) => {
return html`<ak-chip return html`<ak-chip
removable removable

View File

@@ -85,7 +85,10 @@ export class RolePermissionForm extends ModelForm<RolePermissionAssign, number>
</button> </button>
<div class="pf-c-form-control"> <div class="pf-c-form-control">
<ak-chip-group> <ak-chip-group
@click=${this.openSelectPermissionsModal}
placeholder=${msg("Select one or more permissions...")}
>
${this.permissionsToAdd.map((permission) => { ${this.permissionsToAdd.map((permission) => {
return html`<ak-chip return html`<ak-chip
removable removable

View File

@@ -2,21 +2,22 @@ import "#admin/groups/RelatedGroupList";
import "#admin/groups/RelatedUserList"; import "#admin/groups/RelatedUserList";
import "#admin/rbac/ak-rbac-object-permission-page"; import "#admin/rbac/ak-rbac-object-permission-page";
import "#admin/lifecycle/ObjectLifecyclePage"; import "#admin/lifecycle/ObjectLifecyclePage";
import "#admin/roles/ak-role-form";
import "#admin/events/ObjectChangelog"; import "#admin/events/ObjectChangelog";
import "#admin/events/UserEvents"; import "#admin/events/UserEvents";
import "#elements/Tabs"; import "#elements/Tabs";
import "#elements/forms/ModalForm";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { WithLicenseSummary } from "#elements/mixins/license"; import { WithLicenseSummary } from "#elements/mixins/license";
import { setPageDetails } from "#components/ak-page-navbar"; import { setPageDetails } from "#components/ak-page-navbar";
import { renderDescriptionList } from "#components/DescriptionList"; import { renderDescriptionList } from "#components/DescriptionList";
import { RoleForm } from "#admin/roles/ak-role-form";
import { ContentTypeEnum, ModelEnum, RbacApi, Role } from "@goauthentik/api"; import { ContentTypeEnum, ModelEnum, RbacApi, Role } from "@goauthentik/api";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
@@ -99,21 +100,14 @@ export class RoleViewPage extends WithLicenseSummary(AKElement) {
[msg("Name"), this.targetRole.name], [msg("Name"), this.targetRole.name],
[ [
msg("Related actions"), msg("Related actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-primary pf-m-block"
<span slot="header">${msg("Update Role")}</span> ${modalInvoker(RoleForm, {
<ak-role-form instancePk: this.targetRole.pk,
slot="form" })}
.instancePk=${this.targetRole.pk} >
> ${msg("Edit")}
</ak-role-form> </button>`,
<button
slot="trigger"
class="pf-c-button pf-m-primary pf-m-block"
>
${msg("Edit")}
</button>
</ak-forms-modal>`,
], ],
])} ])}
</div> </div>

View File

@@ -3,6 +3,8 @@ import { ModelForm } from "#elements/forms/ModelForm";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
export abstract class BaseSourceForm<T extends object = object> extends ModelForm<T, string> { export abstract class BaseSourceForm<T extends object = object> extends ModelForm<T, string> {
public static override verboseName = msg("Source");
public static override verboseNamePlural = msg("Sources");
getSuccessMessage(): string { getSuccessMessage(): string {
return this.instance return this.instance
? msg("Successfully updated source.") ? msg("Successfully updated source.")

View File

@@ -1,6 +1,5 @@
import "#admin/rbac/ak-rbac-object-permission-page"; import "#admin/rbac/ak-rbac-object-permission-page";
import "#admin/sources/ldap/LDAPSourceConnectivity"; import "#admin/sources/ldap/LDAPSourceConnectivity";
import "#admin/sources/ldap/LDAPSourceForm";
import "#admin/sources/ldap/LDAPSourceUserList"; import "#admin/sources/ldap/LDAPSourceUserList";
import "#admin/sources/ldap/LDAPSourceGroupList"; import "#admin/sources/ldap/LDAPSourceGroupList";
import "#admin/events/ObjectChangelog"; import "#admin/events/ObjectChangelog";
@@ -8,7 +7,6 @@ import "#elements/CodeMirror";
import "#elements/Tabs"; import "#elements/Tabs";
import "#elements/buttons/ActionButton/index"; import "#elements/buttons/ActionButton/index";
import "#elements/buttons/SpinnerButton/index"; import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/ModalForm";
import "#elements/sync/SyncStatusCard"; import "#elements/sync/SyncStatusCard";
import "#elements/tasks/ScheduleList"; import "#elements/tasks/ScheduleList";
@@ -16,10 +14,13 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import renderDescriptionList from "#components/DescriptionList"; import renderDescriptionList from "#components/DescriptionList";
import { LDAPSourceForm } from "#admin/sources/ldap/LDAPSourceForm";
import { LDAPSource, ModelEnum, SourcesApi } from "@goauthentik/api"; import { LDAPSource, ModelEnum, SourcesApi } from "@goauthentik/api";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@@ -105,23 +106,14 @@ export class LDAPSourceViewPage extends AKElement {
], ],
[ [
msg("Related actions"), msg("Related actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-primary pf-m-block"
<span slot="header" ${modalInvoker(LDAPSourceForm, {
>${msg("Update LDAP Source")}</span instancePk: this.source?.slug,
> })}
<ak-source-ldap-form >
slot="form" ${msg("Edit")}
.instancePk=${this.source?.slug} </button>`,
>
</ak-source-ldap-form>
<button
slot="trigger"
class="pf-c-button pf-m-primary pf-m-block"
>
${msg("Edit")}
</button>
</ak-forms-modal>`,
], ],
], ],
{ twocolumn: true }, { twocolumn: true },

View File

@@ -1,22 +1,23 @@
import "#admin/policies/BoundPoliciesList"; import "#admin/policies/BoundPoliciesList";
import "#admin/rbac/ak-rbac-object-permission-page"; import "#admin/rbac/ak-rbac-object-permission-page";
import "#admin/sources/oauth/OAuthSourceDiagram"; import "#admin/sources/oauth/OAuthSourceDiagram";
import "#admin/sources/oauth/OAuthSourceForm";
import "#admin/events/ObjectChangelog"; import "#admin/events/ObjectChangelog";
import "#elements/CodeMirror"; import "#elements/CodeMirror";
import "#elements/Tabs"; import "#elements/Tabs";
import "#elements/buttons/SpinnerButton/index"; import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/ModalForm";
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { modalInvoker } from "#elements/dialogs";
import { sourceBindingTypeNotices } from "#elements/sources/utils"; import { sourceBindingTypeNotices } from "#elements/sources/utils";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import renderDescriptionList from "#components/DescriptionList"; import renderDescriptionList from "#components/DescriptionList";
import { OAuthSourceForm } from "#admin/sources/oauth/OAuthSourceForm";
import { ModelEnum, OAuthSource, ProviderTypeEnum, SourcesApi } from "@goauthentik/api"; import { ModelEnum, OAuthSource, ProviderTypeEnum, SourcesApi } from "@goauthentik/api";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
@@ -137,21 +138,14 @@ export class OAuthSourceViewPage extends AKElement {
], ],
[ [
msg("Related actions"), msg("Related actions"),
html`<ak-forms-modal> html`<button
<span slot="submit">${msg("Save Changes")}</span> class="pf-c-button pf-m-primary pf-m-block"
<span slot="header">${msg("Update OAuth Source")}</span> ${modalInvoker(OAuthSourceForm, {
<ak-source-oauth-form instancePk: this.source.slug,
slot="form" })}
.instancePk=${this.source.slug} >
> ${msg("Edit")}
</ak-source-oauth-form> </button>`,
<button
slot="trigger"
class="pf-c-button pf-m-primary pf-m-block"
>
${msg("Edit")}
</button>
</ak-forms-modal>`,
], ],
])} ])}
</div> </div>

View File

@@ -251,10 +251,19 @@ export class UserListPage extends WithBrandConfig(
const displayName = formatUserDisplayName(item); const displayName = formatUserDisplayName(item);
return [ return [
html`<img class="pf-c-avatar pf-m-hidden pf-m-visible-on-xl" src=${item.avatar} />`, html`<img
html`<a href="#/identity/users/${item.pk}"> class="pf-c-avatar pf-m-hidden pf-m-visible-on-xl"
<div>${item.username}</div> src=${item.avatar}
<small>${item.name ? item.name : html`&lt;${msg("No name set")}&gt;`}</small> alt=${msg(str`Avatar for ${displayName}`)}
/>`,
html`<a
href="#/identity/users/${item.pk}"
aria-label=${msg(str`View details for ${displayName}`)}
>
<div aria-label=${msg(str`Username: ${item.username}`)}>${item.username}</div>
<small aria-label=${msg(str`Display name: ${displayName || msg("No name set")}`)}
>${displayName ? item.name : html`&lt;${msg("No name set")}&gt;`}</small
>
</a>`, </a>`,
html`<ak-status-label ?good=${item.isActive}></ak-status-label>`, html`<ak-status-label ?good=${item.isActive}></ak-status-label>`,
Timestamp(item.lastLogin), Timestamp(item.lastLogin),

View File

@@ -9,7 +9,7 @@ import { SlottedTemplateResult } from "#elements/types";
import { CoreApi, Group } from "@goauthentik/api"; import { CoreApi, Group } from "@goauthentik/api";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, html, nothing } from "lit"; import { CSSResult, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
@@ -26,44 +26,44 @@ export class UserGroupTable extends Table<Group> {
public override order = "name"; public override order = "name";
protected async apiEndpoint(): Promise<PaginatedResponse<Group>> { protected override async apiEndpoint(): Promise<PaginatedResponse<Group>> {
return new CoreApi(DEFAULT_CONFIG).coreGroupsList({ return new CoreApi(DEFAULT_CONFIG).coreGroupsList({
...(await this.defaultEndpointConfig()), ...(await this.defaultEndpointConfig()),
includeUsers: false, includeUsers: false,
}); });
} }
protected columns: TableColumn[] = [ protected override columns: TableColumn[] = [
[msg("Name"), "username"], [msg("Name"), "username"],
[msg("Superuser"), "is_superuser"], [msg("Superuser"), "is_superuser"],
[msg("Members"), ""], [msg("Members"), ""],
]; ];
protected row(item: Group): SlottedTemplateResult[] { protected override row(item: Group): SlottedTemplateResult[] {
return [ return [
html`<div>${item.name}</div>`, item.name,
html`<ak-status-label type="neutral" ?good=${item.isSuperuser}></ak-status-label>`, html`<ak-status-label type="neutral" ?good=${item.isSuperuser}></ak-status-label>`,
html`${(item.users || []).length}`, item.users?.length || 0,
]; ];
} }
protected renderSelectedChip(item: Group): SlottedTemplateResult { protected override renderSelectedChip(item: Group): SlottedTemplateResult {
return item.name; return item.name;
} }
protected override render(): SlottedTemplateResult { protected override render(): SlottedTemplateResult {
const willSuperuser = this.selectedElements.filter((g) => g.isSuperuser).length; const willSuperuser = this.selectedElements.some((g) => g.isSuperuser);
return html`${willSuperuser if (!willSuperuser) {
? html` return super.render();
<div class="pf-c-banner pf-m-warning"> }
${msg(
"Warning: Adding the user to the selected group(s) will give them superuser permissions.", return html`<div class="pf-c-banner pf-m-warning">
)} ${msg(
</div> "Warning: Adding the user to the selected group(s) will give them superuser permissions.",
` )}
: nothing} </div>
${super.render()}`; ${super.render()}`;
} }
} }

View File

@@ -50,9 +50,9 @@ export interface PaginatedResponse<T, A extends object = object> {
* @param input An iterable of items to include in the results array. * @param input An iterable of items to include in the results array.
*/ */
export function createPaginatedResponse<T = unknown, A extends object = object>( export function createPaginatedResponse<T = unknown, A extends object = object>(
input: Iterable<T> = [], input?: Iterable<T> | null,
): PaginatedResponse<T, A> { ): PaginatedResponse<T, A> {
const results = Array.from(input); const results = Array.from(input ?? []);
return { return {
pagination: { pagination: {

View File

@@ -34,12 +34,7 @@ export class AkRadioInput<T extends Jsonifiable> extends HorizontalLightComponen
const helpText = this.help?.trim(); const helpText = this.help?.trim();
return html`${helpText return html`${helpText
? html`<p ? html`<p part="radio-help" class="pf-c-form__helper-radio" id=${this.helpID}>
part="radio-help"
class="pf-c-form__helper-radio"
id=${this.helpID}
slot="label-end"
>
${helpText} ${helpText}
</p>` </p>`
: null}<ak-radio : null}<ak-radio

View File

@@ -1,9 +1,12 @@
import { AKElement } from "#elements/Base"; import { AKElement } from "#elements/Base";
import { SlottedTemplateResult } from "#elements/types";
import Styles from "#components/ak-status-label.css"; import Styles from "#components/ak-status-label.css";
import { P4Disposition } from "#styles/patternfly/constants"; import { P4Disposition } from "#styles/patternfly/constants";
import { match, P } from "ts-pattern";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { html } from "lit"; import { html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
@@ -43,30 +46,35 @@ export class AkStatusLabel extends AKElement {
static styles = [PFLabel, Styles]; static styles = [PFLabel, Styles];
@property({ type: Boolean }) @property({ type: Boolean })
good = false; public good: boolean | null = null;
@property({ type: String, attribute: "good-label" }) @property({ type: String, attribute: "good-label" })
goodLabel = msg("Yes"); public goodLabel = msg("Yes");
@property({ type: String, attribute: "bad-label" }) @property({ type: String, attribute: "bad-label" })
badLabel = msg("No"); public badLabel = msg("No");
@property({ type: String, attribute: "neutral-label" })
public neutralLabel = msg("-");
@property({ type: Boolean }) @property({ type: Boolean })
compact = false; public compact = false;
@property({ type: String }) @property({ type: String })
type: P4Disposition = P4Disposition.Error; public type: P4Disposition = P4Disposition.Error;
render() { protected override render(): SlottedTemplateResult {
const details = statusToDetails.get(this.type); const details = statusToDetails.get(this.type);
if (!details) { if (!details) {
throw new TypeError(`Bad status type [${this.type}] passed to ak-status-label`); throw new TypeError(`Bad status type [${this.type}] passed to ak-status-label`);
} }
const [label, color, icon] = this.good const [label, color, icon] = match(this.good)
? [this.goodLabel, "pf-m-green", "fa-check"] .with(P.nullish, () => [this.neutralLabel, "pf-m-gray", "fa-question"] as const)
: [this.badLabel, ...details]; .with(true, () => [this.goodLabel, "pf-m-green", "fa-check"] as const)
.with(false, () => [this.badLabel, ...details] as const)
.exhaustive();
const classes = { const classes = {
"pf-c-label": true, "pf-c-label": true,

View File

@@ -60,8 +60,6 @@ export class AkSwitchInput extends AKElement {
} }
render() { render() {
const doCheck = this.checked ? this.checked : undefined;
return html`<ak-form-element-horizontal name=${this.name} ?required=${this.required}> return html`<ak-form-element-horizontal name=${this.name} ?required=${this.required}>
<label class="pf-c-switch" for="${this.#fieldID}"> <label class="pf-c-switch" for="${this.#fieldID}">
<input <input
@@ -69,7 +67,7 @@ export class AkSwitchInput extends AKElement {
aria-describedby="${this.#fieldID}-help" aria-describedby="${this.#fieldID}-help"
class="pf-c-switch__input" class="pf-c-switch__input"
type="checkbox" type="checkbox"
?checked=${doCheck} ?checked=${this.checked}
/> />
<span class="pf-c-switch__toggle"> <span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon"> <span class="pf-c-switch__toggle-icon">

View File

@@ -335,7 +335,7 @@ export abstract class WizardStep extends AKElement {
</aside> </aside>
<main <main
part="wizard-main" part="wizard-main"
class="pf-c-wizard__main ak-m-thin-scrollbar" class="pf-c-wizard__main ak-m-thin-scrollbar ak-m-scroll-shadows"
aria-label=${msg("Wizard content")} aria-label=${msg("Wizard content")}
> >
<div id="main-content" class="pf-c-wizard__main-body"> <div id="main-content" class="pf-c-wizard__main-body">

View File

@@ -20,10 +20,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-shrink: 0; flex-shrink: 0;
padding-top: var(--ak-c-dialog__header--PaddingTop); padding-block-start: var(--ak-c-dialog__header--PaddingBlockStart);
padding-bottom: var(--ak-c-dialog__header--PaddingBottom); padding-block-end: var(--ak-c-dialog__header--PaddingBlockEnd);
padding-right: var(--ak-c-dialog__header--PaddingRight); padding-inline-end: var(--ak-c-dialog__header--PaddingInlineEnd);
padding-left: var(--ak-c-dialog__header--PaddingLeft); padding-inline-start: var(--ak-c-dialog__header--PaddingInlineStart);
} }
.ak-c-dialog__header.pf-m-help { .ak-c-dialog__header.pf-m-help {
@@ -32,12 +32,7 @@
} }
.ak-c-dialog__header:last-child { .ak-c-dialog__header:last-child {
padding-bottom: var(--ak-c-dialog__header--last-child--PaddingBottom); padding-block-end: var(--ak-c-dialog__header--last-child--PaddingBlockEnd);
}
.ak-c-dialog__header + .ak-c-dialog__body,
.ak-c-dialog__header + slot + .ak-c-dialog__body {
--ak-c-dialog__body--PaddingTop: 0;
} }
.ak-c-dialog__header-main { .ak-c-dialog__header-main {
@@ -73,7 +68,7 @@
} }
.ak-c-dialog__description { .ak-c-dialog__description {
padding-top: var(--ak-c-dialog__description--PaddingTop); padding-block-start: var(--ak-c-dialog__description--PaddingBlockStart);
} }
/* #endregion */ /* #endregion */
@@ -82,9 +77,10 @@
.ak-c-dialog__body { .ak-c-dialog__body {
min-height: var(--ak-c-dialog__body--MinHeight); min-height: var(--ak-c-dialog__body--MinHeight);
padding-top: var(--ak-c-dialog__body--PaddingTop); padding-block-start: var(--ak-c-dialog__body--PaddingBlockStart);
padding-right: var(--ak-c-dialog__body--PaddingRight); padding-inline-end: var(--ak-c-dialog__body--PaddingInlineEnd);
padding-left: var(--ak-c-dialog__body--PaddingLeft); padding-inline-start: var(--ak-c-dialog__body--PaddingInlineStart);
padding-block-end: var(--ak-c-dialog__body--PaddingBlockEnd);
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
@@ -93,7 +89,7 @@
} }
.ak-c-dialog__body:last-child { .ak-c-dialog__body:last-child {
padding-bottom: var(--ak-c-dialog__body--last-child--PaddingBottom); padding-block-end: var(--ak-c-dialog__body--last-child--PaddingBlockEnd);
} }
.ak-c-dialog__footer { .ak-c-dialog__footer {
@@ -102,14 +98,11 @@
align-items: center; align-items: center;
justify-content: end; justify-content: end;
margin-block-start: var(--pf-global--spacer--md); padding-block-start: var(--ak-c-dialog__footer--PaddingBlockStart);
padding-inline-end: var(--ak-c-dialog__footer--PaddingInlineEnd);
padding-block-end: var(--ak-c-dialog__footer--PaddingBlockEnd);
padding-inline-start: var(--ak-c-dialog__footer--PaddingInlineStart);
padding-top: var(--ak-c-dialog__footer--PaddingTop);
padding-right: var(--ak-c-dialog__footer--PaddingRight);
padding-bottom: var(--ak-c-dialog__footer--PaddingBottom);
padding-left: var(--ak-c-dialog__footer--PaddingLeft);
box-shadow: var(--ak-c-dialog__footer--BoxShadow);
gap: var(--pf-global--spacer--xs); gap: var(--pf-global--spacer--xs);
@media screen and (min-width: 576px) { @media screen and (min-width: 576px) {

View File

@@ -312,9 +312,16 @@ export class AKModal extends AKElement implements TransclusionParentElement {
this.beforeBodySlot.name = "before-body"; this.beforeBodySlot.name = "before-body";
this.dialogBody = this.ownerDocument.createElement("div"); this.dialogBody = this.ownerDocument.createElement("div");
this.dialogBody.classList.add("ak-c-dialog__body", "ak-m-thin-scrollbar"); this.dialogBody.classList.add(
"ak-c-dialog__body",
"ak-m-thin-scrollbar",
"ak-m-scroll-shadows",
);
this.dialogBody.setAttribute("part", "body"); this.dialogBody.setAttribute("part", "body");
this.dialogBody.role = "region";
this.dialogBody.ariaLabel = msg("Dialog content");
this.dialogBody.appendChild(this.defaultSlot); this.dialogBody.appendChild(this.defaultSlot);
this.addEventListener("command", (event) => { this.addEventListener("command", (event) => {
@@ -379,6 +386,9 @@ export class AKModal extends AKElement implements TransclusionParentElement {
nextSlottedElement.visible = true; nextSlottedElement.visible = true;
} }
nextSlottedElement.classList.add("ak-c-dialog__slotted-content");
this.slottedElement?.classList.remove("ak-c-dialog__slotted-content");
this.slottedElement = nextSlottedElement; this.slottedElement = nextSlottedElement;
} }

View File

@@ -83,12 +83,12 @@
/* #region Header */ /* #region Header */
.ak-c-dialog { .ak-c-dialog {
--ak-c-dialog__header--PaddingTop: var(--pf-global--spacer--lg); --ak-c-dialog__header--PaddingBlockStart: var(--pf-global--spacer--lg);
--ak-c-dialog__header--PaddingBottom: var(--pf-global--spacer--lg); --ak-c-dialog__header--PaddingBlockEnd: var(--pf-global--spacer--lg);
--ak-c-dialog__header--PaddingRight: var(--pf-global--spacer--lg); --ak-c-dialog__header--PaddingInlineEnd: var(--pf-global--spacer--lg);
--ak-c-dialog__header--PaddingLeft: var(--pf-global--spacer--lg); --ak-c-dialog__header--PaddingInlineStart: var(--pf-global--spacer--lg);
--ak-c-dialog__header--last-child--PaddingBottom: var(--pf-global--spacer--lg); --ak-c-dialog__header--last-child--PaddingBlockEnd: var(--pf-global--spacer--lg);
--ak-c-dialog__header--body--PaddingTop: var(--pf-global--spacer--md); --ak-c-dialog__header--body--PaddingBlockStart: var(--pf-global--spacer--md);
--ak-c-dialog__title--LineHeight: var(--pf-global--LineHeight--sm); --ak-c-dialog__title--LineHeight: var(--pf-global--LineHeight--sm);
--ak-c-dialog__title--FontFamily: var(--pf-global--FontFamily--heading--sans-serif); --ak-c-dialog__title--FontFamily: var(--pf-global--FontFamily--heading--sans-serif);
@@ -96,7 +96,7 @@
--ak-c-dialog__title-icon--MarginRight: var(--pf-global--spacer--sm); --ak-c-dialog__title-icon--MarginRight: var(--pf-global--spacer--sm);
--ak-c-dialog__title-icon--Color: var(--pf-global--Color--100); --ak-c-dialog__title-icon--Color: var(--pf-global--Color--100);
--ak-c-dialog__description--PaddingTop: var(--pf-global--spacer--xs); --ak-c-dialog__description--PaddingBlockStart: var(--pf-global--spacer--xs);
} }
/* #endregion */ /* #endregion */
@@ -108,10 +108,11 @@
var(--pf-global--FontSize--md) * var(--pf-global--LineHeight--md) var(--pf-global--FontSize--md) * var(--pf-global--LineHeight--md)
); );
--ak-c-dialog__body--PaddingTop: var(--pf-global--spacer--lg); --ak-c-dialog__body--PaddingBlockStart: var(--pf-global--spacer--md);
--ak-c-dialog__body--PaddingRight: var(--pf-global--spacer--lg); --ak-c-dialog__body--PaddingBlockEnd: var(--pf-global--spacer--md);
--ak-c-dialog__body--PaddingLeft: var(--pf-global--spacer--lg); --ak-c-dialog__body--PaddingInlineEnd: var(--pf-global--spacer--lg);
--ak-c-dialog__body--last-child--PaddingBottom: var(--pf-global--spacer--lg); --ak-c-dialog__body--PaddingInlineStart: var(--pf-global--spacer--lg);
--ak-c-dialog__body--last-child--PaddingBlockEnd: var(--pf-global--spacer--lg);
} }
/* #endregion */ /* #endregion */
@@ -119,12 +120,10 @@
/* #region Footer */ /* #region Footer */
.ak-c-dialog { .ak-c-dialog {
--ak-c-dialog__footer--PaddingTop: var(--pf-global--spacer--lg); --ak-c-dialog__footer--PaddingBlockStart: var(--pf-global--spacer--lg);
--ak-c-dialog__footer--PaddingRight: var(--pf-global--spacer--lg); --ak-c-dialog__footer--PaddingInlineEnd: var(--pf-global--spacer--lg);
--ak-c-dialog__footer--PaddingBottom: var(--pf-global--spacer--lg); --ak-c-dialog__footer--PaddingBlockEnd: var(--pf-global--spacer--lg);
--ak-c-dialog__footer--PaddingLeft: var(--pf-global--spacer--lg); --ak-c-dialog__footer--PaddingInlineStart: var(--pf-global--spacer--lg);
--ak-c-dialog__footer--BoxShadow: inset 0 0.5px 0
var(--pf-global--BackgroundColor--dark-transparent-200);
} }
/* #endregion */ /* #endregion */
@@ -283,11 +282,14 @@
} }
.ak-c-dialog__content:has([part*="wizard"]) { .ak-c-dialog__content:has([part*="wizard"]) {
--ak-c-dialog__body--PaddingInlineEnd: 0;
--ak-c-dialog__body--PaddingInlineStart: 0;
--ak-c-dialog__body--last-child--PaddingBlockEnd: 0;
--ak-c-dialog__body--PaddingBlockStart: 0;
--ak-c-dialog__body--PaddingBlockEnd: 0;
--ak-m-scroll-shadows--BorderWidth: 0;
grid-template-rows: [body] auto; grid-template-rows: [body] auto;
--ak-c-dialog__body--PaddingRight: 0;
--ak-c-dialog__body--PaddingLeft: 0;
--ak-c-dialog__body--last-child--PaddingBottom: 0;
--ak-c-dialog__body--PaddingTop: 0;
::part(body) { ::part(body) {
overscroll-behavior: none; overscroll-behavior: none;

View File

@@ -7,6 +7,7 @@ import "#elements/dialogs/ak-modal";
import { AKRefreshEvent } from "#common/events"; import { AKRefreshEvent } from "#common/events";
import { DialogInit } from "#elements/dialogs/shared"; import { DialogInit } from "#elements/dialogs/shared";
import { RouteChangeEvent } from "#elements/router/events";
import { ifPresent } from "#elements/utils/attributes"; import { ifPresent } from "#elements/utils/attributes";
import { html, render } from "lit"; import { html, render } from "lit";
@@ -89,6 +90,8 @@ export function renderDialog(
onDispose, onDispose,
}: DialogInit = {}, }: DialogInit = {},
): Promise<void> { ): Promise<void> {
const eventAbortController = new AbortController();
const dialog = ownerDocument.createElement("dialog"); const dialog = ownerDocument.createElement("dialog");
dialog.classList.add("ak-c-dialog", ...classList); dialog.classList.add("ak-c-dialog", ...classList);
dialog.closedBy = closedBy; dialog.closedBy = closedBy;
@@ -121,8 +124,15 @@ export function renderDialog(
setDialogCountAttribute(-1, ownerDocument); setDialogCountAttribute(-1, ownerDocument);
onDispose?.(event); onDispose?.(event);
eventAbortController.abort();
}; };
window.addEventListener(RouteChangeEvent.eventName, dispose, {
passive: true,
once: true,
signal: eventAbortController.signal,
});
dialog.addEventListener("close", dispose, { dialog.addEventListener("close", dispose, {
passive: true, passive: true,
once: true, once: true,

View File

@@ -11,16 +11,16 @@ import { SlottedTemplateResult } from "#elements/types";
import { LogEvent, LogLevelEnum } from "@goauthentik/api"; import { LogEvent, LogLevelEnum } from "@goauthentik/api";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit"; import { CSSResult, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
@customElement("ak-log-viewer") @customElement("ak-log-viewer")
export class LogViewer extends StaticTable<LogEvent> { export class LogViewer extends StaticTable<LogEvent> {
expandable = true; public static styles: CSSResult[] = [...super.styles, PFDescriptionList];
static styles: CSSResult[] = [...super.styles, PFDescriptionList]; public override expandable = true;
protected override renderEmpty(): SlottedTemplateResult { protected override renderEmpty(): SlottedTemplateResult {
return super.renderEmpty( return super.renderEmpty(
@@ -28,7 +28,7 @@ export class LogViewer extends StaticTable<LogEvent> {
); );
} }
renderExpanded(item: LogEvent): TemplateResult { protected override renderExpanded(item: LogEvent): SlottedTemplateResult {
return html`<dl class="pf-c-description-list pf-m-horizontal"> return html`<dl class="pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
@@ -53,8 +53,8 @@ export class LogViewer extends StaticTable<LogEvent> {
</dl>`; </dl>`;
} }
renderToolbarContainer(): SlottedTemplateResult { protected override renderToolbarContainer(): SlottedTemplateResult {
return nothing; return null;
} }
protected columns: TableColumn[] = [ protected columns: TableColumn[] = [
@@ -64,7 +64,7 @@ export class LogViewer extends StaticTable<LogEvent> {
[msg("Logger")], [msg("Logger")],
]; ];
statusForItem(item: LogEvent): string { protected statusForItem(item: LogEvent): string {
switch (item.logLevel) { switch (item.logLevel) {
case LogLevelEnum.Critical: case LogLevelEnum.Critical:
case LogLevelEnum.Error: case LogLevelEnum.Error:
@@ -82,15 +82,15 @@ export class LogViewer extends StaticTable<LogEvent> {
return formatElapsedTime(item.timestamp); return formatElapsedTime(item.timestamp);
} }
row(item: LogEvent): SlottedTemplateResult[] { protected override row(item: LogEvent): SlottedTemplateResult[] {
return [ return [
html`<ak-timestamp .timestamp=${item.timestamp} refresh></ak-timestamp>`, html`<ak-timestamp .timestamp=${item.timestamp} refresh></ak-timestamp>`,
html`<ak-status-label html`<ak-status-label
type=${this.statusForItem(item)} type=${this.statusForItem(item)}
bad-label=${item.logLevel} bad-label=${item.logLevel}
></ak-status-label>`, ></ak-status-label>`,
html`${item.event}`, item.event,
html`${item.logger}`, item.logger,
]; ];
} }
} }

View File

@@ -467,10 +467,16 @@ export class Form<T = Record<string, unknown>, D = T>
const assignedElements = this.defaultSlot.assignedElements({ flatten: true }); const assignedElements = this.defaultSlot.assignedElements({ flatten: true });
const [firstAssignedElement] = assignedElements; const formFields = assignedElements.filter(isFormField);
if (assignedElements.length === 1 && isFormField(firstAssignedElement)) { if (formFields.length) {
return firstAssignedElement.toJSON() as D; if (formFields.length === 1) {
return formFields[0].toJSON() as D;
}
throw new TypeError(
`Multiple form-associated elements found in the form, but no "ak-form-element-horizontal" elements found. Unable to determine which element(s) to serialize.`,
);
} }
const namedElements = assignedElements.filter((element): element is AKElement => { const namedElements = assignedElements.filter((element): element is AKElement => {

View File

@@ -1,7 +1,13 @@
:host([theme="dark"]) { :host {
--ak-c-form-group__marker--Color: var(--pf-global--Color--300); --ak-c-form-group__marker--Color: var(--pf-global--Color--200);
--ak-c-form-group__marker--ColorHover: var(--pf-global--Color--200); --ak-c-form-group__marker--ColorHover: var(--pf-global--Color--100);
--ak-c-form-group__marker--Opacity: 75%;
--ak-c-form-group--marker--Content: "\f105";
--ak-c-form-group--marker--ContentOpen: "\f107";
}
:host([theme="dark"]) {
details { details {
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
background: var(--pf-global--BackgroundColor--150); background: var(--pf-global--BackgroundColor--150);
@@ -10,63 +16,79 @@
} }
details { details {
&[open] {
--ak-c-form-group--marker--Content: var(--ak-c-form-group--marker--ContentOpen);
--ak-c-form-group__marker--Opacity: 25%;
}
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
border: 1px solid var(--pf-global--BorderColor--200); border: 1px solid var(--pf-global--BorderColor--200);
background: var(--pf-global--BackgroundColor--150); background: var(--pf-global--BackgroundColor--150);
} }
}
&::details-content { details::details-content {
padding-inline-start: calc( padding-inline-start: calc(
var(--pf-global--spacer--lg) + var(--pf-global--spacer--form-element) var(--pf-global--spacer--lg) + var(--pf-global--spacer--form-element)
); );
padding-inline-end: var(--pf-global--spacer--md); padding-inline-end: var(--pf-global--spacer--md);
padding-block-end: var(--pf-global--spacer--sm); padding-block-end: var(--pf-global--spacer--sm);
} }
& > summary { details > summary:hover {
backdrop-filter: var(--ak-c-dialog__backdrop--BackdropFilter); --ak-c-form-group__marker--Color: var(--ak-c-form-group__marker--ColorHover);
}
inset-block-start: 0; details[open] > summary:hover {
position: sticky; --ak-c-form-group__marker--Opacity: 100%;
background: var(--ak-c-form-group--BackgroundColor, transparent); }
z-index: var(--ak-c-form-group--ZIndex, 1);
list-style-position: outside; details > summary {
margin-inline-start: var(--pf-global--spacer--sm); background: var(--ak-c-form-group--BackgroundColor, transparent);
padding-inline-start: calc(var(--pf-global--spacer--md) + 0.25rem);
padding-block: var(--pf-global--spacer-xs);
padding-inline: var(--pf-global--spacer--md);
list-style-type: "\f105";
cursor: pointer;
user-select: none;
font-weight: bold; list-style-position: outside;
margin-inline-start: var(--pf-global--spacer--sm);
padding-inline-start: calc(var(--pf-global--spacer--md) + 0.25rem);
padding-block: var(--pf-global--spacer-xs);
padding-inline: var(--pf-global--spacer--md);
list-style-type: var(--ak-c-form-group--marker--Content);
cursor: pointer;
user-select: none;
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
text-decoration: underline; text-decoration: underline;
margin-inline-start: var(--pf-global--spacer--lg); margin-inline-start: var(--pf-global--spacer--lg);
padding-block: var(--pf-global--spacer--sm); padding-block: var(--pf-global--spacer--sm);
}
&::marker {
color: var(--ak-c-form-group__marker--Color, var(--pf-global--Color--200));
transition: var(--pf-c-form__field-group-toggle-icon--Transition);
font-family: "Font Awesome 5 Free";
font-weight: 900;
}
&:hover::marker {
color: var(--ak-c-form-group__marker--ColorHover, var(--pf-global--Color--100));
}
}
&[open] summary {
list-style-type: "\f107";
} }
} }
details summary::marker {
content: var(--ak-c-form-group--marker--Content, "\f105");
color: color-mix(
var(--ak-c-form-group__marker--Color) var(--ak-c-form-group__marker--Opacity),
transparent
);
transition: var(--pf-c-form__field-group-toggle-icon--Transition);
font-family: "Font Awesome 5 Free";
font-weight: 900;
opacity: var(--ak-c-form-group__marker--Opacity);
}
[part="group-header-title"] {
flex: 1 1 auto;
}
[part="form-group-header-title"] {
flex: 0 0 auto;
display: flex;
justify-content: end;
align-items: center;
gap: var(--pf-global--spacer--sm);
backdrop-filter: var(--ak-c-dialog__backdrop--BackdropFilter);
}
[part="label"] { [part="label"] {
padding: var(--pf-global--spacer--xs); font-weight: 500;
} }
.pf-c-form__field-group-header-description { .pf-c-form__field-group-header-description {

View File

@@ -31,7 +31,7 @@ export class AKFormGroup extends AKElement {
public label = msg("Details"); public label = msg("Details");
@property({ type: String, reflect: true }) @property({ type: String, reflect: true })
public description?: string; public description: string | null = null;
//#endregion //#endregion
@@ -126,10 +126,14 @@ export class AKFormGroup extends AKElement {
aria-describedby="form-group-expandable-content-description" aria-describedby="form-group-expandable-content-description"
> >
<summary @click=${this.toggle}> <summary @click=${this.toggle}>
<div class="pf-c-form__field-group-header-main"> <div class="pf-c-form__field-group-header-main" part="group-header">
<header class="pf-c-form__field-group-header-title"> <header
class="pf-c-form__field-group-header-title"
part="group-header-title"
>
<div <div
class="pf-c-form__field-group-header-title-text" class="pf-c-form__field-group-header-title-text"
part="form-group-header-title"
id="form-group-header-title" id="form-group-header-title"
role="heading" role="heading"
aria-level="3" aria-level="3"
@@ -138,7 +142,6 @@ export class AKFormGroup extends AKElement {
<slot name="header"></slot> <slot name="header"></slot>
</div> </div>
</header> </header>
<div <div
class="pf-c-form__field-group-header-description" class="pf-c-form__field-group-header-description"
data-test-id="form-group-header-description" data-test-id="form-group-header-description"
@@ -149,9 +152,7 @@ export class AKFormGroup extends AKElement {
</div> </div>
</div> </div>
</summary> </summary>
<div id="form-group-expandable-content"> <slot></slot>
<slot></slot>
</div>
</details> </details>
`; `;
} }

View File

@@ -22,4 +22,5 @@
.pf-c-radio__description { .pf-c-radio__description {
text-wrap: balance; text-wrap: balance;
text-wrap: pretty;
} }

View File

@@ -4,6 +4,7 @@
} }
.pf-c-nav { .pf-c-nav {
--ak-m-scroll-shadows--BorderStyle: none;
--ak-c-nav__item--BorderColor: var(--pf-global--BorderColor--100); --ak-c-nav__item--BorderColor: var(--pf-global--BorderColor--100);
--pf-c-nav__subnav__link--hover--after--BorderColor: var(--ak-accent); --pf-c-nav__subnav__link--hover--after--BorderColor: var(--ak-accent);
@@ -25,7 +26,6 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow-y: hidden; overflow-y: hidden;
padding-block-start: var(--pf-global--spacer--sm);
} }
.pf-c-nav__section + .pf-c-nav__section { .pf-c-nav__section + .pf-c-nav__section {
@@ -35,6 +35,7 @@
.pf-c-nav__list { .pf-c-nav__list {
flex-grow: 1; flex-grow: 1;
overflow-y: auto; overflow-y: auto;
padding-block-start: var(--pf-global--spacer--sm);
} }
.pf-c-nav__link.pf-m-current::after, .pf-c-nav__link.pf-m-current::after,

View File

@@ -29,7 +29,7 @@ export class Sidebar extends AKElement {
?hidden=${this.hidden} ?hidden=${this.hidden}
aria-label=${msg("Global navigation")} aria-label=${msg("Global navigation")}
role="navigation" role="navigation"
class="pf-c-nav__list" class="pf-c-nav__list ak-m-thin-scrollbar ak-m-scroll-shadows"
part="list" part="list"
> >
<slot></slot> <slot></slot>

View File

@@ -4,31 +4,31 @@ import { PaginatedResponse, Table } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types"; import { SlottedTemplateResult } from "#elements/types";
import { PropertyValues } from "lit"; import { PropertyValues } from "lit";
import { html, nothing } from "lit-html";
import { property } from "lit/decorators.js"; import { property } from "lit/decorators.js";
export abstract class StaticTable<T extends object> extends Table<T> { export abstract class StaticTable<T extends object> extends Table<T> {
protected override searchEnabled = false; protected override searchEnabled = false;
@property({ attribute: false }) @property({ attribute: false })
items?: T[] = []; public items: T[] | null = [];
protected override async apiEndpoint(): Promise<PaginatedResponse<T, object>> { protected override async apiEndpoint(): Promise<PaginatedResponse<T, object>> {
return createPaginatedResponse(this.items ?? []); return createPaginatedResponse(this.items ?? []);
} }
protected override renderToolbar(): SlottedTemplateResult { protected override renderToolbar(): SlottedTemplateResult {
return html`${this.renderObjectCreate()}`; return this.renderObjectCreate();
} }
protected override renderTablePagination(): SlottedTemplateResult { protected override renderTablePagination(): SlottedTemplateResult {
return nothing; return null;
} }
protected override willUpdate(changedProperties: PropertyValues<this>): void { protected override willUpdate(changedProperties: PropertyValues<this>): void {
if (changedProperties.has("items")) { if (changedProperties.has("items")) {
this.fetch(); this.fetch();
} }
super.willUpdate(changedProperties); super.willUpdate(changedProperties);
} }
} }

View File

@@ -181,3 +181,67 @@
} }
/* #endregion */ /* #endregion */
.ak-m-scroll-shadows {
--ak-m-scroll-shadows--BorderWidth--light: 0.5px;
--ak-m-scroll-shadows--BorderWidth--dark: 1px;
--ak-m-scroll-shadows--Translucency: 75%;
--ak-m-scroll-shadows--BackgroundColor: var(--pf-global--BackgroundColor--100, white);
--ak-m-scroll-shadows--ShadowColor: var(--pf-global--Color--400);
--ak-m-scroll-shadows--ShadowColorMix: color-mix(
var(--ak-m-scroll-shadows--ShadowColor),
transparent var(--ak-m-scroll-shadows--Translucency)
);
--ak-m-scroll-shadows--ShadowLength: 2.75rem;
--ak-m-scroll-shadow--ShadowDepth: 1rem;
background-image:
linear-gradient(var(--ak-m-scroll-shadows--BackgroundColor) 30%, transparent),
linear-gradient(transparent, var(--ak-m-scroll-shadows--BackgroundColor) 70%),
radial-gradient(
farthest-side at 50% 0px,
var(--ak-m-scroll-shadows--ShadowColorMix),
transparent
),
radial-gradient(
farthest-side at 50% 100%,
var(--ak-m-scroll-shadows--ShadowColorMix),
transparent
);
background-position-x: center;
background-position-y: top, bottom;
background-repeat: no-repeat;
background-size:
100% var(--ak-m-scroll-shadows--ShadowLength),
100% var(--ak-m-scroll-shadows--ShadowLength),
100% var(--ak-m-scroll-shadow--ShadowDepth),
100% var(--ak-m-scroll-shadow--ShadowDepth);
background-attachment: local, local, scroll, scroll;
border-block-color: var(
--ak-m-scroll-shadows--BorderColor,
color-mix(
var(--ak-m-scroll-shadows--ShadowColor),
transparent var(--ak-m-scroll-shadows--Translucency)
)
);
border-block-style: var(--ak-m-scroll-shadows--BorderStyle, solid);
border-block-width: var(
--ak-m-scroll-shadows--BorderWidth,
var(--ak-m-scroll-shadows--BorderWidth--light)
);
}
html[data-theme="dark"],
:host([theme="dark"]) {
.ak-m-scroll-shadows {
--ak-m-scroll-shadow--ShadowDepth: 0;
border-block-width: var(
--ak-m-scroll-shadows--BorderWidth,
var(--ak-m-scroll-shadows--BorderWidth--dark)
);
}
}

View File

@@ -58,6 +58,7 @@
/* #region Form controls */ /* #region Form controls */
.pf-c-form-control { .pf-c-form-control {
--pf-c-form-control--BackgroundColor: transparent !important;
--pf-c-form-control--BorderTopColor: transparent !important; --pf-c-form-control--BorderTopColor: transparent !important;
--pf-c-form-control--BorderRightColor: transparent !important; --pf-c-form-control--BorderRightColor: transparent !important;
--pf-c-form-control--BorderLeftColor: transparent !important; --pf-c-form-control--BorderLeftColor: transparent !important;
@@ -174,7 +175,9 @@ fieldset {
} }
label.pf-c-radio { label.pf-c-radio {
--pf-c-radio--BorderColor: var(--pf-global--BorderColor--300); --pf-c-radio--checkmark--BorderColor: var(--pf-global--BorderColor--300);
--pf-c-radio--checkmark--Color: transparent;
--pf-c-radio--BorderColor: transparent;
--pf-c-radio--BoxShadowColor: transparent; --pf-c-radio--BoxShadowColor: transparent;
--pf-c-radio--checked--BackgroundColor: transparent; --pf-c-radio--checked--BackgroundColor: transparent;
@@ -182,21 +185,26 @@ label.pf-c-radio {
--pf-c-radio--hover--BorderColor: var(--pf-global--active-color--200); --pf-c-radio--hover--BorderColor: var(--pf-global--active-color--200);
--pf-c-radio--disabled--BackgroundColor: var(--pf-global--BackgroundColor--150); --pf-c-radio--disabled--BackgroundColor: var(--pf-global--BackgroundColor--150);
transition:
border-color 0.2s,
color 0.2s;
padding-inline: var(--pf-global--spacer--md); padding-inline: var(--pf-global--spacer--md);
padding-block: var(--pf-global--spacer--md); padding-block: var(--pf-global--spacer--sm);
cursor: pointer; cursor: pointer;
background-color: var(--pf-c-radio--BackgroundColor, transparent); background-color: var(--pf-c-radio--BackgroundColor, transparent);
border: 1px solid var(--pf-c-radio--BorderColor); border: 1px solid var(--pf-c-radio--BorderColor);
border-radius: var(--pf-global--BorderRadius--sm); border-radius: var(--pf-global--BorderRadius--sm);
position: relative; position: relative;
align-content: center;
display: grid; display: grid;
grid-template-areas: grid-template-areas:
"input ." "input ."
"input ."; "input .";
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
column-gap: var(--pf-global--spacer--md); column-gap: var(--pf-global--spacer--md);
row-gap: 0;
.pf-c-radio__input { .pf-c-radio__input {
grid-area: input; grid-area: input;
@@ -206,8 +214,17 @@ label.pf-c-radio {
margin-inline-start: var(--pf-global--spacer--sm); margin-inline-start: var(--pf-global--spacer--sm);
appearance: none; appearance: none;
content: none; content: none;
width: 1em;
height: 1em; width: 1.5em;
height: 1.5em;
padding: 0.25em;
border: 1px solid;
color: var(--pf-c-radio--checkmark--BorderColor, transparent);
border-radius: 3px;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
&::after { &::after {
font-family: "Font Awesome 5 Free"; font-family: "Font Awesome 5 Free";
@@ -216,7 +233,8 @@ label.pf-c-radio {
content: "\f00c"; content: "\f00c";
display: block; display: block;
line-height: 1; line-height: 1;
color: var(--pf-c-radio--checkmark--Color, transparent); color: var(--pf-c-radio--checkmark--Color);
transition: color 0.2s;
} }
} }
@@ -234,14 +252,15 @@ label.pf-c-radio {
inset-block-end: 0; inset-block-end: 0;
} }
&:has(.pf-c-radio__input:checked) { &:has(.pf-c-radio__input:checked:not(:disabled)) {
--pf-c-radio--BackgroundColor: var(--pf-c-radio--checked--BackgroundColor); --pf-c-radio--BackgroundColor: var(--pf-c-radio--checked--BackgroundColor);
--pf-c-radio--BorderColor: var(--pf-c-radio--checked--BorderColor); --pf-c-radio--BorderColor: var(--pf-c-radio--checked--BorderColor);
--pf-c-radio--checkmark--Color: var(--pf-global--active-color--300); --pf-c-radio--checkmark--BorderColor: var(--pf-global--active-color--300);
--pf-c-radio--checkmark--Color: var(--pf-c-radio--checked--BorderColor);
} }
&:hover { &:hover:not(:has(.pf-c-radio__input:checked)):not(:has(.pf-c-radio__input:disabled)) {
--pf-c-radio--BorderColor: var(--pf-c-radio--hover--BorderColor); --pf-c-radio--checkmark--BorderColor: var(--pf-c-radio--hover--BorderColor);
--pf-c-radio--checkmark--Color: var(--pf-c-radio--hover--BorderColor); --pf-c-radio--checkmark--Color: var(--pf-c-radio--hover--BorderColor);
} }
@@ -250,6 +269,18 @@ label.pf-c-radio {
--pf-c-radio--BackgroundColor: var(--pf-c-radio--disabled--BackgroundColor) !important; --pf-c-radio--BackgroundColor: var(--pf-c-radio--disabled--BackgroundColor) !important;
} }
.pf-c-radio__description {
margin-block-start: var(--pf-global--spacer--xs);
}
.pf-c-radio__label {
align-self: center;
}
&:has(.pf-c-radio__input:last-child) .pf-c-radio__label {
grid-row: 1 / -1;
}
} }
.pf-c-form__helper-radio:first-child { .pf-c-form__helper-radio:first-child {
@@ -257,44 +288,27 @@ label.pf-c-radio {
line-height: 1; line-height: 1;
} }
:has(ak-radio) {
&::part(form-group) {
grid-template-columns: [label] auto 1fr;
}
&::part(form-group) {
gap: 0;
}
&::part(group-control) {
grid-column: label / -label;
}
}
::part(radio-help) { ::part(radio-help) {
padding-block-start: var(--pf-global--spacer--form-element); padding-block-start: var(--pf-c-form--m-horizontal__group-label--md--PaddingTop);
font-size: var(--pf-c-form__label--FontSize); font-size: var(--pf-c-form__label--FontSize);
line-height: var(--pf-global--LineHeight--md); line-height: var(--pf-c-form__label--LineHeight);
font-style: italic;
color: var(--pf-c-form__group-label-help--Color);
text-align: end;
} }
/* #endregion */ /* #endregion */
ak-switch-input { ak-switch-input {
padding-inline: var(--pf-global--spacer--form-element); padding-inline: var(--pf-global--spacer--form-element);
::part(group-control) {
::part(form-group) { grid-area: control;
grid-template-columns: 1fr;
} }
.pf-c-switch { .pf-c-switch {
grid-template-columns: [label] 1fr [control] 1fr; grid-template-columns: [label] auto [control] auto;
grid-template-rows: [primary] auto [secondary] auto; grid-template-rows: [primary] auto [secondary] auto;
justify-content: space-between; justify-content: start;
padding-block: var(--pf-global--spacer--xs); padding-block: var(--pf-global--spacer--xs);
width: 100%; width: 100%;
column-gap: var(--pf-global--spacer--sm);
} }
.pf-c-switch__label { .pf-c-switch__label {
@@ -353,8 +367,6 @@ ak-switch-input {
--pf-global--link--Color: var(--pf-global--link--Color--light) !important; --pf-global--link--Color: var(--pf-global--link--Color--light) !important;
--pf-global--link--Color--hover: var(--pf-global--link--Color--light--hover) !important; --pf-global--link--Color--hover: var(--pf-global--link--Color--light--hover) !important;
--pf-c-form-control--BackgroundColor: var(--ak-dark-background-light) !important;
--pf-c-form-control--readonly--BackgroundColor: var(--ak-dark-background-light) !important; --pf-c-form-control--readonly--BackgroundColor: var(--ak-dark-background-light) !important;
--pf-c-form-control--disabled--BackgroundColor: var(--ak-dark-background-light) !important; --pf-c-form-control--disabled--BackgroundColor: var(--ak-dark-background-light) !important;
} }

View File

@@ -1,4 +1,5 @@
.pf-c-select { .pf-c-select {
--pf-c-select__toggle--BackgroundColor: transparent;
--pf-c-select__toggle--before--BorderTopColor: transparent; --pf-c-select__toggle--before--BorderTopColor: transparent;
--pf-c-select__toggle--before--BorderRightColor: transparent; --pf-c-select__toggle--before--BorderRightColor: transparent;
--pf-c-select__toggle--before--BorderLeftColor: transparent; --pf-c-select__toggle--before--BorderLeftColor: transparent;

View File

@@ -179,5 +179,123 @@ test.describe("Groups", () => {
}); });
}); });
test("Edit group from view page", async ({ navigator, form, pointer, page }, testInfo) => {
const groupName = groupNames.get(testInfo.testId)!;
const { fill, search } = form;
const { click } = pointer;
const newGroupDialog = page.getByRole("dialog", { name: "New Group" });
const editGroupDialog = page.getByRole("dialog", { name: "Edit Group" });
await test.step("Create group", async () => {
await click("New Group", "button");
await expect(newGroupDialog, "Dialog opens").toBeVisible();
await fill(/^Group Name/, groupName, newGroupDialog);
await newGroupDialog.getByRole("button", { name: "Create Group" }).click();
await expect(newGroupDialog, "Dialog closes after creating group").toBeHidden({
timeout: 10_000,
});
});
await test.step("Navigate to group view page", async () => {
const $group = await search(groupName);
await expect($group, "Group is visible").toBeVisible();
const viewLink = $group.getByRole("link", { name: "view details" });
await expect(viewLink, "View details link is visible").toBeVisible();
await viewLink.click();
});
const updatedName = `${groupName} Edited`;
await test.step("Edit group from view page", async () => {
await expect(editGroupDialog, "Edit dialog is initially closed").toBeHidden();
await click("Edit", "button");
await expect(editGroupDialog, "Edit dialog opens").toBeVisible();
const nameInput = editGroupDialog.getByRole("textbox", { name: /Group Name/ });
await expect(nameInput, "Name input is visible").toBeVisible();
await expect(nameInput, "Name is pre-filled").toHaveValue(groupName);
await nameInput.fill(updatedName);
await editGroupDialog.getByRole("button", { name: "Save Changes" }).click();
await expect(editGroupDialog, "Edit dialog closes after saving").toBeHidden();
});
await test.step("Verify group name updated on view page", async () => {
await expect(
page.getByRole("heading", { name: updatedName }).first(),
"Updated group name is visible on view page",
).toBeVisible();
});
});
test("Edit group from related group list", async ({
navigator,
form,
pointer,
page,
}, testInfo) => {
const groupName = groupNames.get(testInfo.testId)!;
const { fill, search } = form;
const { click } = pointer;
const newGroupDialog = page.getByRole("dialog", { name: "New Group" });
await test.step("Create group with admin user", async () => {
await click("New Group", "button");
await expect(newGroupDialog, "Dialog opens").toBeVisible();
await fill(/^Group Name/, groupName, newGroupDialog);
await newGroupDialog.getByRole("button", { name: "Create Group" }).click();
await expect(newGroupDialog, "Dialog closes").toBeHidden({ timeout: 10_000 });
});
await test.step("Navigate to admin user", async () => {
await navigator.navigate("/if/admin/#/identity/users");
const $adminUser = await search(adminUsername);
await expect($adminUser, "Admin user is visible").toBeVisible();
const viewLink = $adminUser.getByRole("link", {
name: "View details for authentik Default Admin",
});
await expect(viewLink, "View details link is visible").toBeVisible();
await viewLink.click();
});
await test.step("Add user to group via related group list", async () => {
await click("Groups", "tab");
const groupsPanel = page.getByRole("tabpanel", { name: "Groups" });
const addGroupDialog = page.getByRole("dialog", { name: "Add Group" });
await expect(addGroupDialog, "Add dialog is initially closed").toBeHidden();
await groupsPanel.getByRole("button", { name: "Add to existing group" }).click();
await expect(addGroupDialog, "Add dialog opens").toBeVisible();
});
});
//#endregion //#endregion
}); });

View File

@@ -106,5 +106,67 @@ test.describe("Roles", () => {
}); });
}); });
test("Edit role from view page", async ({ navigator, form, pointer, page }, testInfo) => {
const roleName = roleNames.get(testInfo.testId)!;
const { fill, search } = form;
const { click } = pointer;
const newRoleDialog = page.getByRole("dialog", { name: "New Role" });
const editRoleDialog = page.getByRole("dialog", { name: "Edit Role" });
await test.step("Create role", async () => {
await click("New Role", "button");
await expect(newRoleDialog, "Dialog opens").toBeVisible();
await fill(/^Role Name/, roleName, newRoleDialog);
await newRoleDialog.getByRole("button", { name: "Create Role" }).click();
await expect(newRoleDialog, "Dialog closes after creating role").toBeHidden();
});
await test.step("Navigate to role view page", async () => {
const $role = await search(roleName);
await expect($role, "Role is visible").toBeVisible();
const viewLink = $role.getByRole("link", { name: "view details" });
await expect(viewLink, "View details link is visible").toBeVisible();
const viewURL = await viewLink.evaluate((el: HTMLAnchorElement) => el.href);
await navigator.navigate(viewURL);
});
const updatedName = `${roleName} View Edited`;
await test.step("Edit role from view page", async () => {
await expect(editRoleDialog, "Edit dialog is initially closed").toBeHidden();
await click("Edit", "button");
await expect(editRoleDialog, "Edit dialog opens").toBeVisible();
const nameInput = editRoleDialog.getByRole("textbox", { name: /Role Name/ });
await expect(nameInput, "Name input is visible").toBeVisible();
await expect(nameInput, "Name is pre-filled").toHaveValue(roleName);
await nameInput.fill(updatedName);
await editRoleDialog.getByRole("button", { name: "Save Changes" }).click();
await expect(editRoleDialog, "Edit dialog closes after saving").toBeHidden();
});
await test.step("Verify role name updated on view page", async () => {
await expect(
page.getByText(updatedName),
"Updated role name is visible on view page",
).toBeVisible();
});
});
//#endregion //#endregion
}); });