browsertrix/frontend/src/pages/org/settings.ts
Henry Wilkinson 2364433932
Admin Panel Minor Frontend Style Updates (#915)
- Unifies trash icons on all pages to use trash3 (there were a few stragglers!)
- Brings styling of org quotas dialogue in-line with the rest of our dialogues
- Adds missing localization strings
- Swaps button with icon button to match table row action styling elsewhere
2023-06-10 19:21:34 -07:00

501 lines
14 KiB
TypeScript

import { state, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { msg, localized, str } from "@lit/localize";
import { when } from "lit/directives/when.js";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement";
import { isAdmin, isCrawler, AccessCode } from "../../utils/orgs";
import type { OrgData } from "../../utils/orgs";
import type { CurrentUser } from "../../types/user";
import type { APIPaginatedList } from "../../types/api";
import { maxLengthValidator } from "../../utils/form";
type Tab = "information" | "members";
type User = {
email: string;
role: number;
};
type Invite = User & {
created: string;
inviterEmail: string;
};
export type Member = User & {
name: string;
};
export type OrgNameChangeEvent = CustomEvent<{
value: string;
}>;
export type UserRoleChangeEvent = CustomEvent<{
user: Member;
newRole: number;
}>;
export type OrgRemoveMemberEvent = CustomEvent<{
member: Member;
}>;
/**
* Usage:
* ```ts
* <btrix-org-settings
* .authState=${authState}
* .userInfo=${userInfo}
* .org=${org}
* .orgId=${orgId}
* ?isAddingMember=${isAddingMember}
* ></btrix-org-settings>
* ```
*
* @events
* org-name-change
* org-user-role-change
* org-remove-member
*/
@localized()
export class OrgSettings extends LiteElement {
@property({ type: Object })
authState?: AuthState;
@property({ type: Object })
userInfo!: CurrentUser;
@property({ type: String })
orgId!: string;
@property({ type: Object })
org!: OrgData;
@property({ type: String })
activePanel: Tab = "information";
@property({ type: Boolean })
isAddingMember = false;
@property({ type: Boolean })
isSavingOrgName = false;
@state()
pendingInvites: Invite[] = [];
@state()
private isAddMemberFormVisible = false;
@state()
private isSubmittingInvite = false;
private get tabLabels() {
return {
information: msg("Org Information"),
members: msg("Members"),
};
}
private validateOrgNameMax = maxLengthValidator(50);
async willUpdate(changedProperties: Map<string, any>) {
if (changedProperties.has("isAddingMember") && this.isAddingMember) {
this.isAddMemberFormVisible = true;
}
if (
changedProperties.has("activePanel") &&
this.activePanel === "members"
) {
this.fetchPendingInvites();
}
}
render() {
return html`<header class="mb-5">
<h1 class="text-xl font-semibold leading-8">${msg("Org Settings")}</h1>
</header>
<btrix-tab-list activePanel=${this.activePanel} hideIndicator>
<header slot="header" class="flex items-end justify-between h-5">
${when(
this.activePanel === "members",
() => html`
<h3>${msg("Active Members")}</h3>
<sl-button
href=${`/orgs/${this.orgId}/settings/members?invite`}
variant="primary"
size="small"
@click=${this.navLink}
>
<sl-icon
slot="prefix"
name="person-add"
aria-hidden="true"
library="default"
></sl-icon>
${msg("Invite New Member")}</sl-button
>
`,
() => html` <h3>${this.tabLabels[this.activePanel]}</h3> `
)}
</header>
${this.renderTab("information", "settings")}
${this.renderTab("members", "settings/members")}
<btrix-tab-panel name="information"
>${this.renderInformation()}</btrix-tab-panel
>
<btrix-tab-panel name="members"
>${this.renderMembers()}</btrix-tab-panel
>
</btrix-tab-list>`;
}
private renderTab(name: Tab, path: string) {
const isActive = name === this.activePanel;
return html`
<a
slot="nav"
href=${`/orgs/${this.orgId}/${path}`}
class="block font-medium rounded-sm mb-2 mr-2 p-2 transition-all ${isActive
? "text-blue-600 bg-blue-50 shadow-sm"
: "text-neutral-600 hover:bg-neutral-50"}"
@click=${this.navLink}
aria-selected=${isActive}
>
${this.tabLabels[name]}
</a>
`;
}
private renderInformation() {
return html`<div class="rounded border p-5">
<form class="inline-control-form" @submit=${this.onOrgNameSubmit}>
<sl-input
class="inline-control-input with-max-help-text"
name="orgName"
size="small"
label=${msg("Org Name")}
autocomplete="off"
value=${this.org.name}
required
help-text=${this.validateOrgNameMax.helpText}
@sl-input=${this.validateOrgNameMax.validate}
></sl-input>
<sl-button
class="inline-control-button"
type="submit"
size="small"
variant="primary"
?disabled=${this.isSavingOrgName}
?loading=${this.isSavingOrgName}
>${msg("Save Changes")}</sl-button
>
</form>
</div>`;
}
private renderMembers() {
const columnWidths = ["100%", "10rem", "1.5rem"];
return html`
<section class="rounded border overflow-hidden">
<btrix-data-table
.columns=${[msg("Name"), msg("Role"), ""]}
.rows=${Object.entries(this.org.users!).map(([id, user]) => [
user.name,
this.renderUserRoleSelect(user),
this.renderRemoveMemberButton(user),
])}
.columnWidths=${columnWidths}
>
</btrix-data-table>
</section>
${when(
this.pendingInvites.length,
() => html`
<section class="mt-7">
<h3 class="text-lg font-semibold mb-2">
${msg("Pending Invites")}
</h3>
<div class="rounded border overflow-hidden">
<btrix-data-table
.columns=${[msg("Email"), msg("Role"), ""]}
.rows=${this.pendingInvites.map((user) => [
user.email,
this.renderUserRole(user),
this.renderRemoveInviteButton(user),
])}
.columnWidths=${columnWidths}
>
</btrix-data-table>
</div>
</section>
`
)}
<btrix-dialog
label=${msg("Invite New Member")}
?open=${this.isAddingMember}
@sl-request-close=${this.hideInviteDialog}
@sl-show=${() => (this.isAddMemberFormVisible = true)}
@sl-after-hide=${() => (this.isAddMemberFormVisible = false)}
>
${this.isAddMemberFormVisible ? this.renderInviteForm() : ""}
</btrix-dialog>
`;
}
private renderUserRole({ role }: User) {
if (isAdmin(role)) return msg("Admin");
if (isCrawler(role)) return msg("Crawler");
return msg("Viewer");
}
private renderUserRoleSelect(user: Member) {
// Consider superadmins owners
const userRole =
user.role === AccessCode.superadmin ? AccessCode.owner : user.role;
return html`<sl-select
value=${userRole}
size="small"
@sl-change=${this.selectUserRole(user)}
hoist
>
<sl-option value=${AccessCode.owner}>${"Admin"}</sl-option>
<sl-option value=${AccessCode.crawler}>${"Crawler"}</sl-option>
<sl-option value=${AccessCode.viewer}>${"Viewer"}</sl-option>
</sl-select>`;
}
private renderRemoveMemberButton(member: Member) {
let disableButton = false;
if (member.email === this.userInfo.email) {
const { [this.userInfo.id]: _currentUser, ...otherUsers } =
this.org.users!;
const hasOtherAdmin = Object.values(otherUsers).some(({ role }) =>
isAdmin(role)
);
if (!hasOtherAdmin) {
// Must be another admin in order to remove self
disableButton = true;
}
}
return html`<btrix-button
name="trash"
icon
?disabled=${disableButton}
aria-details=${ifDefined(
disableButton === true
? msg("Cannot remove only admin member")
: undefined
)}
@click=${() =>
this.dispatchEvent(
<OrgRemoveMemberEvent>new CustomEvent("org-remove-member", {
detail: { member },
})
)}
>
<sl-icon name="trash3"></sl-icon>
</btrix-button>`;
}
private renderRemoveInviteButton(invite: Invite) {
return html`<btrix-button icon @click=${() => this.removeInvite(invite)}>
<sl-icon name="trash3"></sl-icon>
</btrix-button>`;
}
private hideInviteDialog() {
this.navTo(`/orgs/${this.orgId}/settings/members`);
}
private renderInviteForm() {
return html`
<form
id="orgInviteForm"
@submit=${this.onOrgInviteSubmit}
@reset=${this.hideInviteDialog}
>
<div class="mb-5">
<sl-input
id="inviteEmail"
name="inviteEmail"
type="email"
label=${msg("Email")}
placeholder=${msg("org-member@email.com", {
desc: "Placeholder text for email to invite",
})}
required
>
</sl-input>
</div>
<div class="mb-5">
<sl-radio-group
name="role"
label="Permission"
value=${AccessCode.viewer}
>
<sl-radio value=${AccessCode.owner}>
${msg("Admin — Can create crawls and manage org members")}
</sl-radio>
<sl-radio value=${AccessCode.crawler}>
${msg("Crawler — Can create crawls")}
</sl-radio>
<sl-radio value=${AccessCode.viewer}>
${msg("Viewer — Can view crawls")}
</sl-radio>
</sl-radio-group>
</div>
</form>
<div slot="footer" class="flex justify-between">
<sl-button form="orgInviteForm" type="reset" size="small"
>${msg("Cancel")}</sl-button
>
<sl-button
form="orgInviteForm"
variant="primary"
type="submit"
size="small"
?loading=${this.isSubmittingInvite}
?disabled=${this.isSubmittingInvite}
>${msg("Invite")}</sl-button
>
</div>
`;
}
private async checkFormValidity(formEl: HTMLFormElement) {
await this.updateComplete;
return !formEl.querySelector("[data-invalid]");
}
private async getPendingInvites(): Promise<Invite[]> {
const data: APIPaginatedList = await this.apiFetch(
`/orgs/${this.org.id}/invites`,
this.authState!
);
return data.items;
}
private async fetchPendingInvites() {
try {
this.pendingInvites = await this.getPendingInvites();
} catch (e: any) {
console.debug(e);
this.notify({
message: msg("Sorry, couldn't retrieve pending invites at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async onOrgNameSubmit(e: SubmitEvent) {
e.preventDefault();
const formEl = e.target as HTMLFormElement;
if (!(await this.checkFormValidity(formEl))) return;
const { orgName } = serialize(formEl);
this.dispatchEvent(
<OrgNameChangeEvent>new CustomEvent("org-name-change", {
detail: { value: orgName },
})
);
}
private selectUserRole = (user: User) => (e: Event) => {
this.dispatchEvent(
<UserRoleChangeEvent>new CustomEvent("org-user-role-change", {
detail: {
user,
newRole: Number((e.target as HTMLSelectElement).value),
},
})
);
};
async onOrgInviteSubmit(e: SubmitEvent) {
e.preventDefault();
const formEl = e.target as HTMLFormElement;
if (!(await this.checkFormValidity(formEl))) return;
const { inviteEmail, role } = serialize(formEl);
this.isSubmittingInvite = true;
try {
const data = await this.apiFetch(
`/orgs/${this.orgId}/invite`,
this.authState!,
{
method: "POST",
body: JSON.stringify({
email: inviteEmail,
role: Number(role),
}),
}
);
this.notify({
message: msg(str`Successfully invited ${inviteEmail}.`),
variant: "success",
icon: "check2-circle",
});
this.fetchPendingInvites();
this.hideInviteDialog();
} catch (e: any) {
this.notify({
message: e.isApiError
? e.message
: msg("Sorry, couldn't invite user at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
this.isSubmittingInvite = false;
}
private async removeInvite(invite: Invite) {
try {
await this.apiFetch(
`/orgs/${this.orgId}/invites/delete`,
this.authState!,
{
method: "POST",
body: JSON.stringify({
email: invite.email,
}),
}
);
this.notify({
message: msg(
str`Successfully removed ${invite.email} from ${this.org.name}.`
),
variant: "success",
icon: "check2-circle",
});
this.pendingInvites = this.pendingInvites.filter(
({ email }) => email !== invite.email
);
} catch (e: any) {
console.debug(e);
this.notify({
message: e.isApiError
? e.message
: msg(str`Sorry, couldn't remove ${invite.email} at this time.`),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}
customElements.define("btrix-org-settings", OrgSettings);