web: clean up UserInterface in prep for OAuth and Silo Projects (#8278)

* This was pretty quick. While looking at the Oauth stuff, changes made to the UserInterface triggered
the "harder" eslint pass, which said "UserInterface exceeds permitted complexity (9)." I couldn't
disagree; it had lots of conditionals.

This commit:

- Changes no functionality; it's just cleanup.
- Breaks UserInterface into business and presentation layers
- The presentation layer:
  - Further breaks the presentation layer into a frame and conditional components. Each conditional
    is now a simple guard condition.
  - Taps into the event listener set-up for toggles, eliminating their local scope/window duplication
  - Extracts in-line complex expressions into isolated and scope functions to name them and make them
    easier to find and read.
  - Extracts the custom CSS into its own named variable, again, making it easier to find and read.
- The business layer:
  - Builds the window-level event listener at connection, and disconnects them correctly, allowing
    this whole interface to be used in a SPA.
  - Asserts a reliable contract at the presentation layer; there should be no question "Session" and
    "UIConfig" are available before rendering.
  - Renames `firstUpdated` to `fetchConfigurationDetails`, and calls it in the constructor. There
    ought to be no circumstances where this object is constructed outside a working environment; no
    sense in waiting until you've done a `render() { nothing }` pass to fetch details.

Oddities: There are a pair of `<!-- -->` HTML comments in the framing `render()`; those are there
just to stop prettier from slamming a string of conditional renders all into one line, making them
harder to read.

* Adding a small experiment: Typescript pattern matching.

* A few renames as requested by @BeryJu
This commit is contained in:
Ken Sternberg
2024-03-04 09:46:12 -08:00
committed by GitHub
parent cd4d6483c5
commit b059754fe5
3 changed files with 2288 additions and 1940 deletions

3668
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -64,6 +64,7 @@
"mermaid": "^10.8.0", "mermaid": "^10.8.0",
"rapidoc": "^9.3.4", "rapidoc": "^9.3.4",
"style-mod": "^4.1.2", "style-mod": "^4.1.2",
"ts-pattern": "^5.0.6",
"webcomponent-qr-code": "^1.2.0", "webcomponent-qr-code": "^1.2.0",
"yaml": "^2.4.0" "yaml": "^2.4.0"
}, },

View File

