web/admin: improve user email button labels (#7233)

* web: isolate clipboard handling

We would like to use the clipboard for more than just the token copy button.  This commit
enables that by separating the "Write to Clipboard" and "Write to Notifications" routines
into separate functions, putting "writeToClipboard" into the utilities collection, and
clarifying what happens when a custom presses the TokenCopy button.

* web: break out the recovery link logic into a standalone function

UserViewPage and UserLinkPage have the same functionality to request to view a
link with which a user may access an account recovery flow.  The language and
error messages were different on both of those pages.  This commit harmonizes
the language by making the request a standalone function.  It also exploits the
breakout of the "write to clipboard" commit to write the link to the clipboard,
and to inform the user that the clipboard has been written to, when possible.

* web: parity between UserViewPage and UserListPage

Since the UserListPage's "accordion" view has an offer to "Email
the recovery link" to the user, it seemed appropriate to grant the
same capability to the UserListPage.

* web: harmonize the CSS.

After a bit of messing around, I have also ensured that the gap between the buttons is
the same in all cases, that in the columnar display the buttons are of uniform width,
and that the buttons have the same next:

- "Set Password"
- "View Recovery Link"
- "Email Recovery Link"

NOTE: This commit is contingent upon the PR for [isolate clipboard
handling](https://github.com/goauthentik/authentik/pull/7229) to
be accepted, as it relies on the clipboard handler for the "write
link to clipboard" feature.

* web: ensure the existence of the user

Every `...render()` method in the UserViewPage class has a preamble
guard clause:

```
    if (!this.user) {
        return html``;
    }
```

With this clause, it should not be necessary to repeatedly check
the type of `this.user` throughout the rest of the method, but the
nominal type is `User?`, which means that functions called from
within the method need to be protected against `undefined` failure.
By creating a new variable with the type after the guard clause,
we ensure the type is `User` (no question!) and can safely use it
without those checks.

Along the way, I replaced the empty html with `nothing` and corrected
(mostly by removing) the return types.

References:

- [Lit-HTML: Prefer `nothing` over empty html or other falsey walues](https://lit.dev/docs/api/templates/#nothing)
- [TypeScript: Type annotations on return types are rarely
necessary](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#:~:text=Return%20Type%20Annotations&text=Much%20like%20variable%20type%20annotations,example%20doesn't%20change%20anything.)

* web: accepting suggested label change
This commit is contained in:
Ken Sternberg 2023-10-20 10:01:18 -07:00 committed by GitHub
parent cbbb638ca7
commit cc781cad00
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 115 additions and 108 deletions

View File

@ -21,10 +21,11 @@ import { getURLParam, updateURLParams } from "@goauthentik/elements/router/Route
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
import { writeToClipboard } from "@goauthentik/elements/utils/writeToClipboard";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
@ -40,6 +41,56 @@ import {
UserPath, UserPath,
} from "@goauthentik/api"; } 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") @customElement("ak-user-list")
export class UserListPage extends TablePage<User> { export class UserListPage extends TablePage<User> {
expandable = true; expandable = true;
@ -74,7 +125,7 @@ export class UserListPage extends TablePage<User> {
me?: SessionUser; me?: SessionUser;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return super.styles.concat(PFDescriptionList, PFCard, PFAlert); return [...super.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles];
} }
constructor() { constructor() {
@ -287,8 +338,14 @@ export class UserListPage extends TablePage<User> {
<span class="pf-c-description-list__text">${msg("Recovery")}</span> <span class="pf-c-description-list__text">${msg("Recovery")}</span>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div
<ak-forms-modal size=${PFSize.Medium}> 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="submit">${msg("Update password")}</span>
<span slot="header">${msg("Update password")}</span> <span slot="header">${msg("Update password")}</span>
<ak-user-password-form <ak-user-password-form
@ -303,56 +360,12 @@ export class UserListPage extends TablePage<User> {
? html` ? html`
<ak-action-button <ak-action-button
class="pf-m-secondary" class="pf-m-secondary"
.apiRequest=${() => { .apiRequest=${() => requestRecoveryLink(item)}
return new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryRetrieve({
id: item.pk,
})
.then((rec) => {
showMessage({
level: MessageLevel.success,
message: msg(
"Successfully generated recovery link",
),
description: rec.link,
});
})
.catch((ex: ResponseError) => {
ex.response.json().then(() => {
showMessage({
level: MessageLevel.error,
message: msg(
"No recovery flow is configured.",
),
});
});
});
}}
> >
${msg("Copy recovery link")} ${msg("Create recovery link")}
</ak-action-button> </ak-action-button>
${item.email ${item.email
? html`<ak-forms-modal ? renderRecoveryEmailRequest(item)
.closeAfterSuccessfulSubmit=${false}
>
<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=${item}
>
</ak-user-reset-email-form>
<button
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${msg("Email recovery link")}
</button>
</ak-forms-modal>`
: html`<span : html`<span
>${msg( >${msg(
"Recovery link cannot be emailed, user has no email address saved.", "Recovery link cannot be emailed, user has no email address saved.",

View File

@ -5,11 +5,14 @@ import "@goauthentik/admin/users/UserForm";
import "@goauthentik/admin/users/UserPasswordForm"; import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/app/admin/users/UserAssignedGlobalPermissionsTable"; import "@goauthentik/app/admin/users/UserAssignedGlobalPermissionsTable";
import "@goauthentik/app/admin/users/UserAssignedObjectPermissionsTable"; import "@goauthentik/app/admin/users/UserAssignedObjectPermissionsTable";
import {
renderRecoveryEmailRequest,
requestRecoveryLink,
} from "@goauthentik/app/admin/users/UserListPage";
import { me } from "@goauthentik/app/common/users"; import { me } from "@goauthentik/app/common/users";
import "@goauthentik/app/elements/rbac/ObjectPermissionsPage"; import "@goauthentik/app/elements/rbac/ObjectPermissionsPage";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants"; import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { MessageLevel } from "@goauthentik/common/messages";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import "@goauthentik/components/events/UserEvents"; import "@goauthentik/components/events/UserEvents";
import { AKElement, rootInterface } from "@goauthentik/elements/Base"; import { AKElement, rootInterface } from "@goauthentik/elements/Base";
@ -21,13 +24,12 @@ import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/oauth/UserRefreshList"; import "@goauthentik/elements/oauth/UserRefreshList";
import "@goauthentik/elements/user/SessionList"; import "@goauthentik/elements/user/SessionList";
import "@goauthentik/elements/user/UserConsentList"; import "@goauthentik/elements/user/UserConsentList";
import { msg, str } from "@lit/localize"; import { msg, str } 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 PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -72,7 +74,7 @@ export class UserViewPage extends AKElement {
@state() @state()
me?: SessionUser; me?: SessionUser;
static get styles(): CSSResult[] { static get styles() {
return [ return [
PFBase, PFBase,
PFPage, PFPage,
@ -84,12 +86,24 @@ export class UserViewPage extends AKElement {
PFDescriptionList, PFDescriptionList,
PFSizing, PFSizing,
css` css`
.pf-c-description-list__description ak-action-button {
margin-right: 6px;
margin-bottom: 6px;
}
.ak-button-collection { .ak-button-collection {
max-width: 12em; display: flex;
flex-direction: column;
gap: 0.375rem;
max-width: 12rem;
}
.ak-button-collection > * {
flex: 1 0 100%;
}
#reset-password-button {
margin-right: 0;
}
#ak-email-recovery-request,
#update-password-request .pf-c-button,
#ak-email-recovery-request .pf-c-button {
margin: 0;
width: 100%;
} }
`, `,
]; ];
@ -103,7 +117,7 @@ export class UserViewPage extends AKElement {
}); });
} }
render(): TemplateResult { render() {
return html`<ak-page-header return html`<ak-page-header
icon="pf-icon pf-icon-user" icon="pf-icon pf-icon-user"
header=${msg(str`User ${this.user?.username || ""}`)} header=${msg(str`User ${this.user?.username || ""}`)}
@ -113,13 +127,17 @@ export class UserViewPage extends AKElement {
${this.renderBody()}`; ${this.renderBody()}`;
} }
renderUserCard(): TemplateResult { renderUserCard() {
if (!this.user) { if (!this.user) {
return html``; return nothing;
} }
const user = this.user;
const canImpersonate = const canImpersonate =
rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) &&
this.user.pk !== this.me?.user.pk; this.user.pk !== this.me?.user.pk;
return html` return html`
<div class="pf-c-card__title">${msg("User Info")}</div> <div class="pf-c-card__title">${msg("User Info")}</div>
<div class="pf-c-card__body"> <div class="pf-c-card__body">
@ -129,7 +147,7 @@ export class UserViewPage extends AKElement {
<span class="pf-c-description-list__text">${msg("Username")}</span> <span class="pf-c-description-list__text">${msg("Username")}</span>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${this.user.username}</div> <div class="pf-c-description-list__text">${user.username}</div>
</dd> </dd>
</div> </div>
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
@ -137,7 +155,7 @@ export class UserViewPage extends AKElement {
<span class="pf-c-description-list__text">${msg("Name")}</span> <span class="pf-c-description-list__text">${msg("Name")}</span>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${this.user.name}</div> <div class="pf-c-description-list__text">${user.name}</div>
</dd> </dd>
</div> </div>
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
@ -145,7 +163,7 @@ export class UserViewPage extends AKElement {
<span class="pf-c-description-list__text">${msg("Email")}</span> <span class="pf-c-description-list__text">${msg("Email")}</span>
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text">${this.user.email || "-"}</div> <div class="pf-c-description-list__text">${user.email || "-"}</div>
</dd> </dd>
</div> </div>
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
@ -154,7 +172,7 @@ export class UserViewPage extends AKElement {
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
${this.user.lastLogin?.toLocaleString()} ${user.lastLogin?.toLocaleString()}
</div> </div>
</dd> </dd>
</div> </div>
@ -165,7 +183,7 @@ export class UserViewPage extends AKElement {
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
<ak-label <ak-label
color=${this.user.isActive ? PFColor.Green : PFColor.Orange} color=${user.isActive ? PFColor.Green : PFColor.Orange}
></ak-label> ></ak-label>
</div> </div>
</dd> </dd>
@ -177,7 +195,7 @@ export class UserViewPage extends AKElement {
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
<ak-label <ak-label
color=${this.user.isSuperuser ? PFColor.Green : PFColor.Orange} color=${user.isSuperuser ? PFColor.Green : PFColor.Orange}
></ak-label> ></ak-label>
</div> </div>
</dd> </dd>
@ -191,7 +209,7 @@ export class UserViewPage extends AKElement {
<ak-forms-modal> <ak-forms-modal>
<span slot="submit"> ${msg("Update")} </span> <span slot="submit"> ${msg("Update")} </span>
<span slot="header"> ${msg("Update User")} </span> <span slot="header"> ${msg("Update User")} </span>
<ak-user-form slot="form" .instancePk=${this.user.pk}> <ak-user-form slot="form" .instancePk=${user.pk}>
</ak-user-form> </ak-user-form>
<button <button
slot="trigger" slot="trigger"
@ -201,13 +219,13 @@ export class UserViewPage extends AKElement {
</button> </button>
</ak-forms-modal> </ak-forms-modal>
<ak-user-active-form <ak-user-active-form
.obj=${this.user} .obj=${user}
objectLabel=${msg("User")} objectLabel=${msg("User")}
.delete=${() => { .delete=${() => {
return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({ return new CoreApi(DEFAULT_CONFIG).coreUsersPartialUpdate({
id: this.user?.pk || 0, id: user.pk,
patchedUserRequest: { patchedUserRequest: {
isActive: !this.user?.isActive, isActive: !user.isActive,
}, },
}); });
}} }}
@ -218,15 +236,13 @@ export class UserViewPage extends AKElement {
> >
<pf-tooltip <pf-tooltip
position="top" position="top"
content=${this.user.isActive content=${user.isActive
? msg("Lock the user out of this system") ? msg("Lock the user out of this system")
: msg( : msg(
"Allow the user to log in and use this system", "Allow the user to log in and use this system",
)} )}
> >
${this.user.isActive ${user.isActive ? msg("Deactivate") : msg("Activate")}
? msg("Deactivate")
: msg("Activate")}
</pf-tooltip> </pf-tooltip>
</button> </button>
</ak-user-active-form> </ak-user-active-form>
@ -238,7 +254,7 @@ export class UserViewPage extends AKElement {
.apiRequest=${() => { .apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG) return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({ .coreUsersImpersonateCreate({
id: this.user?.pk || 0, id: user.pk,
}) })
.then(() => { .then(() => {
window.location.href = "/"; window.location.href = "/";
@ -255,7 +271,7 @@ export class UserViewPage extends AKElement {
</pf-tooltip> </pf-tooltip>
</ak-action-button> </ak-action-button>
` `
: html``} : nothing}
</div> </div>
</dd> </dd>
</div> </div>
@ -265,12 +281,12 @@ export class UserViewPage extends AKElement {
</dt> </dt>
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text ak-button-collection"> <div class="pf-c-description-list__text ak-button-collection">
<ak-forms-modal size=${PFSize.Medium}> <ak-forms-modal size=${PFSize.Medium} id="update-password-request">
<span slot="submit">${msg("Update password")}</span> <span slot="submit">${msg("Update password")}</span>
<span slot="header">${msg("Update password")}</span> <span slot="header">${msg("Update password")}</span>
<ak-user-password-form <ak-user-password-form
slot="form" slot="form"
.instancePk=${this.user?.pk} .instancePk=${user.pk}
></ak-user-password-form> ></ak-user-password-form>
<button <button
slot="trigger" slot="trigger"
@ -287,30 +303,7 @@ export class UserViewPage extends AKElement {
<ak-action-button <ak-action-button
id="reset-password-button" id="reset-password-button"
class="pf-m-secondary pf-m-block" class="pf-m-secondary pf-m-block"
.apiRequest=${() => { .apiRequest=${() => requestRecoveryLink(user)}
return new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryRetrieve({
id: this.user?.pk || 0,
})
.then((rec) => {
showMessage({
level: MessageLevel.success,
message: msg(
"Successfully generated recovery link",
),
description: rec.link,
});
})
.catch(() => {
showMessage({
level: MessageLevel.error,
message: msg(
"To create a recovery link, the current tenant needs to have a recovery flow configured.",
),
description: "",
});
});
}}
> >
<pf-tooltip <pf-tooltip
position="top" position="top"
@ -318,9 +311,10 @@ export class UserViewPage extends AKElement {
"Create a link for this user to reset their password", "Create a link for this user to reset their password",
)} )}
> >
${msg("Reset Password")} ${msg("Create Recovery Link")}
</pf-tooltip> </pf-tooltip>
</ak-action-button> </ak-action-button>
${user.email ? renderRecoveryEmailRequest(user) : nothing}
</div> </div>
</dd> </dd>
</div> </div>
@ -329,9 +323,9 @@ export class UserViewPage extends AKElement {
`; `;
} }
renderBody(): TemplateResult { renderBody() {
if (!this.user) { if (!this.user) {
return html``; return nothing;
} }
return html`<ak-tabs> return html`<ak-tabs>
<section <section