From 075a1f5875c5e0c3f897c968d9b866b0ac75b14c Mon Sep 17 00:00:00 2001 From: "Jens L." Date: Wed, 22 Apr 2026 15:08:31 +0100 Subject: [PATCH] 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 * dont show type selector if only one is allowed Signed-off-by: Jens Langhammer * do the same for stage wizard Signed-off-by: Jens Langhammer * minor unrelated fix: alignment in table desc Signed-off-by: Jens Langhammer * add option to bind existing policy Signed-off-by: Jens Langhammer * adjust labels? Signed-off-by: Jens Langhammer * 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 Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> --- web/src/admin/ak-about-modal.ts | 6 +- .../applications/ApplicationListPage.css | 5 - .../admin/applications/ApplicationListPage.ts | 1 + web/src/admin/brands/BrandForm.ts | 26 ++- web/src/admin/flows/BoundStagesList.ts | 59 +++-- web/src/admin/groups/RelatedUserList.ts | 22 +- web/src/admin/policies/BoundPoliciesList.ts | 49 ++--- web/src/admin/policies/PolicyBindingForm.ts | 204 +++++++++--------- web/src/admin/policies/ak-policy-wizard.ts | 99 +++++++-- web/src/admin/stages/ak-stage-wizard.ts | 61 +++++- web/src/admin/users/UserActiveForm.ts | 182 +++++++++++----- web/src/admin/users/UserListPage.ts | 20 +- web/src/admin/users/UserViewPage.ts | 147 +++++++------ web/src/common/ui/locale/plurals.ts | 68 ++++++ web/src/common/users.ts | 70 +++++- web/src/components/ak-page-navbar.ts | 25 +-- web/src/components/ak-textarea-input.ts | 30 ++- web/src/elements/ak-table/ak-simple-table.ts | 162 ++++++++++---- web/src/elements/dialogs/shared.ts | 14 +- web/src/elements/entities/UsedByTable.ts | 99 +++++++++ web/src/elements/entities/names.ts | 21 ++ web/src/elements/entities/used-by.ts | 79 +++++++ web/src/elements/forms/DeleteBulkForm.ts | 186 ++++------------ web/src/elements/forms/DeleteForm.ts | 176 --------------- .../elements/forms/DestructiveModelForm.ts | 135 ++++++++++++ web/src/elements/forms/Form.ts | 3 + web/src/elements/forms/FormGroup.css | 5 +- web/src/elements/forms/FormGroup.ts | 101 +++++---- web/src/elements/forms/ModelForm.ts | 23 +- web/src/elements/messages/MessageContainer.ts | 30 ++- web/src/elements/table/Table.css | 16 +- web/src/elements/user/utils.ts | 42 ---- web/src/elements/utils/images.ts | 7 + web/src/elements/wizard/CreateWizard.ts | 59 +++-- .../elements/wizard/TypeCreateWizardPage.ts | 22 +- web/src/flow/sources/plex/PlexLoginInit.ts | 13 +- .../styles/authentik/components/Form/form.css | 12 +- .../authentik/components/Table/table.css | 17 ++ 38 files changed, 1407 insertions(+), 889 deletions(-) create mode 100644 web/src/common/ui/locale/plurals.ts create mode 100644 web/src/elements/entities/UsedByTable.ts create mode 100644 web/src/elements/entities/names.ts create mode 100644 web/src/elements/entities/used-by.ts delete mode 100644 web/src/elements/forms/DeleteForm.ts create mode 100644 web/src/elements/forms/DestructiveModelForm.ts delete mode 100644 web/src/elements/user/utils.ts diff --git a/web/src/admin/ak-about-modal.ts b/web/src/admin/ak-about-modal.ts index 52f9461d03..59f78395ee 100644 --- a/web/src/admin/ak-about-modal.ts +++ b/web/src/admin/ak-about-modal.ts @@ -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" > diff --git a/web/src/admin/applications/ApplicationListPage.css b/web/src/admin/applications/ApplicationListPage.css index 98a6e4ea5d..a78a7def9f 100644 --- a/web/src/admin/applications/ApplicationListPage.css +++ b/web/src/admin/applications/ApplicationListPage.css @@ -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; diff --git a/web/src/admin/applications/ApplicationListPage.ts b/web/src/admin/applications/ApplicationListPage.ts index e1a8d9a771..b76a4c3df0 100644 --- a/web/src/admin/applications/ApplicationListPage.ts +++ b/web/src/admin/applications/ApplicationListPage.ts @@ -127,6 +127,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage) return [ html` { } protected override renderForm(): TemplateResult { - return html` { help=${msg( "Matching is done based on domain suffix, so if you enter domain.tld, foo.domain.tld will still match.", )} + ?autofocus=${!this.instance} > { 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 { 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.")} > @@ -111,7 +123,7 @@ export class BrandForm extends ModelForm { 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.")} > @@ -120,8 +132,7 @@ export class BrandForm extends ModelForm { 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 {

diff --git a/web/src/admin/flows/BoundStagesList.ts b/web/src/admin/flows/BoundStagesList.ts index 6f317d38d2..136390a3c1 100644 --- a/web/src/admin/flows/BoundStagesList.ts +++ b/web/src/admin/flows/BoundStagesList.ts @@ -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 { - 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> { - return new FlowsApi(DEFAULT_CONFIG).flowsBindingsList({ + @property({ type: String, useDefault: true }) + public target: string | null = null; + + protected override async apiEndpoint(): Promise> { + return this.flowsAPI.flowsBindingsList({ ...(await this.defaultEndpointConfig()), target: this.target || "", }); @@ -52,7 +54,7 @@ export class BoundStagesList extends Table { [msg("Actions"), null, msg("Row Actions")], ]; - renderToolbarSelected(): TemplateResult { + renderToolbarSelected(): SlottedTemplateResult { const disabled = this.selectedElements.length < 1; return html` { ]; }} .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 { `; } - row(item: FlowStageBinding): SlottedTemplateResult[] { + protected override row(item: FlowStageBinding): SlottedTemplateResult[] { return [ html`

${item.order}
`, item.stageObj?.name, @@ -115,30 +117,27 @@ export class BoundStagesList extends Table { protected renderActions(): SlottedTemplateResult { return html` - `; + class="pf-c-button pf-m-primary" + ${modalInvoker(AKStageWizard, { + showBindingPage: true, + bindingTarget: this.target, + })} + > + ${msg("Bind...")} + `; } - protected override renderExpanded(item: FlowStageBinding): TemplateResult { + protected override renderExpanded(item: FlowStageBinding): SlottedTemplateResult { return html`
-

${msg("These bindings control if this stage will be applied to the flow.")}

+ ${msg( + "These bindings control if this stage will be applied to the flow.", + )}
`; } diff --git a/web/src/admin/groups/RelatedUserList.ts b/web/src/admin/groups/RelatedUserList.ts index 7eb9c37764..9a71ce90cc 100644 --- a/web/src/admin/groups/RelatedUserList.ts +++ b/web/src/admin/groups/RelatedUserList.ts @@ -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)} `; })} @@ -317,22 +318,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
- { - return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ - id: item.pk || 0, - patchedUserRequest: { - isActive: !item.isActive, - }, - }); - }} - > - - + ${ToggleUserActivationButton(item)}
diff --git a/web/src/admin/policies/BoundPoliciesList.ts b/web/src/admin/policies/BoundPoliciesList.ts index c159cedbcb..fedeb7650b 100644 --- a/web/src/admin/policies/BoundPoliciesList.ts +++ b/web/src/admin/policies/BoundPoliciesList.ts @@ -184,7 +184,7 @@ export class BoundPoliciesList extends bindingTarget: this.target, })} > - ${msg("Create and bind Policy")} + ${msg("Bind...")} `; } @@ -223,44 +223,16 @@ export class BoundPoliciesList extends html`${msg("No Policies bound.")}
${msg("No policies are currently bound to this object.")}
-
+
${msg("Policy actions")} ${this.renderNewPolicyButton()} - -
+
`, ); } renderToolbar(): SlottedTemplateResult { - return html`${this.allowedTypes.includes(PolicyBindingCheckTarget.Policy) - ? this.renderNewPolicyButton() - : null} - `; + return this.renderNewPolicyButton(); } renderPolicyEngineMode() { @@ -270,10 +242,15 @@ export class BoundPoliciesList extends if (policyEngineMode === undefined) { return nothing; } - return html`

- ${msg(str`The currently selected policy engine mode is ${policyEngineMode.label}:`)} - ${policyEngineMode.description} -

`; + return html`${this.findSlotted("description") + ? html`

+ +

` + : nothing} +

+ ${msg(str`The currently selected policy engine mode is ${policyEngineMode.label}:`)} + ${policyEngineMode.description} +

`; } renderToolbarContainer(): SlottedTemplateResult { diff --git a/web/src/admin/policies/PolicyBindingForm.ts b/web/src/admin/policies/PolicyBindingForm.ts index ed6338f702..1fdd6d23b4 100644 --- a/web/src/admin/policies/PolicyBindingForm.ts +++ b/web/src/admin/policies/PolicyBindingForm.ts @@ -63,7 +63,7 @@ export class PolicyBindingForm 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 extends `; } + protected renderTarget() { + return html` + { + return groupBy(items, (policy) => policy.verboseNamePlural); + }} + .fetchObjects=${async (query?: string): Promise => { + 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 + > + + ${this.typeNotices + .filter(({ type }) => type === PolicyBindingCheckTarget.Policy) + .map((msg) => { + return html`

${msg.notice}

`; + })} +
+ + => { + 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 + > + + ${this.typeNotices + .filter(({ type }) => type === PolicyBindingCheckTarget.Group) + .map((msg) => { + return html`

${msg.notice}

`; + })} +
+ + => { + 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 + > + + ${this.typeNotices + .filter(({ type }) => type === PolicyBindingCheckTarget.User) + .map((msg) => { + return html`

${msg.notice}

`; + })} +
`; + } + protected override renderForm(): TemplateResult { - return html`
-
${this.renderModeSelector()}
- -
+ return html`${this.allowedTypes.length > 1 + ? html`
+
${this.renderModeSelector()}
+ +
` + : this.renderTarget()} => { - return this.#api.policiesAllTypesList(requestInit); + protected override apiEndpoint = async (requestInit?: RequestInit): Promise => { + return this.policiesAPI.policiesAllTypesList(requestInit); }; - protected updated(changedProperties: PropertyValues): void { + protected override updated(changedProperties: PropertyValues): 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("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 = { + 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` + []} + @change=${(ev: CustomEvent>) => { + if (!this.wizard) { + return; + } + + this.wizard.state[initialStep] = ev.detail.value; + this.wizard.navigateNext(); + }} + > + + `; + } + protected renderForms(): SlottedTemplateResult { const bindingPage = this.showBindingPage ? html` - + > ` : null; diff --git a/web/src/admin/stages/ak-stage-wizard.ts b/web/src/admin/stages/ak-stage-wizard.ts index b67cd79fa1..21e31367ab 100644 --- a/web/src/admin/stages/ak-stage-wizard.ts +++ b/web/src/admin/stages/ak-stage-wizard.ts @@ -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 => { return this.#api.stagesAllTypesList(requestInit); }; - protected updated(changedProperties: PropertyValues): void { + protected override updated(changedProperties: PropertyValues): 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("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` + []} + @change=${() => { + if (!this.wizard) { + return; + } + + this.wizard.navigateNext(); + }} + > + + `; + } + protected renderForms(): SlottedTemplateResult { const bindingPage = this.showBindingPage ? html`) { + public static override verboseName = msg("User"); + public static override verboseNamePlural = msg("Users"); + + protected coreAPI = new CoreApi(DEFAULT_CONFIG); + + protected override send(): Promise { + 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 { - 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`
-
-

${msg(str`Update ${this.objectLabel}`)}

-
-
-
-
-

- ${msg(str`Are you sure you want to update ${this.objectLabel}${objName}?`)} -

-
-
- `; + 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 => { + 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` +
+ ${displayName} +
+ { + return [pluckEntityName(ub) || msg("Unnamed"), html`${ub.pk}`]; + })} + > +
`; } } 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``; +} diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index 0f6a3abe64..612914e444 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -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(
- { - return this.#api.coreUsersPartialUpdate({ - id: item.pk, - patchedUserRequest: { - isActive: !item.isActive, - }, - }); - }} - > - - + ${ToggleUserActivationButton(item)}
diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index ead0b48935..7d81a724e5 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -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) { + 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``], - [msg("Type"), userTypeToLabel(user.type)], - [msg("Superuser"), html``], - [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`` ], + [ msg("Type"), userTypeToLabel(user.type) ], + [ msg("Superuser"), html`` ], + [ msg("Actions"), this.renderActionButtons(user) ], + [ msg("Recovery"), this.renderRecoveryButtons(user) ], + ] return html`
${msg("User Info")}
@@ -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")} - { - return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ - id: user.pk, - patchedUserRequest: { - isActive: !user.isActive, - }, - }); - }} - > - - + + ${ToggleUserActivationButton(user, { className: "pf-m-block" })} ${showImpersonate ? html`