@ -5,10 +5,10 @@ import {
EVENT_WS_MESSAGE, EVENT_WS_MESSAGE,
} from "@goauthentik/common/constants"; } from "@goauthentik/common/constants";
import { configureSentry } from "@goauthentik/common/sentry"; import { configureSentry } from "@goauthentik/common/sentry";
import { UserDisplay } from "@goauthentik/common/ui/config"; import { UIConfig, UserDisplay } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { first } from "@goauthentik/common/utils";
import { WebsocketClient } from "@goauthentik/common/ws"; import { WebsocketClient } from "@goauthentik/common/ws";
import { AKElement } from "@goauthentik/elements/Base";
import { EnterpriseAwareInterface } from "@goauthentik/elements/Interface"; import { EnterpriseAwareInterface } from "@goauthentik/elements/Interface";
import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
@ -23,9 +23,10 @@ import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
import "@goauthentik/elements/sidebar/SidebarItem"; import "@goauthentik/elements/sidebar/SidebarItem";
import { ROUTES } from "@goauthentik/user/Routes"; import { ROUTES } from "@goauthentik/user/Routes";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { match } from "ts-pattern";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css"; import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
@ -38,25 +39,86 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import { CoreApi, EventsApi, SessionUser } from "@goauthentik/api"; import { CoreApi, CurrentBrand, EventsApi, SessionUser } from "@goauthentik/api";
@customElement("ak-interface-user") const customStyles = css`
export class UserInterface extends EnterpriseAwareInterface { .pf-c-page__main,
@property({ type: Boolean }) .pf-c-drawer__content,
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false); .pf-c-page__drawer {
z-index: auto !important;
background-color: transparent !important;
}
.pf-c-page__header {
background-color: transparent !important;
box-shadow: none !important;
color: black !important;
}
:host([theme="dark"]) .pf-c-page__header {
color: var(--ak-dark-foreground) !important;
}
:host([theme="light"]) .pf-c-page__header-tools-item .fas,
:host([theme="light"]) .pf-c-notification-badge__count,
:host([theme="light"]) .pf-c-page__header-tools-group .pf-c-button {
color: var(--ak-global--Color--100) !important;
}
.pf-c-page {
background-color: transparent;
}
.display-none {
display: none;
}
.pf-c-brand {
min-height: 32px;
height: 32px;
}
.has-notifications {
color: #2b9af3;
}
.background-wrapper {
height: 100vh;
width: 100%;
position: fixed;
z-index: -1;
top: 0;
left: 0;
background-color: var(--pf-c-page--BackgroundColor) !important;
}
.background-default-slant {
background-color: white; /*var(--ak-accent);*/
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 calc(100% - 5vw));
height: 50vh;
}
:host([theme="dark"]) .background-default-slant {
background-color: black;
}
ak-locale-context {
display: flex;
flex-direction: column;
}
.pf-c-drawer__main {
min-height: calc(100vh - 76px);
max-height: calc(100vh - 76px);
}
`;
@property({ type: Boolean }) // ___ _ _ _
apiDrawerOpen = getURLParam("apiDrawerOpen", false); // | _ \_ _ ___ ___ ___ _ _| |_ __ _| |_(_)___ _ _
// | _/ '_/ -_|_-</ -_) ' \ _/ _` | _| / _ \ ' \
// |_| |_| \___/__/\___|_||_\__\__,_|\__|_\___/_||_|
//
ws: WebsocketClient; // Despite the length of the render() method and its accessories, this top-level Interface does
// surprisingly little. It has been broken into two parts: the business logic at the bottom, and the
// rendering code at the top, which is wholly independent of APIs and Interfaces.
@property({ type: Number }) // Because this is not exported, and because it's invoked as a web component, neither TSC or ESLint
notificationsCount = 0; // trusts that we actually used it. Hence the double ignore below:
@state() @customElement("ak-interface-user-presentation")
me?: SessionUser; // @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static get styles(): CSSResult[] { class UserInterfacePresentation extends AKElement {
static get styles() {
return [ return [
PFBase, PFBase,
PFDisplay, PFDisplay,
@ -67,122 +129,57 @@ export class UserInterface extends EnterpriseAwareInterface {
PFDrawer, PFDrawer,
PFDropdown, PFDropdown,
PFNotificationBadge, PFNotificationBadge,
css` customStyles,
.pf-c-page__main,
.pf-c-drawer__content,
.pf-c-page__drawer {
z-index: auto !important;
background-color: transparent !important;
}
.pf-c-page__header {
background-color: transparent !important;
box-shadow: none !important;
color: black !important;
}
:host([theme="dark"]) .pf-c-page__header {
color: var(--ak-dark-foreground) !important;
}
:host([theme="light"]) .pf-c-page__header-tools-item .fas,
:host([theme="light"]) .pf-c-notification-badge__count,
:host([theme="light"]) .pf-c-page__header-tools-group .pf-c-button {
color: var(--ak-global--Color--100) !important;
}
.pf-c-page {
background-color: transparent;
}
.display-none {
display: none;
}
.pf-c-brand {
min-height: 32px;
height: 32px;
}
.has-notifications {
color: #2b9af3;
}
.background-wrapper {
height: 100vh;
width: 100%;
position: fixed;
z-index: -1;
top: 0;
left: 0;
background-color: var(--pf-c-page--BackgroundColor) !important;
}
.background-default-slant {
background-color: white; /*var(--ak-accent);*/
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 calc(100% - 5vw));
height: 50vh;
}
:host([theme="dark"]) .background-default-slant {
background-color: black;
}
ak-locale-context {
display: flex;
flex-direction: column;
}
.pf-c-drawer__main {
min-height: calc(100vh - 76px);
max-height: calc(100vh - 76px);
}
`,
]; ];
} }
constructor() { @property({ type: Object })
super(); uiConfig!: UIConfig;
this.ws = new WebsocketClient();
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => { @property({ type: Object })
this.notificationDrawerOpen = !this.notificationDrawerOpen; me!: SessionUser;
updateURLParams({
notificationDrawerOpen: this.notificationDrawerOpen, @property({ type: Boolean, reflect: true })
}); notificationDrawerOpen = false;
});
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => { @property({ type: Boolean, reflect: true })
this.apiDrawerOpen = !this.apiDrawerOpen; apiDrawerOpen = false;
updateURLParams({
apiDrawerOpen: this.apiDrawerOpen, @property({ type: Number })
}); notificationsCount = 0;
});
window.addEventListener(EVENT_WS_MESSAGE, () => { @property({ type: Object })
this.firstUpdated(); brand!: CurrentBrand;
});
configureSentry(true); get userDisplayName() {
return match<UserDisplay, string>(this.uiConfig.navbar.userDisplay)
.with(UserDisplay.username, () => this.me.user.username)
.with(UserDisplay.name, () => this.me.user.name)
.with(UserDisplay.email, () => this.me.user.email || "")
.otherwise(() => this.me.user.username);
} }
async firstUpdated(): Promise<void> { get canAccessAdmin() {
this.me = await me(); return (
const notifications = await new EventsApi(DEFAULT_CONFIG).eventsNotificationsList({
seen: false,
ordering: "-created",
pageSize: 1,
user: this.me.user.pk,
});
this.notificationsCount = notifications.pagination.count;
}
render(): TemplateResult {
if (!this.uiConfig || !this.me) {
return html``;
}
let userDisplay = "";
switch (this.uiConfig.navbar.userDisplay) {
case UserDisplay.username:
userDisplay = this.me.user.username;
break;
case UserDisplay.name:
userDisplay = this.me.user.name;
break;
case UserDisplay.email:
userDisplay = this.me.user.email || "";
break;
default:
userDisplay = this.me.user.username;
}
const canAccessAdmin =
this.me.user.isSuperuser || this.me.user.isSuperuser ||
// TODO: somehow add `access_admin_interface` to the API schema // TODO: somehow add `access_admin_interface` to the API schema
this.me.user.systemPermissions.includes("access_admin_interface"); this.me.user.systemPermissions.includes("access_admin_interface")
);
}
get isFullyConfigured() {
return !!(this.uiConfig && this.me && this.brand);
}
render() {
// The `!` in the field definitions above only re-assure typescript and eslint that the
// values *should* be available, not that they *are*. Thus this contract check; it asserts
// that the contract we promised is being honored, and the rest of the code that depends on
// `!` being truthful is not being lied to.
if (!this.isFullyConfigured) {
throw new Error("ak-interface-user-presentation misused; no valid values passed");
}
return html` <ak-locale-context> return html` <ak-locale-context>
<ak-enterprise-status interface="user"></ak-enterprise-status> <ak-enterprise-status interface="user"></ak-enterprise-status>
<div class="pf-c-page"> <div class="pf-c-page">
@ -196,85 +193,18 @@ export class UserInterface extends EnterpriseAwareInterface {
<a href="#/" class="pf-c-page__header-brand-link"> <a href="#/" class="pf-c-page__header-brand-link">
<img <img
class="pf-c-brand" class="pf-c-brand"
src="${first(this.brand?.brandingLogo, DefaultBrand.brandingLogo)}" src="${this.brand.brandingLogo}"
alt="${(this.brand?.brandingTitle, DefaultBrand.brandingTitle)}" alt="${this.brand.brandingTitle}"
/> />
</a> </a>
</div> </div>
<div class="pf-c-page__header-tools"> <div class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group"> <div class="pf-c-page__header-tools-group">
${this.uiConfig.enabledFeatures.apiDrawer ${this.renderApiDrawerTrigger()}
? html`<div <!-- -->
class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg" ${this.renderNotificationDrawerTrigger()}
> <!-- -->
<button ${this.renderSettings()}
class="pf-c-button pf-m-plain"
type="button"
@click=${() => {
this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({
apiDrawerOpen: this.apiDrawerOpen,
});
}}
>
<pf-tooltip
position="top"
content=${msg("Open API drawer")}
>
<i class="fas fa-code" aria-hidden="true"></i>
</pf-tooltip>
</button>
</div>`
: html``}
${this.uiConfig.enabledFeatures.notificationDrawer
? html`<div
class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg"
>
<button
class="pf-c-button pf-m-plain"
type="button"
aria-label="${msg("Unread notifications")}"
@click=${() => {
this.notificationDrawerOpen =
!this.notificationDrawerOpen;
updateURLParams({
notificationDrawerOpen:
this.notificationDrawerOpen,
});
}}
>
<span
class="pf-c-notification-badge ${this
.notificationsCount > 0
? "pf-m-unread"
: ""}"
>
<pf-tooltip
position="top"
content=${msg("Open Notification drawer")}
>
<i class="fas fa-bell" aria-hidden="true"></i>
</pf-tooltip>
<span class="pf-c-notification-badge__count"
>${this.notificationsCount}</span
>
</span>
</button>
</div> `
: html``}
${this.uiConfig.enabledFeatures.settings
? html` <div class="pf-c-page__header-tools-item">
<a
class="pf-c-button pf-m-plain"
type="button"
href="#/settings"
>
<pf-tooltip position="top" content=${msg("Settings")}>
<i class="fas fa-cog" aria-hidden="true"></i>
</pf-tooltip>
</a>
</div>`
: html``}
<div class="pf-c-page__header-tools-item"> <div class="pf-c-page__header-tools-item">
<a <a
href="/flows/-/default/invalidation/" href="/flows/-/default/invalidation/"
@ -285,39 +215,14 @@ export class UserInterface extends EnterpriseAwareInterface {
</pf-tooltip> </pf-tooltip>
</a> </a>
</div> </div>
${canAccessAdmin ${this.renderAdminInterfaceLink()}
? html`<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="/if/admin/"
>
${msg("Admin interface")}
</a>`
: html``}
</div> </div>
${this.me.original ${this.renderImpersonation()}
? html`&nbsp;
<div class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group">
<ak-action-button
class="pf-m-warning pf-m-small"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateEndRetrieve()
.then(() => {
window.location.reload();
});
}}
>
${msg("Stop impersonation")}
</ak-action-button>
</div>
</div>`
: html``}
<div class="pf-c-page__header-tools-group"> <div class="pf-c-page__header-tools-group">
<div <div
class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md" class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md"
> >
${userDisplay} ${this.userDisplayName}
</div> </div>
</div> </div>
<img <img
@ -368,4 +273,202 @@ export class UserInterface extends EnterpriseAwareInterface {
</div> </div>
</ak-locale-context>`; </ak-locale-context>`;
} }
renderApiDrawerTrigger() {
if (!this.uiConfig.enabledFeatures.apiDrawer) {
return nothing;
}
const onClick = (ev: Event) => {
ev.stopPropagation();
this.dispatchEvent(
new Event(EVENT_API_DRAWER_TOGGLE, { bubbles: true, composed: true }),
);
};
return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg">
<button class="pf-c-button pf-m-plain" type="button" @click=${onClick}>
<pf-tooltip position="top" content=${msg("Open API drawer")}>
<i class="fas fa-code" aria-hidden="true"></i>
</pf-tooltip>
</button>
</div>`;
}
renderNotificationDrawerTrigger() {
if (!this.uiConfig.enabledFeatures.notificationDrawer) {
return nothing;
}
const onClick = (ev: Event) => {
ev.stopPropagation();
this.dispatchEvent(
new Event(EVENT_NOTIFICATION_DRAWER_TOGGLE, { bubbles: true, composed: true }),
);
};
return html`<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-lg">
<button
class="pf-c-button pf-m-plain"
type="button"
aria-label="${msg("Unread notifications")}"
@click=${onClick}
>
<span
class="pf-c-notification-badge ${this.notificationsCount > 0
? "pf-m-unread"
: ""}"
>
<pf-tooltip position="top" content=${msg("Open Notification drawer")}>
<i class="fas fa-bell" aria-hidden="true"></i>
</pf-tooltip>
<span class="pf-c-notification-badge__count">${this.notificationsCount}</span>
</span>
</button>
</div> `;
}
renderSettings() {
if (!this.uiConfig.enabledFeatures.settings) {
return nothing;
}
return html` <div class="pf-c-page__header-tools-item">
<a class="pf-c-button pf-m-plain" type="button" href="#/settings">
<pf-tooltip position="top" content=${msg("Settings")}>
<i class="fas fa-cog" aria-hidden="true"></i>
</pf-tooltip>
</a>
</div>`;
}
renderAdminInterfaceLink() {
if (!this.canAccessAdmin) {
return nothing;
}
return html`<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="/if/admin/"
>
${msg("Admin interface")}
</a>`;
}
renderImpersonation() {
if (!this.me.original) {
return nothing;
}
const onClick = () => {
return new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
window.location.reload();
});
};
return html`&nbsp;
<div class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group">
<ak-action-button class="pf-m-warning pf-m-small" .apiRequest=${onClick}>
${msg("Stop impersonation")}
</ak-action-button>
</div>
</div>`;
}
}
// ___ _
// | _ )_ _ __(_)_ _ ___ ______
// | _ \ || (_-< | ' \/ -_|_-<_-<
// |___/\_,_/__/_|_||_\___/__/__/
//
//
@customElement("ak-interface-user")
export class UserInterface extends EnterpriseAwareInterface {
@property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@state()
apiDrawerOpen = getURLParam("apiDrawerOpen", false);
ws: WebsocketClient;
@state()
notificationsCount = 0;
@state()
me?: SessionUser;
constructor() {
super();
this.ws = new WebsocketClient();
this.fetchConfigurationDetails();
configureSentry(true);
this.toggleNotificationDrawer = this.toggleNotificationDrawer.bind(this);
this.toggleApiDrawer = this.toggleApiDrawer.bind(this);
this.fetchConfigurationDetails = this.fetchConfigurationDetails.bind(this);
}
connectedCallback() {
super.connectedCallback();
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
}
disconnectedCallback() {
window.removeEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
window.removeEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
window.removeEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
super.disconnectedCallback();
}
toggleNotificationDrawer() {
this.notificationDrawerOpen = !this.notificationDrawerOpen;
updateURLParams({
notificationDrawerOpen: this.notificationDrawerOpen,
});
}
toggleApiDrawer() {
this.apiDrawerOpen = !this.apiDrawerOpen;
updateURLParams({
apiDrawerOpen: this.apiDrawerOpen,
});
}
fetchConfigurationDetails() {
me().then((me: SessionUser) => {
this.me = me;
new EventsApi(DEFAULT_CONFIG)
.eventsNotificationsList({
seen: false,
ordering: "-created",
pageSize: 1,
user: this.me.user.pk,
})
.then((notifications) => {
this.notificationsCount = notifications.pagination.count;
});
});
}
get isFullyConfigured() {
return !!(this.uiConfig && this.me);
}
render() {
if (!this.isFullyConfigured) {
return nothing;
}
return html`<ak-interface-user-presentation
.uiConfig=${this.uiConfig}
.me=${this.me}
.brand=${this.brand ?? DefaultBrand}
?notificationDrawerOpen=${this.notificationDrawerOpen}
?apiDrawerOpen=${this.apiDrawerOpen}
notificationsCount=${this.notificationsCount}
></ak-interface-user-presentation>`;
}
} }