web/admin: Allow binding users/groups in policy binding wizard and existing stage in stage binding wizard (#21697)

* web/admin: allow creating only binding for policies

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* dont show type selector if only one is allowed

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* do the same for stage wizard

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* minor unrelated fix: alignment in table desc

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add option to bind existing policy

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* adjust labels?

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Clean up post-type select state. Types.

* Clean up brand form.

* Flesh out parse.

* Tidy textarea.

* Fix table alignment when images are present.

* Simplify radio.

* Fix form group layout, styles.

* Flesh out plural helper.

* Flesh out formatted user display name.

* Allow slotted HTML in page description.

* Clean up transclusion types.

* Allow null.

* Flesh out user activation toggle.

* Clean up activation labeling.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
This commit is contained in:
Jens L.
2026-04-22 15:08:31 +01:00
committed by GitHub
parent 24edee3e78
commit 075a1f5875
38 changed files with 1407 additions and 889 deletions

View File

@@ -8,7 +8,7 @@ import { AKModal } from "#elements/dialogs/ak-modal";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithLicenseSummary } from "#elements/mixins/license";
import { SlottedTemplateResult } from "#elements/types";
import { ThemedImage } from "#elements/utils/images";
import { DefaultFlowBackground, ThemedImage } from "#elements/utils/images";
import {
AdminApi,
@@ -27,8 +27,6 @@ import { customElement, state } from "lit/decorators.js";
import PFAbout from "@patternfly/patternfly/components/AboutModalBox/about-modal-box.css";
const DEFAULT_BRAND_IMAGE = "/static/dist/assets/images/flow_background.jpg";
type AboutEntry = [label: string, content?: SlottedTemplateResult];
function renderEntry([label, content = null]: AboutEntry): SlottedTemplateResult {
@@ -191,7 +189,7 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(AKModal)) {
${ref(this.scrollContainerRef)}
class="pf-c-about-modal-box"
style=${styleMap({
"--pf-c-about-modal-box__hero--sm--BackgroundImage": `url(${DEFAULT_BRAND_IMAGE})`,
"--pf-c-about-modal-box__hero--sm--BackgroundImage": `url(${DefaultFlowBackground})`,
})}
part="box"
>

View File

@@ -1,8 +1,3 @@
/* Fix alignment issues with images in tables */
.pf-c-table tbody > tr > * {
vertical-align: middle;
}
tr td:first-child {
width: auto;
min-width: 0px;

View File

@@ -127,6 +127,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
return [
html`<ak-app-icon
aria-label=${msg(str`Application icon for "${item.name}"`)}
role="img"
name=${item.name}
icon=${ifPresent(item.metaIconUrl)}
.iconThemedUrls=${item.metaIconThemedUrls}

View File

@@ -14,6 +14,7 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { DefaultBrand } from "#common/ui/config";
import { ModelForm } from "#elements/forms/ModelForm";
import { DefaultFlowBackground } from "#elements/utils/images";
import { AKLabel } from "#components/ak-label";
@@ -65,7 +66,17 @@ export class BrandForm extends ModelForm<Brand, string> {
}
protected override renderForm(): TemplateResult {
return html` <ak-text-input
const {
brandingTitle = "",
brandingLogo = "",
brandingFavicon = "",
brandingCustomCss = "",
} = this.instance ?? DefaultBrand;
const defaultFlowBackground =
this.instance?.brandingDefaultFlowBackground ?? DefaultFlowBackground;
return html`<ak-text-input
required
name="domain"
input-hint="code"
@@ -75,6 +86,7 @@ export class BrandForm extends ModelForm<Brand, string> {
help=${msg(
"Matching is done based on domain suffix, so if you enter domain.tld, foo.domain.tld will still match.",
)}
?autofocus=${!this.instance}
></ak-text-input>
<ak-switch-input
@@ -91,7 +103,7 @@ export class BrandForm extends ModelForm<Brand, string> {
required
name="brandingTitle"
placeholder="authentik"
value="${this.instance?.brandingTitle ?? DefaultBrand.brandingTitle}"
value=${brandingTitle}
label=${msg("Title")}
autocomplete="off"
spellcheck="false"
@@ -102,7 +114,7 @@ export class BrandForm extends ModelForm<Brand, string> {
required
name="brandingLogo"
label=${msg("Logo")}
value="${this.instance?.brandingLogo ?? DefaultBrand.brandingLogo}"
value=${brandingLogo}
.usage=${UsageEnum.Media}
help=${msg("Logo shown in sidebar/header and flow executor.")}
></ak-file-search-input>
@@ -111,7 +123,7 @@ export class BrandForm extends ModelForm<Brand, string> {
required
name="brandingFavicon"
label=${msg("Favicon")}
value="${this.instance?.brandingFavicon ?? DefaultBrand.brandingFavicon}"
value=${brandingFavicon}
.usage=${UsageEnum.Media}
help=${msg("Icon shown in the browser tab.")}
></ak-file-search-input>
@@ -120,8 +132,7 @@ export class BrandForm extends ModelForm<Brand, string> {
required
name="brandingDefaultFlowBackground"
label=${msg("Default flow background")}
value="${this.instance?.brandingDefaultFlowBackground ??
"/static/dist/assets/images/flow_background.jpg"}"
value=${defaultFlowBackground}
.usage=${UsageEnum.Media}
help=${msg(
"Default background used during flow execution. Can be overridden per flow.",
@@ -141,8 +152,7 @@ export class BrandForm extends ModelForm<Brand, string> {
<ak-codemirror
id="branding-custom-css"
mode="css"
value="${this.instance?.brandingCustomCss ??
DefaultBrand.brandingCustomCss}"
value=${brandingCustomCss}
>
</ak-codemirror>
<p class="pf-c-form__helper-text">

View File

@@ -20,22 +20,24 @@ import { AKStageWizard } from "#admin/stages/ak-stage-wizard";
import { FlowsApi, FlowStageBinding, ModelEnum } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-bound-stages-list")
export class BoundStagesList extends Table<FlowStageBinding> {
expandable = true;
checkbox = true;
clearOnRefresh = true;
protected flowsAPI = new FlowsApi(DEFAULT_CONFIG);
order = "order";
public override expandable = true;
public override checkbox = true;
public override clearOnRefresh = true;
@property()
target?: string;
public override order = "order";
async apiEndpoint(): Promise<PaginatedResponse<FlowStageBinding>> {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsList({
@property({ type: String, useDefault: true })
public target: string | null = null;
protected override async apiEndpoint(): Promise<PaginatedResponse<FlowStageBinding>> {
return this.flowsAPI.flowsBindingsList({
...(await this.defaultEndpointConfig()),
target: this.target || "",
});
@@ -52,7 +54,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
[msg("Actions"), null, msg("Row Actions")],
];
renderToolbarSelected(): TemplateResult {
renderToolbarSelected(): SlottedTemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
object-label=${msg("Stage binding(s)")}
@@ -64,12 +66,12 @@ export class BoundStagesList extends Table<FlowStageBinding> {
];
}}
.usedBy=${(item: FlowStageBinding) => {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsUsedByList({
return this.flowsAPI.flowsBindingsUsedByList({
fsbUuid: item.pk,
});
}}
.delete=${(item: FlowStageBinding) => {
return new FlowsApi(DEFAULT_CONFIG).flowsBindingsDestroy({
return this.flowsAPI.flowsBindingsDestroy({
fsbUuid: item.pk,
});
}}
@@ -80,7 +82,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
</ak-forms-delete-bulk>`;
}
row(item: FlowStageBinding): SlottedTemplateResult[] {
protected override row(item: FlowStageBinding): SlottedTemplateResult[] {
return [
html`<pre>${item.order}</pre>`,
item.stageObj?.name,
@@ -115,30 +117,27 @@ export class BoundStagesList extends Table<FlowStageBinding> {
protected renderActions(): SlottedTemplateResult {
return html`<button
class="pf-c-button pf-m-primary"
${modalInvoker(AKStageWizard, {
showBindingPage: true,
bindingTarget: this.target,
})}
>
${msg("New Stage")}
</button>
<button
slot="trigger"
class="pf-c-button pf-m-primary"
${modalInvoker(StageBindingForm, { targetPk: this.target })}
>
${msg("Bind Existing Stage")}
</button>`;
class="pf-c-button pf-m-primary"
${modalInvoker(AKStageWizard, {
showBindingPage: true,
bindingTarget: this.target,
})}
>
${msg("Bind...")}
</button>`;
}
protected override renderExpanded(item: FlowStageBinding): TemplateResult {
protected override renderExpanded(item: FlowStageBinding): SlottedTemplateResult {
return html`<div class="pf-c-content">
<p>${msg("These bindings control if this stage will be applied to the flow.")}</p>
<ak-bound-policies-list
.target=${item.policybindingmodelPtrId}
.policyEngineMode=${item.policyEngineMode}
>
<span slot="description"
>${msg(
"These bindings control if this stage will be applied to the flow.",
)}</span
>
</ak-bound-policies-list>
</div>`;
}

View File

@@ -14,6 +14,7 @@ import "#elements/forms/ModalForm";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatDisambiguatedUserDisplayName } from "#common/users";
import { IconEditButton, renderModal } from "#elements/dialogs";
import { AKFormSubmitEvent, Form } from "#elements/forms/Form";
@@ -22,11 +23,11 @@ import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { PaginatedResponse, Table, TableColumn, Timestamp } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
import { UserOption } from "#elements/user/utils";
import { AKLabel } from "#components/ak-label";
import { RecoveryButtons } from "#admin/users/recovery";
import { ToggleUserActivationButton } from "#admin/users/UserActiveForm";
import { UserForm } from "#admin/users/UserForm";
import { UserImpersonateForm } from "#admin/users/UserImpersonateForm";
@@ -153,7 +154,7 @@ export class AddRelatedUserForm extends Form<{ users: number[] }> {
this.requestUpdate();
}}
>
${UserOption(user)}
${formatDisambiguatedUserDisplayName(user)}
</ak-chip>`;
})}</ak-chip-group
>
@@ -317,22 +318,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-user-active-form
.obj=${item}
object-label=${msg("User")}
.delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: item.pk || 0,
patchedUserRequest: {
isActive: !item.isActive,
},
});
}}
>
<button slot="trigger" class="pf-c-button pf-m-warning">
${item.isActive ? msg("Deactivate") : msg("Activate")}
</button>
</ak-user-active-form>
${ToggleUserActivationButton(item)}
</div>
</dd>
</div>

View File

@@ -184,7 +184,7 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
bindingTarget: this.target,
})}
>
${msg("Create and bind Policy")}
${msg("Bind...")}
</button>`;
}
@@ -223,44 +223,16 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
html`<ak-empty-state icon="pf-icon-module"
><span>${msg("No Policies bound.")}</span>
<div slot="body">${msg("No policies are currently bound to this object.")}</div>
<fieldset class="pf-c-form__group pf-m-action" slot="primary">
<div class="pf-c-form__group pf-m-action" slot="primary">
<legend class="sr-only">${msg("Policy actions")}</legend>
${this.renderNewPolicyButton()}
<button
type="button"
class="pf-c-button pf-m-secondary"
${modalInvoker(() => {
return StrictUnsafe<PolicyBindingForm>(this.bindingEditForm, {
allowedTypes: this.allowedTypes,
typeNotices: this.typeNotices,
targetPk: this.target || "",
});
})}
>
${msg("Bind existing policy/group/user")}
</button>
</fieldset>
</div>
</ak-empty-state>`,
);
}
renderToolbar(): SlottedTemplateResult {
return html`${this.allowedTypes.includes(PolicyBindingCheckTarget.Policy)
? this.renderNewPolicyButton()
: null}
<button
type="button"
class="pf-c-button pf-m-secondary"
${modalInvoker(() => {
return StrictUnsafe<PolicyBindingForm>(this.bindingEditForm, {
allowedTypes: this.allowedTypes,
typeNotices: this.typeNotices,
targetPk: this.target || "",
});
})}
>
${msg(str`Bind existing ${this.allowedTypesLabel}`)}
</button>`;
return this.renderNewPolicyButton();
}
renderPolicyEngineMode() {
@@ -270,10 +242,15 @@ export class BoundPoliciesList<T extends PolicyBinding = PolicyBinding> extends
if (policyEngineMode === undefined) {
return nothing;
}
return html`<p class="policy-desc">
${msg(str`The currently selected policy engine mode is ${policyEngineMode.label}:`)}
${policyEngineMode.description}
</p>`;
return html`${this.findSlotted("description")
? html`<p class="policy-desc">
<slot name="description"></slot>
</p>`
: nothing}
<p class="policy-desc">
${msg(str`The currently selected policy engine mode is ${policyEngineMode.label}:`)}
${policyEngineMode.description}
</p>`;
}
renderToolbarContainer(): SlottedTemplateResult {

View File

@@ -63,7 +63,7 @@ export class PolicyBindingForm<T extends PolicyBinding = PolicyBinding> extends
public targetPk = "";
@state()
protected policyGroupUser: PolicyBindingCheckTarget = PolicyBindingCheckTarget.Policy;
public policyGroupUser: PolicyBindingCheckTarget = PolicyBindingCheckTarget.Policy;
@property({ type: Array })
public allowedTypes: PolicyBindingCheckTarget[] = [
@@ -161,107 +161,109 @@ export class PolicyBindingForm<T extends PolicyBinding = PolicyBinding> extends
</ak-toggle-group>`;
}
protected renderTarget() {
return html`<ak-form-element-horizontal
label=${msg("Policy")}
name="policy"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.Policy}
>
<ak-search-select
.groupBy=${(items: Policy[]) => {
return groupBy(items, (policy) => policy.verboseNamePlural);
}}
.fetchObjects=${async (query?: string): Promise<Policy[]> => {
const args: PoliciesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const policies = await new PoliciesApi(DEFAULT_CONFIG).policiesAllList(
args,
);
return policies.results;
}}
.renderElement=${(policy: Policy) => policy.name}
.value=${(policy: Policy | null) => policy?.pk}
.selected=${(policy: Policy) => policy.pk === this.instance?.policy}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.Policy)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group")}
name="group"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.Group}
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
includeUsers: false,
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(args);
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | null) => String(group?.pk ?? "")}
.selected=${(group: Group) => group.pk === this.instance?.group}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.Group)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("User")}
name="user"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.User}
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
ordering: "username",
};
if (query !== undefined) {
args.search = query;
}
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args);
return users.results;
}}
.renderElement=${(user: User) => user.username}
.renderDescription=${(user: User) => html`${user.name}`}
.value=${(user: User | null) => user?.pk}
.selected=${(user: User) => user.pk === this.instance?.user}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.User)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>`;
}
protected override renderForm(): TemplateResult {
return html` <div class="pf-c-card pf-m-selectable pf-m-selected">
<div class="pf-c-card__body">${this.renderModeSelector()}</div>
<div class="pf-c-card__footer">
<ak-form-element-horizontal
label=${msg("Policy")}
name="policy"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.Policy}
>
<ak-search-select
.groupBy=${(items: Policy[]) => {
return groupBy(items, (policy) => policy.verboseNamePlural);
}}
.fetchObjects=${async (query?: string): Promise<Policy[]> => {
const args: PoliciesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const policies = await new PoliciesApi(
DEFAULT_CONFIG,
).policiesAllList(args);
return policies.results;
}}
.renderElement=${(policy: Policy) => policy.name}
.value=${(policy: Policy | null) => policy?.pk}
.selected=${(policy: Policy) => policy.pk === this.instance?.policy}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.Policy)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group")}
name="group"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.Group}
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {
const args: CoreGroupsListRequest = {
ordering: "name",
includeUsers: false,
};
if (query !== undefined) {
args.search = query;
}
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
args,
);
return groups.results;
}}
.renderElement=${(group: Group): string => {
return group.name;
}}
.value=${(group: Group | null) => String(group?.pk ?? "")}
.selected=${(group: Group) => group.pk === this.instance?.group}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.Group)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("User")}
name="user"
?hidden=${this.policyGroupUser !== PolicyBindingCheckTarget.User}
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
ordering: "username",
};
if (query !== undefined) {
args.search = query;
}
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(args);
return users.results;
}}
.renderElement=${(user: User) => user.username}
.renderDescription=${(user: User) => html`${user.name}`}
.value=${(user: User | null) => user?.pk}
.selected=${(user: User) => user.pk === this.instance?.user}
blankable
>
</ak-search-select>
${this.typeNotices
.filter(({ type }) => type === PolicyBindingCheckTarget.User)
.map((msg) => {
return html`<p class="pf-c-form__helper-text">${msg.notice}</p>`;
})}
</ak-form-element-horizontal>
</div>
</div>
return html`${this.allowedTypes.length > 1
? html`<div class="pf-c-card pf-m-selectable pf-m-selected">
<div class="pf-c-card__body">${this.renderModeSelector()}</div>
<div class="pf-c-card__footer">${this.renderTarget()}</div>
</div>`
: this.renderTarget()}
<ak-switch-input
name="enabled"
label=${msg("Enabled")}

View File

@@ -9,26 +9,36 @@ import "#admin/policies/unique_password/UniquePasswordPolicyForm";
import "#elements/wizard/FormWizardPage";
import "#elements/wizard/TypeCreateWizardPage";
import "#elements/wizard/Wizard";
import "#elements/forms/FormGroup";
import "#admin/policies/PolicyBindingForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PolicyBindingCheckTarget } from "#common/policies/utils";
import { RadioChangeEventDetail, RadioOption } from "#elements/forms/Radio";
import { SlottedTemplateResult } from "#elements/types";
import { CreateWizard } from "#elements/wizard/CreateWizard";
import { FormWizardPage } from "#elements/wizard/FormWizardPage";
import { TypeCreateWizardPageLayouts } from "#elements/wizard/TypeCreateWizardPage";
import { PolicyBindingForm } from "#admin/policies/PolicyBindingForm";
import { PoliciesApi, Policy, PolicyBinding, TypeCreate } from "@goauthentik/api";
import {
PoliciesApi,
Policy,
PolicyBinding,
PolicyBindingRequest,
TypeCreate,
} from "@goauthentik/api";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { html, PropertyValues } from "lit";
import { property } from "lit/decorators.js";
const initialStep = "initial";
@customElement("ak-policy-wizard")
export class PolicyWizard extends CreateWizard {
#api = new PoliciesApi(DEFAULT_CONFIG);
protected policiesAPI = new PoliciesApi(DEFAULT_CONFIG);
@property({ type: Boolean })
public showBindingPage = false;
@@ -36,6 +46,9 @@ export class PolicyWizard extends CreateWizard {
@property()
public bindingTarget: string | null = null;
public override groupLabel = msg("Bind New Policy");
public override groupDescription = msg("Select the type of policy you want to create.");
public override initialSteps = this.showBindingPage
? ["initial", "create-binding"]
: ["initial"];
@@ -45,11 +58,11 @@ export class PolicyWizard extends CreateWizard {
public override layout = TypeCreateWizardPageLayouts.list;
protected apiEndpoint = async (requestInit?: RequestInit): Promise<TypeCreate[]> => {
return this.#api.policiesAllTypesList(requestInit);
protected override apiEndpoint = async (requestInit?: RequestInit): Promise<TypeCreate[]> => {
return this.policiesAPI.policiesAllTypesList(requestInit);
};
protected updated(changedProperties: PropertyValues<this>): void {
protected override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("showBindingPage")) {
@@ -57,25 +70,81 @@ export class PolicyWizard extends CreateWizard {
}
}
protected createBindingActivate = async (page: FormWizardPage) => {
const createSlot = page.host.steps[1];
const bindingForm = page.querySelector<PolicyBindingForm>("ak-policy-binding-form");
protected createBindingActivate = async (
page: FormWizardPage<{ "initial": PolicyBindingCheckTarget; "create-binding": Policy }>,
) => {
const createSlot = page.host.steps[1] as "create-binding";
const bindingForm = page.querySelector("ak-policy-binding-form");
if (!bindingForm) return;
bindingForm.instance = {
policy: (page.host.state[createSlot] as Policy).pk,
} as PolicyBinding;
if (page.host.state[createSlot]) {
bindingForm.allowedTypes = [PolicyBindingCheckTarget.Policy];
bindingForm.policyGroupUser = PolicyBindingCheckTarget.Policy;
const policyBindingRequest: Partial<PolicyBindingRequest> = {
policy: (page.host.state[createSlot] as Policy).pk,
};
bindingForm.instance = policyBindingRequest as unknown as PolicyBinding;
}
if (page.host.state[initialStep]) {
bindingForm.allowedTypes = [page.host.state[initialStep]];
bindingForm.policyGroupUser = page.host.state[initialStep];
}
};
protected override renderCreateBefore(): SlottedTemplateResult {
if (!this.showBindingPage) {
return null;
}
return html`<ak-form-group
slot="pre-items"
label=${msg("Bind Existing...")}
description=${msg(
"Select a type to bind an existing object instead of creating a new one.",
)}
open
>
<ak-radio
.options=${[
{
label: msg("Bind a user"),
description: html`${msg("Statically bind an existing user.")}`,
value: PolicyBindingCheckTarget.User,
},
{
label: msg("Bind a group"),
description: html`${msg("Statically bind an existing group.")}`,
value: PolicyBindingCheckTarget.Group,
},
{
label: msg("Bind an existing policy"),
description: html`${msg("Bind an existing policy.")}`,
value: PolicyBindingCheckTarget.Policy,
},
] satisfies RadioOption<PolicyBindingCheckTarget>[]}
@change=${(ev: CustomEvent<RadioChangeEventDetail<PolicyBindingCheckTarget>>) => {
if (!this.wizard) {
return;
}
this.wizard.state[initialStep] = ev.detail.value;
this.wizard.navigateNext();
}}
>
</ak-radio>
</ak-form-group>`;
}
protected renderForms(): SlottedTemplateResult {
const bindingPage = this.showBindingPage
? html`<ak-wizard-page-form
slot="create-binding"
headline=${msg("Create Binding")}
.activePageCallback=${this.createBindingActivate}
>
<ak-policy-binding-form .targetPk=${this.bindingTarget}></ak-policy-binding-form>
><ak-policy-binding-form .targetPk=${this.bindingTarget}></ak-policy-binding-form>
</ak-wizard-page-form>`
: null;

View File

@@ -3,16 +3,17 @@ import "#elements/LicenseNotice";
import "#elements/wizard/FormWizardPage";
import "#elements/wizard/TypeCreateWizardPage";
import "#elements/wizard/Wizard";
import "#elements/forms/FormGroup";
import "#admin/flows/StageBindingForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { RadioOption } from "#elements/forms/Radio";
import { SlottedTemplateResult } from "#elements/types";
import { CreateWizard } from "#elements/wizard/CreateWizard";
import { FormWizardPage } from "#elements/wizard/FormWizardPage";
import { TypeCreateWizardPageLayouts } from "#elements/wizard/TypeCreateWizardPage";
import { StageBindingForm } from "#admin/flows/StageBindingForm";
import { FlowStageBinding, Stage, StagesApi, TypeCreate } from "@goauthentik/api";
import { msg } from "@lit/localize";
@@ -27,8 +28,8 @@ export class AKStageWizard extends CreateWizard {
@property({ type: Boolean })
public showBindingPage = false;
@property()
public bindingTarget?: string;
@property({ type: String, useDefault: true })
public bindingTarget: string | null = null;
public override initialSteps = this.showBindingPage
? ["initial", "create-binding"]
@@ -39,11 +40,14 @@ export class AKStageWizard extends CreateWizard {
public override layout = TypeCreateWizardPageLayouts.list;
public override groupLabel = msg("Bind New Stage");
public override groupDescription = msg("Select the type of stage you want to create.");
protected apiEndpoint = async (requestInit?: RequestInit): Promise<TypeCreate[]> => {
return this.#api.stagesAllTypesList(requestInit);
};
protected updated(changedProperties: PropertyValues<this>): void {
protected override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
if (changedProperties.has("showBindingPage")) {
@@ -51,17 +55,52 @@ export class AKStageWizard extends CreateWizard {
}
}
protected createBindingActivate = async (context: FormWizardPage) => {
const createSlot = context.host.steps[1];
const bindingForm = context.querySelector<StageBindingForm>("ak-stage-binding-form");
protected createBindingActivate = async (
context: FormWizardPage<{ "create-binding": Stage }>,
) => {
const createSlot = context.host.steps[1] as "create-binding";
const bindingForm = context.querySelector("ak-stage-binding-form");
if (!bindingForm) return;
bindingForm.instance = {
stage: (context.host.state[createSlot] as Stage).pk,
} as FlowStageBinding;
if (context.host.state[createSlot]) {
bindingForm.instance = {
stage: (context.host.state[createSlot] as Stage).pk,
} as FlowStageBinding;
}
};
protected override renderCreateBefore(): SlottedTemplateResult {
if (!this.showBindingPage) {
return null;
}
return html`<ak-form-group
slot="pre-items"
label=${msg("Existing Stage")}
description=${msg("Bind an existing stage to this flow.")}
open
>
<ak-radio
.options=${[
{
label: "Bind existing stage",
description: msg("Bind an existing stage to this flow."),
value: true,
},
] satisfies RadioOption<boolean>[]}
@change=${() => {
if (!this.wizard) {
return;
}
this.wizard.navigateNext();
}}
>
</ak-radio>
</ak-form-group>`;
}
protected renderForms(): SlottedTemplateResult {
const bindingPage = this.showBindingPage
? html`<ak-wizard-page-form

View File

@@ -1,73 +1,145 @@
import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/FormGroup";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatDisambiguatedUserDisplayName } from "#common/users";
import { showMessage } from "#elements/messages/MessageContainer";
import { UserDeleteForm } from "#elements/user/utils";
import { RawContent } from "#elements/ak-table/ak-simple-table";
import { modalInvoker } from "#elements/dialogs";
import { pluckEntityName } from "#elements/entities/names";
import { DestructiveModelForm } from "#elements/forms/DestructiveModelForm";
import { WithLocale } from "#elements/mixins/locale";
import { SlottedTemplateResult } from "#elements/types";
import { msg, str } from "@lit/localize";
import { html, TemplateResult } from "lit";
import { CoreApi, UsedBy, User } from "@goauthentik/api";
import { str } from "@lit/localize";
import { msg } from "@lit/localize/init/install";
import { html } from "lit-html";
import { customElement } from "lit/decorators.js";
@customElement("ak-user-active-form")
export class UserActiveForm extends UserDeleteForm {
onSuccess(): void {
showMessage({
message: msg(
str`Successfully updated ${this.objectLabel} ${this.getObjectDisplayName()}`,
),
level: MessageLevel.success,
/**
* A form for activating/deactivating a user.
*/
@customElement("ak-user-activation-toggle-form")
export class UserActivationToggleForm extends WithLocale(DestructiveModelForm<User>) {
public static override verboseName = msg("User");
public static override verboseNamePlural = msg("Users");
protected coreAPI = new CoreApi(DEFAULT_CONFIG);
protected override send(): Promise<unknown> {
if (!this.instance) {
return Promise.reject(new Error("No user instance provided"));
}
const nextActiveState = !this.instance.isActive;
return this.coreAPI.coreUsersPartialUpdate({
id: this.instance.pk,
patchedUserRequest: {
isActive: nextActiveState,
},
});
}
onError(error: unknown): Promise<void> {
return parseAPIResponseError(error).then((parsedError) => {
showMessage({
message: msg(
str`Failed to update ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
),
level: MessageLevel.error,
});
});
public override formatSubmitLabel(): string {
return super.formatSubmitLabel(
this.instance?.isActive ? msg("Deactivate") : msg("Activate"),
);
}
override renderModalInner(): TemplateResult {
const objName = this.getFormattedObjectName();
return html`<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">${msg(str`Update ${this.objectLabel}`)}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">
<form class="pf-c-form pf-m-horizontal">
<p>
${msg(str`Are you sure you want to update ${this.objectLabel}${objName}?`)}
</p>
</form>
</section>
<fieldset class="pf-c-modal-box__footer">
<legend class="sr-only">${msg("Form actions")}</legend>
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-secondary"
>${msg("Cancel")}</ak-spinner-button
>
<ak-spinner-button
.callAction=${() => {
return this.confirm();
}}
class="pf-m-warning"
>${msg("Save Changes")}</ak-spinner-button
>
</fieldset>`;
public override formatSubmittingLabel(): string {
return super.formatSubmittingLabel(
this.instance?.isActive ? msg("Deactivating...") : msg("Activating..."),
);
}
protected override formatDisplayName(): string {
if (!this.instance) {
return msg("Unknown user");
}
return formatDisambiguatedUserDisplayName(this.instance, this.activeLanguageTag);
}
protected override formatHeadline(): string {
return this.instance?.isActive
? msg(str`Review ${this.verboseName} Deactivation`, {
id: "form.headline.deactivation",
})
: msg(str`Review ${this.verboseName} Activation`, { id: "form.headline.activation" });
}
public override usedBy = (): Promise<UsedBy[]> => {
if (!this.instance) {
return Promise.resolve([]);
}
return this.coreAPI.coreUsersUsedByList({ id: this.instance.pk });
};
protected override renderUsedBySection(): SlottedTemplateResult {
if (this.instance?.isActive) {
return super.renderUsedBySection();
}
const displayName = this.formatDisplayName();
const { usedByList, verboseName } = this;
return html`<ak-form-group
open
label=${msg("Objects associated with this user", {
id: "usedBy.associated-objects.label",
})}
>
<div
class="pf-m-monospace"
aria-description=${msg(
str`List of objects that are associated with this ${verboseName}.`,
{
id: "usedBy.description",
},
)}
slot="description"
>
${displayName}
</div>
<ak-simple-table
.columns=${[msg("Object Name"), msg("ID")]}
.content=${usedByList.map((ub): RawContent[] => {
return [pluckEntityName(ub) || msg("Unnamed"), html`<code>${ub.pk}</code>`];
})}
></ak-simple-table>
</ak-form-group>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-user-active-form": UserActiveForm;
"ak-user-activation-toggle-form": UserActivationToggleForm;
}
}
export interface ToggleUserActivationButtonProps {
className?: string;
}
export function ToggleUserActivationButton(
user: User,
{ className = "" }: ToggleUserActivationButtonProps = {},
): SlottedTemplateResult {
const label = user.isActive ? msg("Deactivate") : msg("Activate");
const tooltip = user.isActive
? msg("Lock the user out of this system")
: msg("Allow the user to log in and use this system");
return html`<button
class="pf-c-button pf-m-warning ${className}"
type="button"
${modalInvoker(UserActivationToggleForm, {
instance: user,
})}
>
<pf-tooltip position="top" content=${tooltip}>${label}</pf-tooltip>
</button>`;
}

View File

@@ -30,6 +30,7 @@ import { SlottedTemplateResult } from "#elements/types";
import { AKUserWizard } from "#admin/users/ak-user-wizard";
import { RecoveryButtons } from "#admin/users/recovery";
import { ToggleUserActivationButton } from "#admin/users/UserActiveForm";
import { UserForm } from "#admin/users/UserForm";
import { UserImpersonateForm } from "#admin/users/UserImpersonateForm";
@@ -69,7 +70,7 @@ export class UserListPage extends WithBrandConfig(
.pf-c-avatar {
max-height: var(--pf-c-avatar--Height);
max-width: var(--pf-c-avatar--Width);
margin-bottom: calc(var(--pf-c-avatar--Width) * -0.6);
vertical-align: middle;
}
`,
];
@@ -309,22 +310,7 @@ export class UserListPage extends WithBrandConfig(
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-user-active-form
object-label=${msg("User")}
.obj=${item}
.delete=${() => {
return this.#api.coreUsersPartialUpdate({
id: item.pk,
patchedUserRequest: {
isActive: !item.isActive,
},
});
}}
>
<button slot="trigger" class="pf-c-button pf-m-warning">
${item.isActive ? msg("Deactivate") : msg("Activate")}
</button>
</ak-user-active-form>
${ToggleUserActivationButton(item)}
</div>
</dd>
</div>

View File

@@ -28,27 +28,34 @@ import "./UserDevicesTable.js";
import "#elements/ak-mdx/ak-mdx";
import { DEFAULT_CONFIG } from "#common/api/config";
import { AKRefreshEvent } from "#common/events";
import { userTypeToLabel } from "#common/labels";
import { formatUserDisplayName } from "#common/users";
import { formatDisambiguatedUserDisplayName, formatUserDisplayName } from "#common/users";
import { AKElement } from "#elements/Base";
import { listen } from "#elements/decorators/listen";
import { showAPIErrorMessage } from "#elements/messages/MessageContainer";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
import { WithLicenseSummary } from "#elements/mixins/license";
import { WithLocale } from "#elements/mixins/locale";
import { WithSession } from "#elements/mixins/session";
import { Timestamp } from "#elements/table/shared";
import { SlottedTemplateResult } from "#elements/types";
import { setPageDetails } from "#components/ak-page-navbar";
import { type DescriptionPair, renderDescriptionList } from "#components/DescriptionList";
import { RecoveryButtons } from "#admin/users/recovery";
import { ToggleUserActivationButton } from "#admin/users/UserActiveForm";
import { UserForm } from "#admin/users/UserForm";
import { UserImpersonateForm } from "#admin/users/UserImpersonateForm";
import { CapabilitiesEnum, CoreApi, ModelEnum, User } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { css, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { css, html, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
@@ -62,20 +69,16 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import PFSizing from "@patternfly/patternfly/utilities/Sizing/sizing.css";
@customElement("ak-user-view")
export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSession(AKElement))) {
@property({ type: Number })
set userId(id: number) {
new CoreApi(DEFAULT_CONFIG)
.coreUsersRetrieve({
id: id,
})
.then((user) => {
this.user = user;
});
}
export class UserViewPage extends WithLicenseSummary(
WithLocale(WithBrandConfig(WithCapabilitiesConfig(WithSession(AKElement)))),
) {
#api = new CoreApi(DEFAULT_CONFIG);
@state()
protected user: User | null = null;
@property({ type: Number, useDefault: true })
public userId: number | null = null;
@property({ attribute: false, useDefault: true })
public user: User | null = null;
static styles = [
PFPage,
@@ -103,26 +106,64 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
`,
];
renderUserCard() {
@listen(AKRefreshEvent)
public refresh = () => {
if (!this.userId) {
return;
}
return this.#api
.coreUsersRetrieve({
id: this.userId!,
})
.then((user) => {
this.user = user;
})
.catch(showAPIErrorMessage);
};
protected override updated(changed: PropertyValues<this>) {
super.updated(changed);
if (changed.has("userId") && this.userId !== null) {
this.refresh();
}
if (changed.has("user") && this.user) {
const { username, avatar, name, email } = this.user;
const icon = avatar ?? "pf-icon pf-icon-user";
setPageDetails({
icon,
iconImage: !!avatar,
header: username ? msg(str`User ${username}`) : msg("User"),
description: this.user
? formatDisambiguatedUserDisplayName({ name, email }, this.activeLanguageTag)
: null,
});
}
}
protected renderUserCard() {
if (!this.user) {
return nothing;
return null;
}
const user = this.user;
// prettier-ignore
const userInfo: DescriptionPair[] = [
[msg("Username"), user.username],
[msg("Name"), user.name],
[msg("Email"), user.email || "-"],
[msg("Last login"), Timestamp(user.lastLogin)],
[msg("Last password change"), Timestamp(user.passwordChangeDate)],
[msg("Active"), html`<ak-status-label ?good=${user.isActive}></ak-status-label>`],
[msg("Type"), userTypeToLabel(user.type)],
[msg("Superuser"), html`<ak-status-label type="warning" ?good=${user.isSuperuser}></ak-status-label>`],
[msg("Actions"), this.renderActionButtons(user)],
[msg("Recovery"), this.renderRecoveryButtons(user)],
];
[ msg("Username"), user.username ],
[ msg("Name"), user.name ],
[ msg("Email"), user.email || "-" ],
[ msg("Last login"), Timestamp(user.lastLogin) ],
[ msg("Last password change"), Timestamp(user.passwordChangeDate) ],
[ msg("Active"), html`<ak-status-label ?good=${user.isActive}></ak-status-label>` ],
[ msg("Type"), userTypeToLabel(user.type) ],
[ msg("Superuser"), html`<ak-status-label type="warning" ?good=${user.isSuperuser}></ak-status-label>` ],
[ msg("Actions"), this.renderActionButtons(user) ],
[ msg("Recovery"), this.renderRecoveryButtons(user) ],
]
return html`
<div class="pf-c-card__title">${msg("User Info")}</div>
@@ -132,7 +173,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
`;
}
renderActionButtons(user: User) {
protected renderActionButtons(user: User): SlottedTemplateResult {
const showImpersonate =
this.can(CapabilitiesEnum.CanImpersonate) && user.pk !== this.currentUser?.pk;
@@ -145,29 +186,8 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
>
${msg("Edit User")}
</button>
<ak-user-active-form
.obj=${user}
object-label=${msg("User")}
.delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: user.pk,
patchedUserRequest: {
isActive: !user.isActive,
},
});
}}
>
<button slot="trigger" class="pf-c-button pf-m-warning pf-m-block">
<pf-tooltip
position="top"
content=${user.isActive
? msg("Lock the user out of this system")
: msg("Allow the user to log in and use this system")}
>
${user.isActive ? msg("Deactivate") : msg("Activate")}
</pf-tooltip>
</button>
</ak-user-active-form>
${ToggleUserActivationButton(user, { className: "pf-m-block" })}
${showImpersonate
? html`<button
class="pf-c-button pf-m-tertiary pf-m-block"
@@ -185,7 +205,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
</div> `;
}
renderRecoveryButtons(user: User) {
protected renderRecoveryButtons(user: User) {
return html`<div class="ak-button-collection">
${RecoveryButtons({
user,
@@ -195,7 +215,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
</div>`;
}
renderTabCredentialsToken(user: User): TemplateResult {
protected renderTabCredentialsToken(user: User): TemplateResult {
return html`
<ak-tabs pageIdentifier="userCredentialsTokens" vertical>
<div
@@ -308,7 +328,7 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
`;
}
renderTabApplications(user: User): TemplateResult {
protected renderTabApplications(user: User): TemplateResult {
return html`<div class="pf-c-card">
<ak-user-application-table .user=${user}></ak-user-application-table>
</div>`;
@@ -348,10 +368,11 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
`;
}
render() {
protected override render() {
if (!this.user) {
return nothing;
return null;
}
return html`<main>
<ak-tabs>
<div
@@ -476,16 +497,6 @@ export class UserViewPage extends WithBrandConfig(WithCapabilitiesConfig(WithSes
</ak-tabs>
</main>`;
}
updated(changed: PropertyValues<this>) {
super.updated(changed);
setPageDetails({
icon: this.user?.avatar ?? "pf-icon pf-icon-user",
iconImage: !!this.user?.avatar,
header: this.user?.username ? msg(str`User ${this.user.username}`) : msg("User"),
description: this.user?.name || "",
});
}
}
declare global {

View File

@@ -0,0 +1,68 @@
/**
* Defines the plural forms for a given locale, and provides a function to select the appropriate form based on a count.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/PluralRules MDN} for more information on plural categories and rules.
*/
export interface PluralForms {
/**
* The "other" form is required as a fallback for categories that may not be provided.
* For example, if only "one" and "other" are provided,
* then "other" will be used for all counts that don't fall into the "one" category.
*/
other: () => string;
/**
* Used for counts that fall into the "one" category for the given locale.
*/
one?: () => string;
/**
* Used for counts that fall into the "two" category for the given locale.
*/
two?: () => string;
/**
* Used for counts that fall into the "few" category for the given locale.
*/
few?: () => string;
/**
* Used for counts that fall into the "many" category for the given locale.
*/
many?: () => string;
/**
* Used for counts that fall into the "zero" category for the given locale.
*/
zero?: () => string;
}
/**
* Cache of {@linkcode Intl.PluralRules} instances, keyed by locale argument. The empty string key is used for the default locale.
*/
const PluralRulesCache = new Map<Intl.LocalesArgument, Intl.PluralRules>();
/**
* Get an {@linkcode Intl.PluralRules} instance for the given locale, using a cache to avoid unnecessary allocations.
*
* @param locale The locale to get plural rules for, or undefined to use the default locale.
* @returns An {@linkcode Intl.PluralRules} instance for the given locale.
*/
function getPluralRules(locale?: Intl.LocalesArgument): Intl.PluralRules {
const key = locale ?? "";
let pr = PluralRulesCache.get(key);
if (!pr) {
pr = new Intl.PluralRules(locale);
PluralRulesCache.set(key, pr);
}
return pr;
}
/**
* Get the appropriate plural form for a given count and set of forms.
*
* @param count The count to get the plural form for.
* @param forms The forms to use for each plural category.
* @param locale The locale to use for determining the plural category, or undefined to use the default locale.
*/
export function plural(count: number, forms: PluralForms, locale?: Intl.LocalesArgument): string {
const category = getPluralRules(locale).select(count);
return (forms[category] ?? forms.other)();
}

View File

@@ -6,12 +6,14 @@ import { CoreApi, SessionUser, UserSelf } from "@goauthentik/api";
import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
export interface ClientSessionPermissions {
editApplications: boolean;
accessAdmin: boolean;
}
export type UserLike = Pick<UserSelf, "username" | "name" | "email">;
export type UserLike = Partial<Pick<UserSelf, "username" | "name" | "email">>;
/**
* The display name of the current user, according to their UI config settings.
@@ -29,6 +31,72 @@ export function formatUserDisplayName(user: UserLike | null, uiConfig?: UIConfig
return label || "";
}
const formatUnknownUserLabel = () =>
msg("Unknown user", {
id: "user.display.unknownUser",
desc: "Placeholder for an unknown user, in the format 'Unknown user'.",
});
/**
* Format a user's display name with disambiguation, such as when multiple users have the same name appearing in a list.
*/
export function formatDisambiguatedUserDisplayName(
user?: UserLike | null,
formatter?: Intl.ListFormat,
): string;
export function formatDisambiguatedUserDisplayName(
user?: UserLike | null,
locale?: Intl.LocalesArgument,
): string;
export function formatDisambiguatedUserDisplayName(
user?: UserLike | null,
localeOrFormatter?: Intl.ListFormat | Intl.LocalesArgument,
): string {
if (!user) {
return formatUnknownUserLabel();
}
const formatter =
localeOrFormatter instanceof Intl.ListFormat
? localeOrFormatter
: new Intl.ListFormat(localeOrFormatter, { style: "narrow", type: "unit" });
const { username, name, email } = user;
const segments: string[] = [];
if (username) {
segments.push(username);
}
if (name && name !== username) {
if (segments.length === 0) {
segments.push(name);
} else {
segments.push(
msg(str`(${name})`, {
id: "user.display.nameInParens",
desc: "The user's name in parentheses, used when the name is different from the username",
}),
);
}
}
if (email && email !== username) {
segments.push(
msg(str`<${email}>`, {
id: "user.display.emailInAngleBrackets",
desc: "The user's email in angle brackets, used when the email is different from the username",
}),
);
}
if (!segments.length) {
return formatUnknownUserLabel();
}
return formatter.format(segments);
}
/**
* Whether the current session is an unauthenticated guest session.
*/

View File

@@ -7,13 +7,14 @@ import { AKElement } from "#elements/Base";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithSession } from "#elements/mixins/session";
import { isAdminRoute } from "#elements/router/utils";
import { SlottedTemplateResult } from "#elements/types";
import { ThemedImage } from "#elements/utils/images";
import Styles from "#components/ak-page-navbar.css";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -38,7 +39,7 @@ export function setPageDetails(header: PageHeaderInit) {
export interface PageHeaderInit {
header?: string | null;
description?: string | null;
description?: SlottedTemplateResult;
icon?: string | null;
iconImage?: boolean;
}
@@ -73,20 +74,20 @@ export class AKPageNavbar
//#region Properties
@state()
icon?: string | null = null;
@property({ attribute: false })
public icon?: string | null = null;
@state()
iconImage = false;
@property({ attribute: false })
public iconImage = false;
@state()
header?: string | null = null;
@property({ attribute: false })
public header?: string | null = null;
@state()
description?: string | null = null;
@property({ attribute: false })
public description?: SlottedTemplateResult = null;
@state()
hasIcon = true;
@property({ attribute: false })
public hasIcon = true;
//#endregion

View File

@@ -2,17 +2,37 @@ import { HorizontalLightComponent } from "./HorizontalLightComponent.js";
import { ifPresent } from "#elements/utils/attributes";
import { html } from "lit";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-textarea-input")
export class AkTextareaInput extends HorizontalLightComponent<string> {
@property({ type: String, reflect: true })
public value = "";
@property({ type: Number })
public rows?: number;
@property({ type: Number })
public maxLength: number = -1;
@property({ type: String })
public placeholder: string | null = null;
public placeholder: string = "";
public override connectedCallback(): void {
super.connectedCallback();
// Listen for form reset events to clear the value
this.closest("form")?.addEventListener("reset", this.handleReset);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.closest("form")?.removeEventListener("reset", this.handleReset);
}
private handleReset = (): void => {
this.value = "";
};
public override renderControl() {
const code = this.inputHint === "code";
@@ -22,11 +42,13 @@ export class AkTextareaInput extends HorizontalLightComponent<string> {
// Prevent the leading spaces added by Prettier's whitespace algo
// prettier-ignore
return html`<textarea
id=${ifDefined(this.fieldID)}
id=${ifPresent(this.fieldID)}
@input=${setValue}
class="pf-c-form-control"
?required=${this.required}
name=${this.name}
rows=${ifPresent(this.rows)}
maxlength=${(this.maxLength >= 0) ? this.maxLength : nothing}
placeholder=${ifPresent(this.placeholder)}
autocomplete=${ifPresent(code, "off")}
spellcheck=${ifPresent(code, "false")}

View File

@@ -1,11 +1,21 @@
import "#elements/EmptyState";
import { TableColumn } from "./TableColumn.js";
import type { Column, TableFlat, TableGroup, TableGrouped, TableRow } from "./types.js";
import { convertContent } from "./utils.js";
import { AKElement } from "#elements/Base";
import { bound } from "#elements/decorators/bound";
import {
EntityDescriptorElement,
isTransclusionParentElement,
TransclusionChildElement,
TransclusionChildSymbol,
} from "#elements/dialogs/shared";
import { WithLocale } from "#elements/mixins/locale";
import { SlottedTemplateResult } from "#elements/types";
import { randomId } from "#elements/utils/randomId";
import { msg, str } from "@lit/localize";
import { css, html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
@@ -70,43 +80,90 @@ export interface ISimpleTable {
* which is zero-indexed
*
*/
@customElement("ak-simple-table")
export class SimpleTable extends AKElement implements ISimpleTable {
static styles = [
export class SimpleTable
extends WithLocale(AKElement)
implements ISimpleTable, TransclusionChildElement
{
declare ["constructor"]: Required<EntityDescriptorElement>;
public static verboseName: string = msg("Object");
public static verboseNamePlural: string = msg("Objects");
public static styles = [
PFTable,
css`
.pf-c-table thead .pf-c-table__check {
min-width: 3rem;
}
.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);
}
.pf-c-toolbar__item .pf-c-input-group {
padding: 0 var(--pf-global--spacer--sm);
}
tr:last-child {
--pf-c-table--BorderColor: transparent;
}
`,
];
public [TransclusionChildSymbol] = true;
#verboseName: string | null = null;
/**
* Optional singular label for the type of entity this form creates/edits.
*
* Overrides the static `verboseName` property for this instance.
*/
@property({ type: String, attribute: "entity-singular" })
public set verboseName(value: string | null) {
this.#verboseName = value;
if (isTransclusionParentElement(this.parentElement)) {
this.parentElement.slottedElementUpdatedAt = new Date();
}
}
public get verboseName(): string | null {
return this.#verboseName || this.constructor.verboseName || null;
}
#verboseNamePlural: string | null = null;
/**
* Optional plural label for the type of entity this form creates/edits.
*
* Overrides the static `verboseNamePlural` property for this instance.
*/
@property({ type: String, attribute: "entity-plural" })
public set verboseNamePlural(value: string | null) {
this.#verboseNamePlural = value;
if (isTransclusionParentElement(this.parentElement)) {
this.parentElement.slottedElementUpdatedAt = new Date();
}
}
public get verboseNamePlural(): string | null {
return this.#verboseNamePlural || this.constructor.verboseNamePlural || null;
}
@property({ type: String, attribute: true, reflect: true })
order?: string;
public order?: string;
@property({ type: Array, attribute: false })
columns: Column[] = [];
public columns: Column[] = [];
@property({ type: Object, attribute: false })
set content(content: ContentType) {
this._content = convertContent(content);
public set content(content: ContentType) {
this.#content = convertContent(content);
}
get content(): TableGrouped | TableFlat {
return this._content;
public get content(): TableGrouped | TableFlat {
return this.#content;
}
private _content: TableGrouped | TableFlat = {
#content: TableGrouped | TableFlat = {
kind: "flat",
content: [],
};
@@ -141,62 +198,81 @@ export class SimpleTable extends AKElement implements ISimpleTable {
super.performUpdate();
}
public renderRow(row: TableRow, _rownum: number) {
return html` <tr part="row">
protected renderEmpty(): SlottedTemplateResult {
const columnCount = this.columns.length || 1;
const verboseNamePlural = this.constructor.verboseNamePlural || msg("Objects");
const message = msg(
str`No ${verboseNamePlural.toLocaleLowerCase(this.activeLanguageTag)} found.`,
{
id: "table.empty",
desc: "The message to show when a table has no content. The placeholder {0} is replaced with the pluralized name of the type of entity being shown in the table.",
},
);
return html`<tr role="presentation">
<td role="presentation" colspan=${columnCount}>
<div class="pf-l-bullseye">
<ak-empty-state><span>${message}</span></ak-empty-state>
</div>
</td>
</tr>`;
}
protected renderRow(row: TableRow, _rownum: number): SlottedTemplateResult {
return html`<tr part="row">
${map(row.content, (col, idx) => html`<td part="cell cell-${idx}">${col}</td>`)}
</tr>`;
}
public renderRows(rows: TableRow[]) {
protected renderRows(rows: TableRow[]): SlottedTemplateResult {
return html`<tbody part="body">
${repeat(rows, (row) => row.key, this.renderRow)}
${rows.length ? repeat(rows, (row) => row.key, this.renderRow) : this.renderEmpty()}
</tbody>`;
}
@bound
public renderRowGroup({ group, content }: TableGroup) {
protected renderRowGroup = ({ group, content }: TableGroup): SlottedTemplateResult => {
return html`<thead part="group-header">
<tr part="group-row">
<td colspan="200" part="group-head">${group}</td>
</tr>
</thead>
${this.renderRows(content)}`;
}
};
@bound
public renderRowGroups(rowGroups: TableGroup[]) {
return html`${map(rowGroups, this.renderRowGroup)}`;
}
protected renderRowGroups = (rowGroups: TableGroup[]): SlottedTemplateResult => {
return map(rowGroups, this.renderRowGroup);
};
public renderBody() {
// prettier-ignore
return this.content.kind === 'flat'
protected renderBody(): SlottedTemplateResult {
return this.content.kind === "flat"
? this.renderRows(this.content.content)
: this.renderRowGroups(this.content.content);
}
public renderColumnHeaders() {
protected renderColumnHeaders(): SlottedTemplateResult {
return html`<tr part="column-row" role="row">
${map(this.icolumns, (col) => col.render(this.order))}
</tr>`;
}
public renderTable() {
return html`
<table part="table" class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable">
<thead part="column-header">
${this.renderColumnHeaders()}
</thead>
${this.renderBody()}
</table>
`;
protected renderTable(): SlottedTemplateResult {
return html`<table
part="table"
class="pf-c-table pf-m-compact pf-m-grid-md pf-m-expandable"
>
<thead part="column-header">
${this.renderColumnHeaders()}
</thead>
${this.renderBody()}
</table> `;
}
public render() {
protected render(): SlottedTemplateResult {
return this.renderTable();
}
public override updated() {
public override updated(): void {
this.setAttribute("data-ouia-component-safe", "true");
}
}

View File

@@ -50,9 +50,21 @@ export interface DialogInit {
onDispose?: (event?: Event) => void;
}
export interface TransclusionElementConstructor extends CustomElementConstructor {
export interface EntityDescriptor {
/**
* Singular label for the type of entity this form creates/edits.
*/
verboseName?: string | null;
/**
* Plural label for the type of entity this form creates/edits.
*/
verboseNamePlural?: string | null;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export interface EntityDescriptorElement extends Function, EntityDescriptor {}
export interface TransclusionElementConstructor extends EntityDescriptor, CustomElementConstructor {
createLabel?: string | null;
}

View File

@@ -0,0 +1,99 @@
import { PFSize } from "#common/enums";
import { UsedByListItem } from "#elements/entities/used-by";
import { StaticTable } from "#elements/table/StaticTable";
import { TableColumn } from "#elements/table/TableColumn";
import { SlottedTemplateResult } from "#elements/types";
import { type UsedBy } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues } from "lit";
import { html } from "lit-html";
import { until } from "lit-html/directives/until.js";
import { customElement, property, state } from "lit/decorators.js";
import PFList from "@patternfly/patternfly/components/List/list.css";
export interface BulkDeleteMetadata {
key: string;
value: string;
}
@customElement("ak-used-by-table")
export class UsedByTable<T extends object> extends StaticTable<T> {
static styles: CSSResult[] = [...super.styles, PFList];
@property({ attribute: false })
public metadata: (item: T) => BulkDeleteMetadata[] = (item: T) => {
const metadata: BulkDeleteMetadata[] = [];
if ("name" in item) {
metadata.push({ key: msg("Name"), value: item.name as string });
}
return metadata;
};
@property({ attribute: false })
public usedBy: null | ((item: T) => Promise<UsedBy[]>) = null;
@state()
protected usedByData: Map<T, UsedBy[]> = new Map();
protected override rowLabel(item: T): string | null {
const name = "name" in item && typeof item.name === "string" ? item.name.trim() : null;
return name || null;
}
@state()
protected get columns(): TableColumn[] {
const [first] = this.items || [];
if (!first) {
return [];
}
return this.metadata(first).map((element) => [element.key]);
}
protected override row(item: T): SlottedTemplateResult[] {
return this.metadata(item).map((element) => element.value);
}
protected override renderToolbarContainer(): SlottedTemplateResult {
return null;
}
protected override firstUpdated(changedProperties: PropertyValues<this>): void {
this.expandable = !!this.usedBy;
super.firstUpdated(changedProperties);
}
protected override renderExpanded(item: T): SlottedTemplateResult {
const handler = async () => {
if (!this.usedByData.has(item) && this.usedBy) {
this.usedByData.set(item, await this.usedBy(item));
}
return this.renderUsedBy(this.usedByData.get(item) || []);
};
return html`${this.usedBy
? until(handler(), html`<ak-spinner size=${PFSize.Large}></ak-spinner>`)
: null}`;
}
protected renderUsedBy(usedBy: UsedBy[]): SlottedTemplateResult {
if (usedBy.length < 1) {
return html`<span>${msg("Not used by any other object.")}</span>`;
}
return html`<ul class="pf-c-list">
${usedBy.map((ub) => UsedByListItem({ ub }))}
</ul>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-used-by-table": UsedByTable<object>;
}
}

View File

@@ -0,0 +1,21 @@
/**
* Given an object and a key, returns the trimmed string value of the key if it exists, otherwise returns null.
*
* @param item The object to pluck the name from.
* @param key The key to look for in the object, defaults to "name".
* @returns The trimmed string value of the key if it exists, otherwise null.
*/
export function pluckEntityName<T extends object, K extends Extract<keyof T, string>>(
item?: T | null,
key: K = "name" as K,
): string | null {
if (typeof item !== "object" || item === null) {
return null;
}
if (!(key in item)) {
return null;
}
return typeof item[key] === "string" ? item[key].trim() : null;
}

View File

@@ -0,0 +1,79 @@
import { pluckEntityName } from "#elements/entities/names";
import { LitFC } from "#elements/types";
import { UsedBy, UsedByActionEnum } from "@goauthentik/api";
import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { html } from "lit-html";
export function formatUsedByConsequence(usedBy: UsedBy, verboseName?: string): string {
verboseName ||= msg("Object");
return match(usedBy.action)
.with(UsedByActionEnum.Cascade, () => {
const relationName = usedBy.modelName || msg("Related object");
return msg(str`${relationName} will be deleted`, {
id: "used-by.consequence.cascade",
desc: "Consequence of deletion, when the related object will also be deleted. The name of the related object will be included, in the format 'Related object will be deleted'.",
});
})
.with(UsedByActionEnum.CascadeMany, () =>
msg(str`Connection will be deleted`, {
id: "used-by.consequence.cascade-many",
}),
)
.with(UsedByActionEnum.SetDefault, () =>
msg(str`Reference will be reset to default value`, {
id: "used-by.consequence.set-default",
}),
)
.with(UsedByActionEnum.SetNull, () =>
msg(str`Reference will be set to an empty value`, {
id: "used-by.consequence.set-null",
}),
)
.with(UsedByActionEnum.LeftDangling, () =>
msg(str`${verboseName} will be left dangling (may cause errors)`, {
id: "used-by.consequence.left-dangling",
}),
)
.with(UsedByActionEnum.UnknownDefaultOpenApi, () =>
msg(str`${verboseName} has an unknown relationship (check logs)`, {
id: "used-by.consequence.unknown-default-open-api",
}),
)
.otherwise(() =>
msg(str`${verboseName} has an unrecognized relationship (check logs)`, {
id: "used-by.consequence.unrecognized",
}),
);
}
export interface UsedByListItemProps {
ub: UsedBy;
formattedName?: string;
verboseName?: string | null;
}
export function formatUsedByMessage({
ub,
verboseName,
formattedName,
}: UsedByListItemProps): string {
verboseName ||= msg("Object");
formattedName ||= pluckEntityName(ub) || msg("Unnamed");
const consequence = formatUsedByConsequence(ub, verboseName);
return msg(str`${formattedName} (${consequence})`, {
id: "used-by-list-item",
desc: "Used in list item, showing the name of the object and the consequence of deletion.",
});
}
export const UsedByListItem: LitFC<UsedByListItemProps> = (props) => {
return html`<li>${formatUsedByMessage(props)}</li>`;
};

View File

@@ -1,111 +1,19 @@
import "#elements/buttons/SpinnerButton/index";
import "#elements/entities/UsedByTable";
import { EVENT_REFRESH } from "#common/constants";
import { PFSize } from "#common/enums";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { ModalButton } from "#elements/buttons/ModalButton";
import { BulkDeleteMetadata } from "#elements/entities/UsedByTable";
import { showMessage } from "#elements/messages/MessageContainer";
import { StaticTable } from "#elements/table/StaticTable";
import { TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
import { UsedBy, UsedByActionEnum } from "@goauthentik/api";
import { UsedBy } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
import PFList from "@patternfly/patternfly/components/List/list.css";
type BulkDeleteMetadata = { key: string; value: string }[];
@customElement("ak-delete-objects-table")
export class DeleteObjectsTable<T extends object> extends StaticTable<T> {
static styles: CSSResult[] = [...super.styles, PFList];
@property({ attribute: false })
public metadata: (item: T) => BulkDeleteMetadata = (item: T) => {
const metadata: BulkDeleteMetadata = [];
if ("name" in item) {
metadata.push({ key: msg("Name"), value: item.name as string });
}
return metadata;
};
@property({ attribute: false })
public usedBy?: (item: T) => Promise<UsedBy[]>;
@state()
protected usedByData: Map<T, UsedBy[]> = new Map();
protected override rowLabel(item: T): string | null {
const name = "name" in item && typeof item.name === "string" ? item.name.trim() : null;
return name || null;
}
@state()
protected get columns(): TableColumn[] {
return this.metadata(this.items![0]).map((element) => [element.key]);
}
protected row(item: T): SlottedTemplateResult[] {
return this.metadata(item).map((element) => {
return html`${element.value}`;
});
}
protected override renderToolbarContainer(): SlottedTemplateResult {
return nothing;
}
protected override firstUpdated(changedProperties: PropertyValues<this>): void {
this.expandable = !!this.usedBy;
super.firstUpdated(changedProperties);
}
protected override renderExpanded(item: T): TemplateResult {
const handler = async () => {
if (!this.usedByData.has(item) && this.usedBy) {
this.usedByData.set(item, await this.usedBy(item));
}
return this.renderUsedBy(this.usedByData.get(item) || []);
};
return html`${this.usedBy
? until(handler(), html`<ak-spinner size=${PFSize.Large}></ak-spinner>`)
: nothing}`;
}
protected renderUsedBy(usedBy: UsedBy[]): TemplateResult {
if (usedBy.length < 1) {
return html`<span>${msg("Not used by any other object.")}</span>`;
}
return html`<ul class="pf-c-list">
${usedBy.map((ub) => {
let consequence = "";
switch (ub.action) {
case UsedByActionEnum.Cascade:
consequence = msg("object will be DELETED");
break;
case UsedByActionEnum.CascadeMany:
consequence = msg("connection will be deleted");
break;
case UsedByActionEnum.SetDefault:
consequence = msg("reference will be reset to default value");
break;
case UsedByActionEnum.SetNull:
consequence = msg("reference will be set to an empty value");
break;
case UsedByActionEnum.LeftDangling:
consequence = msg("reference will be left dangling");
break;
}
return html`<li>${msg(str`${ub.name} (${consequence})`)}</li>`;
})}
</ul>`;
}
}
import { html, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-forms-delete-bulk")
export class DeleteBulkForm<T> extends ModalButton {
@@ -127,61 +35,58 @@ export class DeleteBulkForm<T> extends ModalButton {
/**
* Action shown in messages, for example `deleted` or `removed`
*/
@property()
action = msg("deleted");
@property({ type: String })
public action = msg("deleted");
@property({ attribute: false })
metadata: (item: T) => BulkDeleteMetadata = (item: T) => {
public metadata: (item: T) => BulkDeleteMetadata[] = (item: T) => {
const rec = item as Record<string, unknown>;
const meta = [];
if (Object.prototype.hasOwnProperty.call(rec, "name")) {
const meta: BulkDeleteMetadata[] = [];
if (Object.hasOwn(rec, "name")) {
meta.push({ key: msg("Name"), value: rec.name as string });
}
if (Object.prototype.hasOwnProperty.call(rec, "pk")) {
if (Object.hasOwn(rec, "pk")) {
meta.push({ key: msg("ID"), value: rec.pk as string });
}
return meta;
};
@property({ attribute: false })
usedBy?: (item: T) => Promise<UsedBy[]>;
public usedBy?: (item: T) => Promise<UsedBy[]>;
@property({ attribute: false })
delete!: (item: T) => Promise<unknown>;
public delete!: (item: T) => Promise<unknown>;
async confirm(): Promise<void> {
try {
await Promise.all(
this.objects.map((item) => {
return this.delete(item);
}),
);
this.onSuccess();
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
this.open = false;
} catch (e) {
this.onError(e as Error);
throw e;
}
}
protected async confirm(): Promise<void> {
return Promise.all(this.objects.map((item) => this.delete(item)))
.then(() => {
showMessage({
message: msg(
str`Successfully deleted ${this.objects.length} ${this.objectLabel}`,
),
level: MessageLevel.success,
});
onSuccess(): void {
showMessage({
message: msg(str`Successfully deleted ${this.objects.length} ${this.objectLabel}`),
level: MessageLevel.success,
});
}
onError(e: Error): void {
showMessage({
message: msg(str`Failed to delete ${this.objectLabel}: ${e.toString()}`),
level: MessageLevel.error,
});
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
this.open = false;
})
.catch((parsedError: unknown) => {
return parseAPIResponseError(parsedError).then(() => {
showMessage({
message: msg(str`Failed to delete ${this.objectLabel}`),
description: pluckErrorDetail(parsedError),
level: MessageLevel.error,
});
});
});
}
renderModalInner(): TemplateResult {
@@ -207,12 +112,12 @@ export class DeleteBulkForm<T> extends ModalButton {
</form>
</section>
<section class="pf-c-modal-box__body pf-m-light">
<ak-delete-objects-table
<ak-used-by-table
.items=${this.objects}
.usedBy=${this.usedBy}
.metadata=${this.metadata}
>
</ak-delete-objects-table>
</ak-used-by-table>
</section>
<fieldset class="pf-c-modal-box__footer">
<legend class="sr-only">${msg("Form actions")}</legend>
@@ -234,7 +139,6 @@ export class DeleteBulkForm<T> extends ModalButton {
declare global {
interface HTMLElementTagNameMap {
"ak-delete-objects-table": DeleteObjectsTable<object>;
"ak-forms-delete-bulk": DeleteBulkForm<object>;
}
}

View File

@@ -1,176 +0,0 @@
import "#elements/buttons/SpinnerButton/index";
import { EVENT_REFRESH } from "#common/constants";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { MessageLevel } from "#common/messages";
import { ModalButton } from "#elements/buttons/ModalButton";
import { showMessage } from "#elements/messages/MessageContainer";
import { UsedBy, UsedByActionEnum } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { until } from "lit/directives/until.js";
import PFList from "@patternfly/patternfly/components/List/list.css";
@customElement("ak-forms-delete")
export class DeleteForm extends ModalButton {
static styles: CSSResult[] = [...super.styles, PFList];
@property({ attribute: false })
public obj?: Record<string, unknown>;
@property({ type: String, attribute: "object-label" })
public objectLabel?: string;
@property({ attribute: false })
public usedBy?: () => Promise<UsedBy[]>;
@property({ attribute: false })
public delete!: () => Promise<unknown>;
/**
* Get the display name for the object being deleted/updated.
*/
protected getObjectDisplayName(): string | undefined {
return this.obj?.name as string | undefined;
}
/**
* Get the formatted object name for display in messages.
* Returns ` "displayName"` with quotes if display name exists, empty string otherwise.
*/
protected getFormattedObjectName(): string {
const displayName = this.getObjectDisplayName();
return displayName ? ` "${displayName}"` : "";
}
confirm(): Promise<void> {
return this.delete()
.then(() => {
this.onSuccess();
this.open = false;
this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, {
bubbles: true,
composed: true,
}),
);
})
.catch(async (error: unknown) => {
await this.onError(error);
throw error;
});
}
onSuccess(): void {
showMessage({
message: msg(
str`Successfully deleted ${this.objectLabel} ${this.getObjectDisplayName()}`,
),
level: MessageLevel.success,
});
}
onError(error: unknown): Promise<void> {
return parseAPIResponseError(error).then((parsedError) => {
showMessage({
message: msg(
str`Failed to delete ${this.objectLabel}: ${pluckErrorDetail(parsedError)}`,
),
level: MessageLevel.error,
});
});
}
renderModalInner(): TemplateResult {
const objName = this.getFormattedObjectName();
return html`<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">${msg(str`Delete ${this.objectLabel}`)}</h1>
</div>
</section>
<section class="pf-c-modal-box__body pf-m-light">
<form class="pf-c-form pf-m-horizontal">
<p>
${msg(str`Are you sure you want to delete ${this.objectLabel}${objName}?`)}
</p>
</form>
</section>
${this.usedBy
? until(
this.usedBy().then((usedBy) => {
if (usedBy.length < 1) {
return nothing;
}
return html`
<section class="pf-c-modal-box__body pf-m-light">
<form class="pf-c-form pf-m-horizontal">
<p>${msg(str`The following objects use ${objName}`)}</p>
<ul class="pf-c-list">
${usedBy.map((ub) => {
let consequence = "";
switch (ub.action) {
case UsedByActionEnum.Cascade:
consequence = msg("object will be DELETED");
break;
case UsedByActionEnum.CascadeMany:
consequence = msg(
"connecting object will be deleted",
);
break;
case UsedByActionEnum.SetDefault:
consequence = msg(
"reference will be reset to default value",
);
break;
case UsedByActionEnum.SetNull:
consequence = msg(
"reference will be set to an empty value",
);
break;
}
return html`<li>
${msg(str`${ub.name} (${consequence})`)}
</li>`;
})}
</ul>
</form>
</section>
`;
}),
)
: nothing}
<fieldset class="pf-c-modal-box__footer">
<legend class="sr-only">${msg("Form actions")}</legend>
<ak-spinner-button
.callAction=${async () => {
this.open = false;
}}
class="pf-m-plain"
>
${msg("Cancel")}
</ak-spinner-button>
<ak-spinner-button
.callAction=${() => {
return this.confirm();
}}
class="pf-m-danger"
>
${msg("Delete")}
</ak-spinner-button>
</fieldset>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-forms-delete": DeleteForm;
}
}

View File

@@ -0,0 +1,135 @@
import "#elements/buttons/SpinnerButton/index";
import "#elements/entities/UsedByTable";
import "#elements/forms/FormGroup";
import "#elements/ak-table/ak-simple-table";
import { plural } from "#common/ui/locale/plurals";
import { RawContent } from "#elements/ak-table/ak-simple-table";
import { pluckEntityName } from "#elements/entities/names";
import { formatUsedByConsequence } from "#elements/entities/used-by";
import { ModelForm } from "#elements/forms/ModelForm";
import { SlottedTemplateResult } from "#elements/types";
import { UsedBy } from "@goauthentik/api";
import { msg, str } from "@lit/localize";
import { CSSResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import PFList from "@patternfly/patternfly/components/List/list.css";
/**
* A generic form for confirming the deletion of an object, with an optional list of objects that use the object being deleted.
*/
@customElement("ak-destructive-model-form")
export class DestructiveModelForm<T extends object = object> extends ModelForm<T> {
public static override verboseName = msg("Object");
public static override verboseNamePlural = msg("Objects");
public static override submitVerb = msg("Modify");
public static override createLabel = msg("Review");
public static override submittingVerb = msg("Modifying");
public static styles: CSSResult[] = [...super.styles, PFList];
protected override loadInstance(): Promise<T | null> {
return Promise.resolve(this.instance);
}
@property({ attribute: false })
public usedBy?: () => Promise<UsedBy[]>;
@state()
protected usedByList: UsedBy[] = [];
/**
* Get the display name for the object being deleted/updated.
*/
protected formatDisplayName(): string {
return pluckEntityName(this.instance) ?? msg("Unnamed object");
}
public override formatSubmitLabel(submitVerb?: string): string {
const noun = this.verboseName;
const verb = submitVerb ?? (this.constructor as typeof DestructiveModelForm).submitVerb;
return noun
? msg(str`${verb} ${noun}`, {
id: "form.submit.verb-entity",
})
: verb;
}
protected override load(): Promise<void> {
if (!this.usedBy) {
this.usedByList = [];
return Promise.resolve();
}
return this.usedBy().then((usedBy) => {
this.usedByList = usedBy;
});
}
protected renderUsedBySection(): SlottedTemplateResult {
const { usedByList, verboseName } = this;
return guard([usedByList, verboseName], () => {
const displayName = this.formatDisplayName();
const objectUsageMessage = plural(usedByList.length, {
zero: () =>
msg(str`${verboseName} is not associated with any objects.`, {
id: "usedBy.count.zero",
desc: "Zero: no objects use this entity.",
}),
one: () =>
msg(str`${verboseName} is associated with one object.`, {
id: "usedBy.count.one",
desc: "Singular: exactly one object uses this entity.",
}),
other: () =>
msg(str`${verboseName} is associated with ${usedByList.length} objects.`, {
id: "usedBy.count.other",
desc: "Plural: N objects use this entity.",
}),
});
return html`<ak-form-group ?open=${usedByList.length} label=${objectUsageMessage}>
<div
class="pf-m-monospace"
aria-description=${msg(
str`List of objects that are associated with this ${verboseName}.`,
{
id: "usedBy.description",
},
)}
slot="description"
>
${displayName}
</div>
<ak-simple-table
.columns=${[msg("Object Name"), msg("Consequence"), msg("ID")]}
.content=${usedByList.map((ub): RawContent[] => {
return [
pluckEntityName(ub) || msg("Unnamed"),
formatUsedByConsequence(ub),
html`<code>${ub.pk}</code>`,
];
})}
></ak-simple-table>
</ak-form-group>`;
});
}
protected override renderForm(): SlottedTemplateResult {
return this.renderUsedBySection();
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-forms-delete": DestructiveModelForm;
}
}

View File

@@ -21,6 +21,7 @@ import {
renderModal,
} from "#elements/dialogs";
import {
EntityDescriptorElement,
isTransclusionParentElement,
TransclusionChildElement,
TransclusionChildSymbol,
@@ -102,6 +103,8 @@ export class Form<T = Record<string, unknown>, D = T>
extends AKElement
implements TransclusionChildElement
{
declare ["constructor"]: EntityDescriptorElement;
public static styles: CSSResult[] = [
PFCard,
PFButton,

View File

@@ -32,7 +32,7 @@ details::details-content {
var(--pf-global--spacer--lg) + var(--pf-global--spacer--form-element)
);
padding-inline-end: var(--pf-global--spacer--md);
padding-block-end: var(--pf-global--spacer--sm);
padding-block: var(--pf-global--spacer--sm);
}
details > summary:hover {
@@ -93,4 +93,7 @@ details summary::marker {
.pf-c-form__field-group-header-description {
text-wrap: balance;
text-wrap: pretty;
font-size: var(--pf-c-form__helper-text--FontSize);
color: var(--pf-c-form__helper-text--Color);
}

View File

@@ -1,8 +1,9 @@
import { AKElement } from "#elements/Base";
import Styles from "#elements/forms/FormGroup.css";
import { SlottedTemplateResult } from "#elements/types";
import { msg } from "@lit/localize";
import { CSSResult, html, PropertyValues, TemplateResult } from "lit";
import { CSSResult, html, PropertyValues } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
@@ -28,7 +29,7 @@ export class AKFormGroup extends AKElement {
public open = false;
@property({ type: String, reflect: true })
public label = msg("Details");
public label = "";
@property({ type: String, reflect: true })
public description: string | null = null;
@@ -61,6 +62,20 @@ export class AKFormGroup extends AKElement {
}
};
protected defaultSlot: HTMLSlotElement;
protected headerSlot: HTMLSlotElement;
protected descriptionSlot: HTMLSlotElement;
constructor() {
super();
this.defaultSlot = this.ownerDocument.createElement("slot");
this.headerSlot = this.ownerDocument.createElement("slot");
this.headerSlot.name = "header";
this.descriptionSlot = this.ownerDocument.createElement("slot");
this.descriptionSlot.name = "description";
}
public connectedCallback(): void {
super.connectedCallback();
@@ -115,46 +130,54 @@ export class AKFormGroup extends AKElement {
//#region Render
public render(): TemplateResult {
return html`
<details
${ref(this.#detailsRef)}
?open=${this.open}
aria-expanded=${this.open ? "true" : "false"}
role="group"
aria-labelledby="form-group-header-title"
aria-describedby="form-group-expandable-content-description"
>
<summary @click=${this.toggle}>
<div class="pf-c-form__field-group-header-main" part="group-header">
<header
class="pf-c-form__field-group-header-title"
part="group-header-title"
>
<div
class="pf-c-form__field-group-header-title-text"
part="form-group-header-title"
id="form-group-header-title"
role="heading"
aria-level="3"
>
<div part="label">${this.label}</div>
<slot name="header"></slot>
</div>
</header>
protected render(): SlottedTemplateResult {
const headerSlotted = !!this.findSlotted("header");
const descriptionSlotted = !!this.findSlotted("description");
return html`<details
${ref(this.#detailsRef)}
?open=${this.open}
aria-expanded=${this.open ? "true" : "false"}
role="group"
aria-labelledby="form-group-header-title"
aria-describedby="form-group-expandable-content-description"
>
<summary @click=${this.toggle}>
<div class="pf-c-form__field-group-header-main" part="group-header">
<header class="pf-c-form__field-group-header-title" part="group-header-title">
<div
class="pf-c-form__field-group-header-description"
data-test-id="form-group-header-description"
id="form-group-expandable-content-description"
class="pf-c-form__field-group-header-title-text"
part="form-group-header-title"
id="form-group-header-title"
role="heading"
aria-level="3"
>
${this.description}
<slot name="description"></slot>
${this.label || !headerSlotted
? html`<div part="label">
${this.label ||
(!headerSlotted
? msg("Details", {
id: "form-group.default-label",
})
: null)}
</div>`
: null}
${headerSlotted ? this.headerSlot : null}
</div>
</div>
</summary>
<slot></slot>
</details>
`;
</header>
${this.description || descriptionSlotted
? html`<div
class="pf-c-form__field-group-header-description"
data-test-id="form-group-header-description"
id="form-group-expandable-content-description"
>
${this.description} ${this.descriptionSlot}
</div>`
: null}
</div>
</summary>
${this.defaultSlot}
</details> `;
}
//#endregion

View File

@@ -44,7 +44,7 @@ function isNamedInstance(instance: unknown): instance is NamedInstance {
* @prop {PKT} instancePk - The primary key of the instance to load.
*/
export abstract class ModelForm<
T extends object = object,
T extends object | null = object,
PKT extends string | number = string | number,
D = T,
> extends Form<T, D> {
@@ -96,17 +96,17 @@ export abstract class ModelForm<
* @param pk The primary key of the instance to load.
* @returns A promise that resolves to the loaded instance.
*/
protected abstract loadInstance(pk: PKT): Promise<T>;
protected abstract loadInstance(pk: PKT): Promise<T | null>;
/**
* An overridable method for assigning the loaded instance to the form's state.
*
* This can be used to intercept the loaded instance before it's set on the form.
*/
protected assignInstance(instance: T): void {
protected assignInstance(instance: T | null): void {
this.instance = instance;
if (isNamedInstance(instance)) {
if (instance && isNamedInstance(instance)) {
this.verboseName = instance.verboseName ?? this.verboseName;
this.verboseNamePlural = instance.verboseNamePlural ?? this.verboseNamePlural;
}
@@ -162,30 +162,29 @@ export abstract class ModelForm<
//#endregion
protected override formatSubmitLabel(): string {
protected override formatSubmitLabel(submitLabel?: string): string {
const { saveLabel } = this.constructor as typeof ModelForm;
if (this.instancePk && saveLabel) {
return saveLabel;
}
return super.formatSubmitLabel();
return super.formatSubmitLabel(submitLabel);
}
protected override formatSubmittingLabel(): string {
protected override formatSubmittingLabel(submittingLabel?: string): string {
const { savingLabel } = this.constructor as typeof ModelForm;
if (this.instancePk && savingLabel) {
return savingLabel;
}
return super.formatSubmittingLabel();
return super.formatSubmittingLabel(submittingLabel);
}
protected override formatHeadline(): string {
const modifier = this.instancePk
? (this.constructor as typeof ModelForm).modifierLabel
: null;
protected override formatHeadline(modifier?: string | null): string {
modifier ||= this.instancePk ? (this.constructor as typeof ModelForm).modifierLabel : null;
return super.formatHeadline(this.headline, modifier);
}

View File

@@ -1,6 +1,6 @@
import "#elements/messages/Message";
import { APIError, pluckErrorDetail } from "#common/errors/network";
import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
import { APIMessage, MessageLevel } from "#common/messages";
import { AKElement } from "#elements/Base";
@@ -64,8 +64,13 @@ export function showMessage(message: APIMessage | null, unique: boolean = false)
* @param unique Whether to only display the message if the title is unique.
* @see {@link parseAPIResponseError} for more information on how to handle API errors.
*/
export function showAPIErrorMessage(error: APIError, unique = false): void {
export function showAPIErrorMessage(error: unknown, unique = false): Promise<void> {
if (!error) {
return Promise.resolve();
}
if (
typeof error === "object" &&
instanceOfValidationError(error) &&
Array.isArray(error.nonFieldErrors) &&
error.nonFieldErrors.length
@@ -80,16 +85,21 @@ export function showAPIErrorMessage(error: APIError, unique = false): void {
);
}
return;
return Promise.resolve();
}
showMessage(
{
level: MessageLevel.error,
message: pluckErrorDetail(error),
},
unique,
);
return parseAPIResponseError(error)
.then((parsedError) => pluckErrorDetail(parsedError))
.catch(() => pluckErrorDetail(error, msg("An unknown error occurred")))
.then((message) => {
showMessage(
{
level: MessageLevel.error,
message: message,
},
unique,
);
});
}
export type MessageContainerAlignment = "top-left" | "top-right" | "bottom-left" | "bottom-right";

View File

@@ -110,14 +110,6 @@ td:has(ak-rbac-object-permission-modal) {
margin-inline: 0;
}
.pf-c-table thead .pf-c-table__check {
min-width: 3rem;
}
.pf-c-table tbody .pf-c-table__check input {
margin-top: calc(var(--pf-c-table__check--input--MarginTop) + 1px);
}
.selected-chips::part(chip-group) {
margin-block-end: var(--pf-global--spacer--sm);
}
@@ -187,6 +179,14 @@ time {
}
}
/* Fix alignment issues with images in tables */
.pf-c-table:has(td > img),
.pf-c-table:has(td > [role="img"]) {
tbody > tr > * {
vertical-align: middle;
}
}
:host {
display: block;
container-type: inline-size;

View File

@@ -1,42 +0,0 @@
import { DeleteForm } from "#elements/forms/DeleteForm";
import { User } from "@goauthentik/api";
export function UserOption(user: User): string {
let finalString = user.username;
if (user.name || user.email) {
finalString += " (";
if (user.name) {
finalString += user.name;
if (user.email) {
finalString += ", ";
}
}
if (user.email) {
finalString += user.email;
}
finalString += ")";
}
return finalString;
}
/**
* Get a display-friendly name for a user object.
* Falls back to username if no display name (name field) is set.
*
* @param user - The user object
* @returns The user's display name or username
*/
export function getUserDisplayName(user: User): string {
return user.name || user.username;
}
/**
* Base class for delete/update forms that work with User objects.
* Automatically uses username as fallback when user has no display name.
*/
export class UserDeleteForm extends DeleteForm {
protected override getObjectDisplayName(): string | undefined {
return this.obj ? getUserDisplayName(this.obj as unknown as User) : undefined;
}
}

View File

@@ -12,6 +12,13 @@ import { html, nothing } from "lit";
export const FontAwesomeProtocol = "fa://";
/**
* The default background image for flows, used when no specific background is set.
*
* @todo This feels fragile, especially with theme variables and asset management.
*/
export const DefaultFlowBackground = "/static/dist/assets/images/flow_background.jpg";
export interface ThemedImageProps extends ImgHTMLAttributes<HTMLImageElement> {
/**
* The image path (base URL, may contain %(theme)s for display purposes only)

View File

@@ -33,6 +33,7 @@ import { html, PropertyValues } from "lit";
import { guard } from "lit-html/directives/guard.js";
import { createRef, ref } from "lit-html/directives/ref.js";
import { property } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
export class CreateWizard extends AKElement implements TransclusionChildElement {
/**
@@ -85,6 +86,12 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
@property({ type: String, useDefault: true })
public layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list;
@property({ type: String, attribute: "group-label", useDefault: true })
public groupLabel: string | null = null;
@property({ type: String, attribute: "group-description", useDefault: true })
public groupDescription: string | null = null;
@property({ attribute: false, useDefault: true })
public finalHandler?: () => Promise<void>;
@@ -266,6 +273,14 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
*/
protected renderInitialPageContent?(): SlottedTemplateResult;
/**
* Optional method to render content before the type selection on the initial page,
* for example to offer a choice between creation and binding an existing entity.
*/
protected renderCreateBefore(): SlottedTemplateResult {
return null;
}
protected renderHeading(): SlottedTemplateResult {
const { selectedType, wizard } = this;
@@ -292,26 +307,32 @@ export class CreateWizard extends AKElement implements TransclusionChildElement
.finalHandler=${this.finalHandler}
>
${this.renderHeading()}
<ak-wizard-page-type-create
${ref(this.pageTypeCreateRef)}
slot="initial"
.types=${this.creationTypes}
layout=${this.layout}
headline=${this.verboseName
? msg(str`Choose ${this.verboseName} Type`)
: msg("Choose type")}
@ak-type-create-select=${this.typeSelectListener}
>
${guard([initialPageContent], () => {
if (!initialPageContent) {
return null;
}
${keyed(
this.wizard?.activeStep,
html`<ak-wizard-page-type-create
${ref(this.pageTypeCreateRef)}
slot="initial"
.types=${this.creationTypes}
layout=${this.layout}
group-label=${ifPresent(this.groupLabel)}
group-description=${ifPresent(this.groupDescription)}
headline=${this.verboseName
? msg(str`Choose ${this.verboseName} Type`)
: msg("Choose type")}
@ak-type-create-select=${this.typeSelectListener}
>
${this.renderCreateBefore()}
${guard([initialPageContent], () => {
if (!initialPageContent) {
return null;
}
return html`<div>
<p>${initialPageContent}</p>
</div>`;
})}
</ak-wizard-page-type-create>
return html`<div>
<p>${initialPageContent}</p>
</div>`;
})}
</ak-wizard-page-type-create>`,
)}
${this.renderForms()}
</ak-wizard>`;
}

View File

@@ -1,5 +1,6 @@
import "#elements/LicenseNotice";
import "#elements/Alert";
import "#elements/forms/FormGroup";
import { WithLicenseSummary } from "#elements/mixins/license";
import { SlottedTemplateResult } from "#elements/types";
@@ -39,6 +40,12 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
@property({ type: String, useDefault: true })
public layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list;
@property({ type: String, attribute: "group-label", useDefault: true })
public groupLabel: string | null = null;
@property({ type: String, attribute: "group-description", useDefault: true })
public groupDescription: string | null = null;
//#endregion
static styles: CSSResult[] = [
@@ -239,6 +246,18 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
return html`<div class="ak-c-loading-skeleton ak-m-list"></div>`;
}
const renderedItems = this.renderListItems();
const content = this.groupLabel
? html`<ak-form-group
label=${this.groupLabel}
description=${ifPresent(this.groupDescription)}
part="group"
open
>
${renderedItems}
</ak-form-group>`
: renderedItems;
return [
this.findSlotted() ? this.defaultSlot : null,
html`<form
@@ -248,7 +267,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
role="radiogroup"
aria-label=${ifPresent(this.headline)}
>
${this.renderListItems()}
<slot name="pre-items"></slot>
${content}
</form>`,
];
}

View File

@@ -2,7 +2,6 @@ import "#elements/EmptyState";
import "#flow/components/ak-flow-card";
import { DEFAULT_CONFIG } from "#common/api/config";
import { parseAPIResponseError } from "#common/errors/network";
import { PlexAPIClient, popupCenterScreen } from "#common/helpers/plex";
import { showAPIErrorMessage } from "#elements/messages/MessageContainer";
@@ -53,13 +52,11 @@ export class PlexLoginInit extends BaseStage<
window.location.assign(redirectChallenge.to);
})
.catch(async (error: unknown) => {
return parseAPIResponseError(error)
.then(showAPIErrorMessage)
.then(() => {
setTimeout(() => {
window.location.assign("/");
}, 5000);
});
return showAPIErrorMessage(error).then(() => {
setTimeout(() => {
window.location.assign("/");
}, 5000);
});
});
});
}

View File

@@ -193,7 +193,6 @@ label.pf-c-radio {
padding-block: var(--pf-global--spacer--sm);
cursor: pointer;
background-color: var(--pf-c-radio--BackgroundColor, transparent);
border: 1px solid var(--pf-c-radio--BorderColor);
border-radius: var(--pf-global--BorderRadius--sm);
position: relative;
@@ -220,21 +219,28 @@ label.pf-c-radio {
padding: 0.25em;
border: 1px solid;
color: var(--pf-c-radio--checkmark--BorderColor, transparent);
border-radius: 3px;
border-radius: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
background: color-mix(var(--pf-c-radio--checkmark--BorderColor), transparent 84%);
&::after {
font-family: "Font Awesome 5 Free";
font-weight: 900;
content: "\f00c";
content: "";
display: block;
line-height: 1;
color: var(--pf-c-radio--checkmark--Color);
transition: color 0.2s;
background: var(--pf-c-radio--checkmark--Color);
height: 100%;
width: 100%;
border-radius: 100%;
filter: drop-shadow(0 0 6px color-mix(currentColor, transparent 50%));
}
}

View File

@@ -23,6 +23,23 @@
}
}
.pf-c-table thead .pf-c-table__check {
text-align: center;
min-width: 3rem;
}
.pf-c-table tbody .pf-c-table__check {
text-align: center;
input {
margin-top: calc(var(--pf-c-table__check--input--MarginTop) + 1px);
}
}
td:has(ak-timestamp) {
--pf-c-table--cell--MinWidth: 16ch;
}
/* #region Dark Theme */
:host([theme="dark"]) .pf-c-toolbar {