Compare commits

...

4 Commits

Author SHA1 Message Date
8f995aab62 web: break application view into constituent parts
As part of the project to make the verticals more controllable and responsive, this commit breaks
the ApplicationView into two different parts: The API layer and the rendering layer.

The rendering layer is officially dumb beyond words; it knows nothing at all about Applications,
RBAC, or Outposts; it just draws what it's told to draw. It has parts inside that have their own
reactivity, but that reactivity means nothing to the renderer.

The Renderer itself is broken into two: The LoadingRenderer works when there is no application, and
the regular Renderer is build when there is.  Typescript's check makes it impossible to attempt to
use the standard renderer when there is no application, so all of the `this.application?` checks
just... go away.

A _huge_ section of the View is the control card, which offers the user the power to visit the
provider, provide access to the backchannel providers, edit the application, run an access check
against a given user, and launch the application. All of these features were heavily obscured by a
blizzard of dg/dl/dt/dd html objects that made it hard to see what was in there. Each "description"
pair has been broken out into a tuple of Term and Description, with filters to remove the ones that
aren't applicable whenever an application doesn't have, for example, a launch url, or backchannel
providers, and a utility function I wrote _ages_ ago renders the description list syntax for me
without my having to do it all by hand.

The nice thing about this work is that it now allows me to *see* where in the ApplicationView code
to focus my efforts on providing activation hooks for the "create a new policy," "assign a new
permission to a user," or "edit an application" commands that should be accessible by the palette,
or even from the sidebar.

The other nice thing is that it reveals just *where* in our code to focus our efforts on revamping
our styling, and making it better for ourselves and our users.

There's a reason I call this my *legibility project*.

It didn't even take that long... about 2½ hours, and I'm only going to get faster at it as the needs
of the different components become clear.
2024-05-08 17:47:26 -07:00
2846e49657 Added comments to explain something. 2024-05-08 13:56:46 -07:00
0e60e755d4 Eslint had opinions. 2024-05-08 13:50:47 -07:00
6cf2433e2b web: fix value handling inside controlled components
This is one of those stupid bugs that drive web developers crazy. The basics are straightforward:
when you cause a higher-level component to have a "big enough re-render," for some unknown
definition of "big enough," it will re-render the sub-components. In traditional web interaction,
those components should never be re-rendered while the user is interacting with the form, but in
frameworks where there's dynamic re-arrangement, part or all of the form could get re-rendered at
any mmoment. Since neither the form nor any of its intermediaries is tracking the values as they're
changed, it's up to the components themselves to keep the user's input-- and to be hardened against
property changes coming from the outside world.

So static memoization of the initial value passed in, and aggressively walling off the values the
customer generates from that field, are needed to protect the user's work from any framework's
dynamic DOM management. I remember struggling with this in React; I had hoped Lit was better, but in
this case, not better enough.

The protocol for "is it an ak-data-control" is "it has a `json()` method that returns the data ready
to be sent to the authentik server."  I missed that in one place, so that's on me.
2024-05-08 13:38:49 -07:00
5 changed files with 280 additions and 252 deletions

View File

