mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
68
web/src/common/ui/locale/plurals.ts
Normal file
68
web/src/common/ui/locale/plurals.ts
Normal 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)();
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
99
web/src/elements/entities/UsedByTable.ts
Normal file
99
web/src/elements/entities/UsedByTable.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
21
web/src/elements/entities/names.ts
Normal file
21
web/src/elements/entities/names.ts
Normal 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;
|
||||
}
|
||||
79
web/src/elements/entities/used-by.ts
Normal file
79
web/src/elements/entities/used-by.ts
Normal 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>`;
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
135
web/src/elements/forms/DestructiveModelForm.ts
Normal file
135
web/src/elements/forms/DestructiveModelForm.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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>`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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%));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user