This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
authentik/web/src/admin/users/UserListPage.ts
Ken Sternberg 73751e5cd9
web: refactor status label to separate component (#7407)
* web: break circular dependency between AKElement & Interface.

This commit changes the way the root node of the web application shell is
discovered by child components, such that the base class shared by both
no longer results in a circular dependency between the two models.

I've run this in isolation and have seen no failures of discovery; the identity
token exists as soon as the Interface is constructed and is found by every item
on the page.

* web: fix broken typescript references

This built... and then it didn't?  Anyway, the current fix is to
provide type information the AkInterface for the data that consumers
require.

* A quality of life thing: `<ak-status-label good>`

There's an idiom throughout the UI:

``` HTML
<ak-label color=${item.enabled ? PFColor.Green : PFColor.Red}>
      ${item.enabled ? msg("Yes") : msg("No")}
      </ak-label>
```

There are two problems with this.

- Repeating the conditional multiple times is error-prone
- The color scheme doesn't communicate much.

There are uses for ak-label that aren't like this, but I'm focusing on this particular use case,
which occurs about 20 times throughout the UI.

Since it's so common, let's isolate the most common case: `<ak-status-label good />` gives you the
"good" status, and `<ak-status-label/>` gives you the "bad" status, which is the default (no
arguments to the function).

There wasn't much clarity in the system for when to use orange vs red vs grey, but looking through
the use cases, it became clear that Red meant fail/inaccessible, Orange meant "Warning, but not
blocking," and Grey just means "info: this thing is off".

So let's define that with meaning: there are three types, error, warning, and info. Which
corresponds to debugging levels, but whatever, nerds grok that stuff.

So that example at the top becomes

```<ak-status-label ?good=${item.enabled}></ak-status-label>```

... and we can now more clearly understand what that conveys.

There is some heavy tension in this case: this is an easier and quicker-to-write solution to
informing the user of a binary status in an iconic way, but the developer has to remember that it
exists.

Story provided, and changes to the existing uses of the existing idiom provided.

* Added the 'compact label' story to storybook.
2023-11-20 11:24:48 -08:00

422 lines
18 KiB
TypeScript

import { AdminInterface } from "@goauthentik/admin/AdminInterface";
import "@goauthentik/admin/users/ServiceAccountForm";
import "@goauthentik/admin/users/UserActiveForm";
import "@goauthentik/admin/users/UserForm";
import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm";
import { me } from "@goauthentik/app/common/users";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { userTypeToLabel } from "@goauthentik/common/labels";
import { MessageLevel } from "@goauthentik/common/messages";
import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label";
import { rootInterface } from "@goauthentik/elements/Base";
import { PFSize } from "@goauthentik/elements/Spinner";
import "@goauthentik/elements/TreeView";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
import { writeToClipboard } from "@goauthentik/elements/utils/writeToClipboard";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import {
CapabilitiesEnum,
CoreApi,
ResponseError,
SessionUser,
User,
UserPath,
} from "@goauthentik/api";
export const requestRecoveryLink = (user: User) =>
new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryRetrieve({
id: user.pk,
})
.then((rec) =>
writeToClipboard(rec.link).then((wroteToClipboard) =>
showMessage({
level: MessageLevel.success,
message: rec.link,
description: wroteToClipboard
? msg("A copy of this recovery link has been placed in your clipboard")
: "",
}),
),
)
.catch((ex: ResponseError) =>
ex.response.json().then(() =>
showMessage({
level: MessageLevel.error,
message: msg(
"The current tenant must have a recovery flow configured to use a recovery link",
),
}),
),
);
export const renderRecoveryEmailRequest = (user: User) =>
html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request">
<span slot="submit"> ${msg("Send link")} </span>
<span slot="header"> ${msg("Send recovery link to user")} </span>
<ak-user-reset-email-form slot="form" .user=${user}> </ak-user-reset-email-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Email recovery link")}
</button>
</ak-forms-modal>`;
const recoveryButtonStyles = css`
#recovery-request-buttons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.375rem;
}
#recovery-request-buttons > *,
#update-password-request .pf-c-button {
margin: 0;
}
`;
@customElement("ak-user-list")
export class UserListPage extends TablePage<User> {
expandable = true;
checkbox = true;
searchEnabled(): boolean {
return true;
}
pageTitle(): string {
return msg("Users");
}
pageDescription(): string {
return "";
}
pageIcon(): string {
return "pf-icon pf-icon-user";
}
@property()
order = "last_login";
@property()
activePath;
@state()
hideDeactivated = getURLParam<boolean>("hideDeactivated", false);
@state()
userPaths?: UserPath;
@state()
me?: SessionUser;
static get styles(): CSSResult[] {
return [...super.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
}
constructor() {
super();
this.activePath = getURLParam<string>("path", "/");
uiConfig().then((c) => {
if (c.defaults.userPath !== new DefaultUIConfig().defaults.userPath) {
this.activePath = c.defaults.userPath;
}
});
}
async apiEndpoint(page: number): Promise<PaginatedResponse<User>> {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
ordering: this.order,
page: page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
pathStartswith: getURLParam("path", ""),
isActive: this.hideDeactivated ? true : undefined,
});
this.userPaths = await new CoreApi(DEFAULT_CONFIG).coreUsersPathsRetrieve({
search: this.search,
});
this.me = await me();
return users;
}
columns(): TableColumn[] {
return [
new TableColumn(msg("Name"), "username"),
new TableColumn(msg("Active"), "is_active"),
new TableColumn(msg("Last login"), "last_login"),
new TableColumn(msg("Actions")),
];
}
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
const currentUser = rootInterface<AdminInterface>()?.user;
const shouldShowWarning = this.selectedElements.find((el) => {
return el.pk === currentUser?.user.pk || el.pk == currentUser?.original?.pk;
});
return html`<ak-forms-delete-bulk
objectLabel=${msg("User(s)")}
.objects=${this.selectedElements}
.metadata=${(item: User) => {
return [
{ key: msg("Username"), value: item.username },
{ key: msg("ID"), value: item.pk.toString() },
{ key: msg("UID"), value: item.uid },
];
}}
.usedBy=${(item: User) => {
return new CoreApi(DEFAULT_CONFIG).coreUsersUsedByList({
id: item.pk,
});
}}
.delete=${(item: User) => {
return new CoreApi(DEFAULT_CONFIG).coreUsersDestroy({
id: item.pk,
});
}}
>
${shouldShowWarning
? html`<div slot="notice" class="pf-c-form__alert">
<div class="pf-c-alert pf-m-inline pf-m-warning">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
</div>
<h4 class="pf-c-alert__title">
${msg(
str`Warning: You're about to delete the user you're logged in as (${shouldShowWarning.username}). Proceed at your own risk.`,
)}
</h4>
</div>
</div>`
: html``}
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${msg("Delete")}
</button>
</ak-forms-delete-bulk>`;
}
renderToolbarAfter(): TemplateResult {
return html`&nbsp;
<div class="pf-c-toolbar__group pf-m-filter-group">
<div class="pf-c-toolbar__item pf-m-search-filter">
<div class="pf-c-input-group">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${this.hideDeactivated}
@change=${() => {
this.hideDeactivated = !this.hideDeactivated;
this.page = 1;
this.fetch();
updateURLParams({
hideDeactivated: this.hideDeactivated,
});
}}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Hide deactivated user")}</span>
</label>
</div>
</div>
</div>`;
}
row(item: User): TemplateResult[] {
const canImpersonate =
rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) &&
item.pk !== this.me?.user.pk;
return [
html`<a href="#/identity/users/${item.pk}">
<div>${item.username}</div>
<small>${item.name === "" ? msg("<No name set>") : item.name}</small> </a
>&nbsp;<small>${userTypeToLabel(item.type)}</small>`,
html`<ak-status-label ?good=${item.isActive}></ak-status-label>`,
html`${first(item.lastLogin?.toLocaleString(), msg("-"))}`,
html`<ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update User")} </span>
<ak-user-form slot="form" .instancePk=${item.pk}> </ak-user-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit"></i>
</pf-tooltip>
</button>
</ak-forms-modal>
${canImpersonate
? html`
<ak-action-button
class="pf-m-tertiary"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({
id: item.pk,
})
.then(() => {
window.location.href = "/";
});
}}
>
${msg("Impersonate")}
</ak-action-button>
`
: html``}`,
];
}
renderExpanded(item: User): TemplateResult {
return html`<td role="cell" colspan="3">
<div class="pf-c-table__expandable-row-content">
<dl class="pf-c-description-list pf-m-horizontal">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text"
>${msg("User status")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
${item.isActive ? msg("Active") : msg("Inactive")}
</div>
<div class="pf-c-description-list__text">
${item.isSuperuser ? msg("Superuser") : msg("Regular user")}
</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("Change status")}</span
>
</dt>
<dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">
<ak-user-active-form
.obj=${item}
objectLabel=${msg("User")}
.delete=${() => {
return new CoreApi(
DEFAULT_CONFIG,
).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>
</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("Recovery")}</span>
</dt>
<dd class="pf-c-description-list__description">
<div
class="pf-c-description-list__text"
id="recovery-request-buttons"
>
<ak-forms-modal
size=${PFSize.Medium}
id="update-password-request"
>
<span slot="submit">${msg("Update password")}</span>
<span slot="header">${msg("Update password")}</span>
<ak-user-password-form
slot="form"
.instancePk=${item.pk}
></ak-user-password-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Set password")}
</button>
</ak-forms-modal>
${rootInterface()?.tenant?.flowRecovery
? html`
<ak-action-button
class="pf-m-secondary"
.apiRequest=${() => requestRecoveryLink(item)}
>
${msg("Create recovery link")}
</ak-action-button>
${item.email
? renderRecoveryEmailRequest(item)
: html`<span
>${msg(
"Recovery link cannot be emailed, user has no email address saved.",
)}</span
>`}
`
: html` <p>
${msg(
"To let a user directly reset a their password, configure a recovery flow on the currently active tenant.",
)}
</p>`}
</div>
</dd>
</div>
</dl>
</div>
</td>
<td></td>
<td></td>`;
}
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create User")} </span>
<ak-user-form slot="form"> </ak-user-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>
<ak-forms-modal .closeAfterSuccessfulSubmit=${false} .cancelText=${msg("Close")}>
<span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Service account")} </span>
<ak-user-service-account-form slot="form"> </ak-user-service-account-form>
<button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Create Service account")}
</button>
</ak-forms-modal>
`;
}
renderSidebarBefore(): TemplateResult {
return html`<div class="pf-c-sidebar__panel pf-m-width-25">
<div class="pf-c-card">
<div class="pf-c-card__title">${msg("User folders")}</div>
<div class="pf-c-card__body">
<ak-treeview
.items=${this.userPaths?.paths || []}
activePath=${this.activePath}
></ak-treeview>
</div>
</div>
</div>`;
}
}