@ -3,7 +3,6 @@ import "@goauthentik/admin/applications/ApplicationCheckAccessForm";
import "@goauthentik/admin/applications/ApplicationForm";
import "@goauthentik/admin/policies/BoundPoliciesList";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js";
import "@goauthentik/components/ak-app-icon";
import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base";
@ -13,10 +12,8 @@ import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/rbac/ObjectPermissionsPage";
import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
import { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -35,18 +32,14 @@ import {
RbacPermissionsAssignedByUsersListModelEnum,
} from "@goauthentik/api";
import {
ApplicationViewPageLoadingRenderer,
ApplicationViewPageRenderer,
} from "./ApplicationViewPageRenderers.js";
@customElement("ak-application-view")
export class ApplicationViewPage extends AKElement {
@property({ type: String })
applicationSlug?: string;
@state()
application?: Application;
@state()
missingOutpost = false;
static get styles(): CSSResult[] {
static get styles() {
return [
PFBase,
PFList,
@ -60,6 +53,15 @@ export class ApplicationViewPage extends AKElement {
];
}
@property({ type: String })
applicationSlug?: string;
@state()
application?: Application;
@state()
missingOutpost = false;
fetchIsMissingOutpost(providersByPk: Array<number>) {
new OutpostsApi(DEFAULT_CONFIG)
.outpostsInstancesList({
@ -94,231 +96,15 @@ export class ApplicationViewPage extends AKElement {
}
}
render(): TemplateResult {
return html`<ak-page-header
header=${this.application?.name || msg("Loading")}
description=${ifDefined(this.application?.metaPublisher)}
.iconImage=${true}
>
<ak-app-icon
size=${PFSize.Medium}
slot="icon"
.app=${this.application}
></ak-app-icon>
</ak-page-header>
${this.renderApp()}`;
}
render() {
const renderer = this.application
? new ApplicationViewPageRenderer(
this.application,
this.missingOutpost,
RbacPermissionsAssignedByUsersListModelEnum.CoreApplication,
)
: new ApplicationViewPageLoadingRenderer();
renderApp(): TemplateResult {
if (!this.application) {
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
</ak-empty-state>`;
}
return html`<ak-tabs>
${this.missingOutpost
? html`<div slot="header" class="pf-c-banner pf-m-warning">
${msg("Warning: Application is not used by any Outpost.")}
</div>`
: html``}
<section
slot="page-overview"
data-tab-title="${msg("Overview")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-l-grid pf-m-gutter">
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-2-col-on-xl pf-m-2-col-on-2xl"
>
<div class="pf-c-card__title">${msg("Related")}</div>
<div class="pf-c-card__body">
<dl class="pf-c-description-list">
${this.application.providerObj
? html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Provider")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<a
href="#/core/providers/${this.application
.providerObj?.pk}"
>
${this.application.providerObj?.name}
(${this.application.providerObj?.verboseName})
</a>
</div>
</dd>
</div>`
: html``}
${(this.application.backchannelProvidersObj || []).length > 0
? html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Backchannel Providers")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ul class="pf-c-list">
${this.application.backchannelProvidersObj.map(
(provider) => {
return html`
<li>
<a
href="#/core/providers/${provider.pk}"
>
${provider.name}
(${provider.verboseName})
</a>
</li>
`;
},
)}
</ul>
</div>
</dd>
</div>`
: html``}
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Policy engine mode")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${this.application.policyEngineMode?.toUpperCase()}
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Edit")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header">
${msg("Update Application")}
</span>
<ak-application-form
slot="form"
.instancePk=${this.application.slug}
>
</ak-application-form>
<button
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${msg("Edit")}
</button>
</ak-forms-modal>
</div>
</dd>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Check access")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-forms-modal .closeAfterSuccessfulSubmit=${false}>
<span slot="submit"> ${msg("Check")} </span>
<span slot="header">
${msg("Check Application access")}
</span>
<ak-application-check-access-form
slot="form"
.application=${this.application}
>
</ak-application-check-access-form>
<button
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${msg("Test")}
</button>
</ak-forms-modal>
</div>
</dd>
</div>
${this.application.launchUrl
? html`<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("Launch")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<a
target="_blank"
href=${this.application.launchUrl}
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${msg("Launch")}
</a>
</div>
</dd>
</div>`
: html``}
</dl>
</div>
</div>
<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-10-col-on-xl pf-m-10-col-on-2xl"
>
<div class="pf-c-card__title">
${msg("Logins over the last week (per 8 hours)")}
</div>
<div class="pf-c-card__body">
${this.application &&
html` <ak-charts-application-authorize
applicationSlug=${this.application.slug}
>
</ak-charts-application-authorize>`}
</div>
</div>
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Changelog")}</div>
<div class="pf-c-card__body">
<ak-object-changelog
targetModelPk=${this.application.pk || ""}
targetModelApp="authentik_core"
targetModelName="application"
>
</ak-object-changelog>
</div>
</div>
</div>
</section>
<section
slot="page-policy-bindings"
data-tab-title="${msg("Policy / Group / User Bindings")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
<div class="pf-c-card">
<div class="pf-c-card__title">
${msg("These policies control which users can access this application.")}
</div>
<ak-bound-policies-list .target=${this.application.pk}>
</ak-bound-policies-list>
</div>
</section>
<ak-rbac-object-permission-page
slot="page-permissions"
data-tab-title="${msg("Permissions")}"
model=${RbacPermissionsAssignedByUsersListModelEnum.CoreApplication}
objectPk=${this.application.pk}
></ak-rbac-object-permission-page>
</ak-tabs>`;
return renderer.render();
}
}

View File

@ -0,0 +1,214 @@
import { PFSize } from "@goauthentik/common/enums.js";
import { DescriptionPair, renderDescriptionList } from "@goauthentik/components/DescriptionList.js";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import type { Application, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
export class ApplicationViewPageLoadingRenderer {
constructor() {}
render() {
return html`<ak-page-header header=${msg("Loading")}
><ak-empty-state ?loading="${true}" header=${msg("Loading")}> </ak-empty-state
></ak-page-header>`;
}
}
export class ApplicationViewPageRenderer {
constructor(
private app: Application,
private noOutpost: boolean,
private rbacModel: RbacPermissionsAssignedByUsersListModelEnum,
) {}
missingOutpostMessage() {
return this.noOutpost
? html`<div slot="header" class="pf-c-banner pf-m-warning">
${msg("Warning: Application is not used by any Outpost.")}
</div>`
: nothing;
}
controlCardContents(app: Application): DescriptionPair[] {
// prettier-ignore
const rows: (DescriptionPair | null)[] = [
app.providerObj
? [
msg("Provider"),
html`
<a href="#/core/providers/${app.providerObj?.pk}">
${app.providerObj?.name} (${app.providerObj?.verboseName})
</a>
`,
]
: null,
(app.backchannelProvidersObj || []).length > 0
? [
msg("Backchannel Providers"),
html`
<ul class="pf-c-list">
${app.backchannelProvidersObj.map((provider) => {
return html`
<li>
<a href="#/core/providers/${provider.pk}">
${provider.name} (${provider.verboseName})
</a>
</li>
`;
})}
</ul>
`,
]
: null,
[
msg("Policy engine mode"),
app.policyEngineMode?.toUpperCase()
],
[
msg("Edit"),
html`
<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update Application")} </span>
<ak-application-form slot="form" .instancePk=${app.slug}>
</ak-application-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Edit")}
</button>
</ak-forms-modal>
`,
],
[
msg("Check access"),
html`
<ak-forms-modal .closeAfterSuccessfulSubmit=${false}>
<span slot="submit"> ${msg("Check")} </span>
<span slot="header"> ${msg("Check Application access")} </span>
<ak-application-check-access-form slot="form" .application=${app}>
</ak-application-check-access-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Test")}
</button>
</ak-forms-modal>
`,
],
app.launchUrl
? [
msg("Launch"),
html`
<a
target="_blank"
href=${app.launchUrl}
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${msg("Launch")}
</a>
`,
]
: null,
];
return rows.filter((row) => row !== null) as DescriptionPair[];
}
controlCard(app: Application) {
return html`
<div class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-2-col-on-xl pf-m-2-col-on-2xl">
<div class="pf-c-card__title">${msg("Related")}</div>
<div class="pf-c-card__body">
${renderDescriptionList(this.controlCardContents(app))}
</div>
</div>
`;
}
loginsChart(app: Application) {
return html`<div
class="pf-c-card pf-l-grid__item pf-m-12-col pf-m-10-col-on-xl pf-m-10-col-on-2xl"
>
<div class="pf-c-card__title">${msg("Logins over the last week (per 8 hours)")}</div>
<div class="pf-c-card__body">
${app &&
html` <ak-charts-application-authorize applicationSlug=${app.slug}>
</ak-charts-application-authorize>`}
</div>
</div>`;
}
changelog(app: Application) {
return html`
<div class="pf-c-card pf-l-grid__item pf-m-12-col">
<div class="pf-c-card__title">${msg("Changelog")}</div>
<div class="pf-c-card__body">
<ak-object-changelog
targetModelPk=${app.pk || ""}
targetModelApp="authentik_core"
targetModelName="application"
>
</ak-object-changelog>
</div>
</div>
`;
}
overview(app: Application) {
return html`
<div class="pf-l-grid pf-m-gutter">
${this.controlCard(app)} ${this.loginsChart(app)} ${this.changelog(app)}
</div>
</section>`;
}
policiesList(app: Application) {
return html`
<div class="pf-c-card">
<div class="pf-c-card__title">
${msg("These policies control which users can access this application.")}
</div>
<ak-bound-policies-list .target=${app.pk}> </ak-bound-policies-list>
</div>
`;
}
render() {
return html` <ak-page-header
header=${this.app.name}
description=${ifDefined(this.app.metaPublisher)}
.iconImage=${true}
>
<ak-app-icon size=${PFSize.Medium} slot="icon" .app=${this.app}></ak-app-icon>
</ak-page-header>
<ak-tabs>
${this.missingOutpostMessage()}
<section
slot="page-overview"
data-tab-title="${msg("Overview")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
${this.overview(this.app)}
</section>
<section
slot="page-policy-bindings"
data-tab-title="${msg("Policy / Group / User Bindings")}"
class="pf-c-page__main-section pf-m-no-padding-mobile"
>
${this.policiesList(this.app)}
</section>
<ak-rbac-object-permission-page
slot="page-permissions"
data-tab-title="${msg("Permissions")}"
model=${this.rbacModel}
objectPk=${this.app.pk}
></ak-rbac-object-permission-page>
</ak-tabs>`;
}
}

View File

@ -2,8 +2,9 @@ import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { PropertyValues } from "@lit/reactive-element/reactive-element";
import { TemplateResult, css, html } from "lit";
import { customElement, property, queryAll } from "lit/decorators.js";
import { customElement, property, queryAll, state } from "lit/decorators.js";
import { map } from "lit/directives/map.js";
import PFCheck from "@patternfly/patternfly/components/Check/check.css";
@ -112,10 +113,14 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
@queryAll('input[type="checkbox"]')
checkboxes!: NodeListOf<HTMLInputElement>;
internals?: ElementInternals;
@state()
values: string[] = [];
get json() {
return this.value;
internals?: ElementInternals;
doneFirstUpdate = false;
json() {
return this.values;
}
private get formValue() {
@ -124,7 +129,7 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
}
const name = this.name;
const entries = new FormData();
this.value.forEach((v) => entries.append(name, v));
this.values.forEach((v) => entries.append(name, v));
return entries;
}
@ -136,14 +141,14 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
onClick(ev: Event) {
ev.stopPropagation();
this.value = Array.from(this.checkboxes)
this.values = Array.from(this.checkboxes)
.filter((checkbox) => checkbox.checked)
.map((checkbox) => checkbox.name);
this.dispatchCustomEvent("change", this.value);
this.dispatchCustomEvent("input", this.value);
this.dispatchCustomEvent("change", this.values);
this.dispatchCustomEvent("input", this.values);
if (this.internals) {
this.internals.setValidity({});
if (this.required && this.value.length === 0) {
if (this.required && this.values.length === 0) {
this.internals.setValidity(
{
valueMissing: true,
@ -154,6 +159,16 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
}
this.internals.setFormValue(this.formValue);
}
// Doing a write-back so anyone examining the checkbox.value field will get something
// meaningful. Doesn't do anything for anyone, usually, but it's nice to have.
this.value = this.values;
}
willUpdate(changed: PropertyValues<this>) {
if (changed.has("value") && !this.doneFirstUpdate) {
this.doneFirstUpdate = true;
this.values = this.value;
}
}
connectedCallback() {
@ -183,7 +198,7 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
render() {
const renderOne = ([name, label]: CheckboxPr) => {
const selected = this.value.includes(name);
const selected = this.values.includes(name);
const blockFwd = (e: Event) => {
e.stopImmediatePropagation();
};

View File

@ -53,6 +53,9 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
private isLoading = false;
private doneFirstUpdate = false;
private internalSelected: DualSelectPair[] = [];
private pagination?: Pagination;
constructor() {
@ -69,6 +72,11 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
}
willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("selected") && !this.doneFirstUpdate) {
this.doneFirstUpdate = true;
this.internalSelected = this.selected;
}
if (changedProperties.has("searchDelay")) {
this.doSearch = debounce(
AkDualSelectProvider.prototype.doSearch.bind(this),
@ -105,7 +113,8 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
if (!(event instanceof CustomEvent)) {
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
}
this.selected = event.detail.value;
this.internalSelected = event.detail.value;
this.selected = this.internalSelected;
}
onSearch(event: Event) {
@ -124,12 +133,16 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
return this.dualSelector.value!.selected.map(([k, _]) => k);
}
json() {
return this.value;
}
render() {
return html`<ak-dual-select
${ref(this.dualSelector)}
.options=${this.options}
.pages=${this.pagination}
.selected=${this.selected}
.selected=${this.internalSelected}
available-label=${this.availableLabel}
selected-label=${this.selectedLabel}
></ak-dual-select>`;

View File

@ -80,7 +80,7 @@ export function serializeForm<T extends KeyUnknown>(
}
if ("akControl" in inputElement.dataset) {
assignValue(element, inputElement.value, json);
assignValue(element, (inputElement as unknown as AkControlElement).json(), json);
return;
}