Manage org member roles and invites (#558)

- View and delete pending invites
- Update user roles for members
- Remove members
This commit is contained in:
sua yoo 2023-02-08 18:32:40 -08:00 committed by GitHub
parent 40fb04b385
commit 7463becdff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 486 additions and 79 deletions

View File

@ -0,0 +1,131 @@
import { LitElement, html, css, TemplateResult } from "lit";
import { property } from "lit/decorators.js";
type CellContent = string | TemplateResult;
/**
* Styled data table
*
* Usage example:
* ```ts
* <btrix-data-table
* .columns=${[html`A`, html`B`, html`C`]}
* .rows=${[
* [html`1a`, html`1b`, html`1c`],
* [html`2a`, html`2b`, html`2c`],
* ]}
* .columnWidths=${["100%", "20rem"]}
* >
* </btrix-data-table>
* ```
*/
export class DataTable extends LitElement {
static styles = css`
:host {
display: contents;
}
.table {
display: table;
table-layout: fixed;
font-family: var(--font-monostyle-family);
font-variation-settings: var(--font-monostyle-variation);
width: 100%;
}
.thead {
display: table-header-group;
}
.tbody {
display: table-row-group;
}
.row {
display: table-row;
}
.cell {
display: table-cell;
vertical-align: middle;
}
.cell:nth-of-type(n + 2) {
border-left: 1px solid var(--sl-panel-border-color);
}
.cell[role="cell"] {
border-top: 1px solid var(--sl-panel-border-color);
}
.cell.padSmall {
padding: var(--sl-spacing-2x-small);
}
.cell.padded {
padding: var(--sl-spacing-x-small);
}
.thead .row {
background-color: var(--sl-color-neutral-50);
color: var(--sl-color-neutral-700);
font-size: var(--sl-font-size-x-small);
line-height: 1rem;
text-transform: uppercase;
}
`;
@property({ type: Array })
columns: CellContent[] = [];
@property({ type: Array })
rows: Array<CellContent[]> = [];
// Array of CSS widths
@property({ type: Array })
columnWidths: string[] = [];
render() {
return html`
<div role="table" class="table">
<div role="rowgroup" class="thead">
<div role="row" class="row">
${this.columns.map(this.renderColumnHeader)}
</div>
</div>
<div role="rowgroup" class="tbody">
${this.rows.map(this.renderRow)}
</div>
</div>
`;
}
private renderColumnHeader = (cell: CellContent, index: number) => html`
<div
role="columnheader"
class="cell padded"
style=${this.columnWidths[index]
? `width: ${this.columnWidths[index]}`
: ""}
>
${cell}
</div>
`;
private renderRow = (cells: CellContent[]) => html`
<div role="row" class="row">${cells.map(this.renderCell)}</div>
`;
private renderCell = (cell: CellContent) => {
const shouldPadSmall =
typeof cell === "string"
? false
: // TODO better logic to check template component
cell.strings[0].startsWith("<sl-");
return html`
<div role="cell" class="cell ${shouldPadSmall ? "padSmall" : "padded"}">
${cell}
</div>
`;
};
}

View File

@ -101,6 +101,9 @@ import("./tag").then(({ Tag }) => {
import("./dialog").then(({ Dialog }) => {
customElements.define("btrix-dialog", Dialog);
});
import("./data-table").then(({ DataTable }) => {
customElements.define("btrix-data-table", DataTable);
});
customElements.define("btrix-alert", Alert);
customElements.define("btrix-input", Input);

View File

@ -5,7 +5,7 @@ import type { CurrentUser } from "../types/user";
import type { OrgData } from "../utils/orgs";
import LiteElement, { html } from "../utils/LiteElement";
import { isOwner } from "../utils/orgs";
import { isAdmin } from "../utils/orgs";
@localized()
export class OrgsList extends LiteElement {
@ -39,7 +39,7 @@ export class OrgsList extends LiteElement {
${this.userInfo &&
org.users &&
(this.userInfo.isAdmin ||
isOwner(org.users[this.userInfo.id].role))
isAdmin(org.users[this.userInfo.id].role))
? html`<sl-tag size="small" variant="primary"
>${msg("Admin")}</sl-tag
>`

View File

@ -18,6 +18,12 @@ import "./browser-profiles-detail";
import "./browser-profiles-list";
import "./browser-profiles-new";
import "./settings";
import type {
Member,
OrgNameChangeEvent,
UserRoleChangeEvent,
OrgRemoveMemberEvent,
} from "./settings";
export type OrgTab =
| "crawls"
@ -62,6 +68,9 @@ export class Org extends LiteElement {
@state()
private org?: OrgData | null;
@state()
private isSavingOrgName = false;
get userOrg() {
if (!this.userInfo) return null;
return this.userInfo.orgs.find(({ id }) => id === this.orgId)!;
@ -295,6 +304,10 @@ export class Org extends LiteElement {
.orgId=${this.orgId}
activePanel=${activePanel}
?isAddingMember=${isAddingMember}
?isSavingOrgName=${this.isSavingOrgName}
@org-name-change=${this.onOrgNameChange}
@org-user-role-change=${this.onUserRoleChange}
@org-remove-member=${this.onOrgRemoveMember}
></btrix-org-settings>`;
}
@ -303,4 +316,130 @@ export class Org extends LiteElement {
return data;
}
private async onOrgNameChange(e: OrgNameChangeEvent) {
this.isSavingOrgName = true;
try {
await this.apiFetch(`/orgs/${this.org!.id}/rename`, this.authState!, {
method: "POST",
body: JSON.stringify({ name: e.detail.value }),
});
this.notify({
message: msg("Updated organization name."),
variant: "success",
icon: "check2-circle",
});
this.dispatchEvent(
new CustomEvent("update-user-info", { bubbles: true })
);
} catch (e: any) {
this.notify({
message: e.isApiError
? e.message
: msg("Sorry, couldn't update organization name at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
this.isSavingOrgName = false;
}
private async onOrgRemoveMember(e: OrgRemoveMemberEvent) {
this.removeMember(e.detail.member);
}
private async onUserRoleChange(e: UserRoleChangeEvent) {
const { user, newRole } = e.detail;
try {
await this.apiFetch(`/orgs/${this.orgId}/user-role`, this.authState!, {
method: "PATCH",
body: JSON.stringify({
email: user.email,
role: newRole,
}),
});
this.notify({
message: msg(
str`Successfully updated role for ${user.name || user.email}.`
),
variant: "success",
icon: "check2-circle",
});
this.org = await this.getOrg(this.orgId);
} catch (e: any) {
console.debug(e);
this.notify({
message: e.isApiError
? e.message
: msg(
str`Sorry, couldn't update role for ${
user.name || user.email
} at this time.`
),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async removeMember(member: Member) {
if (!this.org) return;
const isSelf = member.email === this.userInfo!.email;
if (
isSelf &&
!window.confirm(
msg(
str`Are you sure you want to remove yourself from ${this.org.name}?`
)
)
) {
return;
}
try {
await this.apiFetch(`/orgs/${this.orgId}/remove`, this.authState!, {
method: "POST",
body: JSON.stringify({
email: member.email,
}),
});
this.notify({
message: msg(
str`Successfully removed ${member.name || member.email} from ${
this.org.name
}.`
),
variant: "success",
icon: "check2-circle",
});
if (isSelf) {
// FIXME better UX, this is the only page currently that doesn't require org...
this.navTo("/account/settings");
} else {
this.org = await this.getOrg(this.orgId);
}
} catch (e: any) {
console.debug(e);
this.notify({
message: e.isApiError
? e.message
: msg(
str`Sorry, couldn't remove ${
member.name || member.email
} at this time.`
),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
}

View File

@ -6,11 +6,32 @@ import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement";
import { isOwner, AccessCode } from "../../utils/orgs";
import { isAdmin, isCrawler, AccessCode } from "../../utils/orgs";
import type { OrgData } from "../../utils/orgs";
import type { CurrentUser } from "../../types/user";
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:
@ -23,6 +44,11 @@ type Tab = "information" | "members";
* ?isAddingMember=${isAddingMember}
* ></btrix-org-settings>
* ```
*
* @events
* org-name-change
* org-user-role-change
* org-remove-member
*/
@localized()
export class OrgSettings extends LiteElement {
@ -44,11 +70,14 @@ export class OrgSettings extends LiteElement {
@property({ type: Boolean })
isAddingMember = false;
@state()
private isAddMemberFormVisible = false;
@property({ type: Boolean })
isSavingOrgName = false;
@state()
private isSavingOrgName = false;
pendingInvites: Invite[] = [];
@state()
private isAddMemberFormVisible = false;
@state()
private isSubmittingInvite = false;
@ -64,6 +93,12 @@ export class OrgSettings extends LiteElement {
if (changedProperties.has("isAddingMember") && this.isAddingMember) {
this.isAddMemberFormVisible = true;
}
if (
changedProperties.has("activePanel") &&
this.activePanel === "members"
) {
this.fetchPendingInvites();
}
}
render() {
@ -73,10 +108,10 @@ export class OrgSettings extends LiteElement {
<btrix-tab-list activePanel=${this.activePanel} ?hideIndicator=${true}>
<header slot="header" class="flex items-end justify-between h-5">
<h3>${this.tabLabels[this.activePanel]}</h3>
${when(
this.activePanel === "members",
() => html`
<h3>${msg("Active Members")}</h3>
<sl-button
href=${`/orgs/${this.orgId}/settings/members?invite`}
variant="primary"
@ -84,7 +119,8 @@ export class OrgSettings extends LiteElement {
@click=${this.navLink}
>${msg("Invite New Member")}</sl-button
>
`
`,
() => html` <h3>${this.tabLabels[this.activePanel]}</h3> `
)}
</header>
${this.renderTab("information", "settings")}
@ -146,42 +182,44 @@ export class OrgSettings extends LiteElement {
}
private renderMembers() {
const columnWidths = ["100%", "10rem", "1.5rem"];
return html`
<div role="table" class="rounded border">
<div class="border-b bg-neutral-50" role="rowgroup">
<div class="flex font-medium" role="row">
<div class="flex-1 px-3 py-1" role="columnheader" aria-sort="none">
${msg("Name")}
</div>
<div
class="flex-0 w-52 px-3 py-1"
role="columnheader"
aria-sort="none"
>
${msg("Role", { desc: "Organization member's role" })}
</div>
</div>
</div>
<div role="rowgroup">
${Object.entries(this.org.users!).map(
([id, user]) => html`
<div
class="border-b last:border-none flex items-center"
role="row"
<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}
>
<div class="flex-1 p-3" role="cell">${user.name}</div>
<div class="flex-0 w-52 p-3" role="cell">
${isOwner(user.role)
? msg("Admin")
: user.role === AccessCode.crawler
? msg("Crawler")
: msg("Viewer")}
</div>
</div>
`
)}
</div>
</div>
</btrix-data-table>
</div>
</section>
`
)}
<btrix-dialog
label=${msg("Invite New Member")}
@ -195,14 +233,67 @@ export class OrgSettings extends LiteElement {
`;
}
private renderUserRole(user: { name: string; role: typeof AccessCode }) {
return html`<sl-select value=${user.role} size="small">
<sl-menu-item value=${AccessCode.owner}> ${"Admin"} </sl-menu-item>
<sl-menu-item value=${AccessCode.crawler}> ${"Crawler"} </sl-menu-item>
<sl-menu-item value=${AccessCode.viewer}> ${"Viewer"} </sl-menu-item>
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-select=${this.selectUserRole(user)}
>
<sl-menu-item value=${AccessCode.owner}>${"Admin"}</sl-menu-item>
<sl-menu-item value=${AccessCode.crawler}>${"Crawler"}</sl-menu-item>
<sl-menu-item value=${AccessCode.viewer}>${"Viewer"}</sl-menu-item>
</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`<sl-icon-button
name="trash"
?disabled=${disableButton}
style="font-size: 1rem"
let
disableButton="false;"
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-button>`;
}
private renderRemoveInviteButton(invite: Invite) {
return html`<btrix-icon-button
name="trash"
@click=${() => this.removeInvite(invite)}
></btrix-icon-button>`;
}
private hideInviteDialog() {
this.navTo(`/orgs/${this.orgId}/settings/members`);
}
@ -262,11 +353,31 @@ export class OrgSettings extends LiteElement {
`;
}
async checkFormValidity(formEl: HTMLFormElement) {
private async checkFormValidity(formEl: HTMLFormElement) {
await this.updateComplete;
return !formEl.querySelector("[data-invalid]");
}
private getPendingInvites(): Promise<Invite[]> {
return this.apiFetch(`/orgs/${this.org.id}/invites`, this.authState!).then(
(data) => data.pending_invites
);
}
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();
@ -274,38 +385,24 @@ export class OrgSettings extends LiteElement {
if (!(await this.checkFormValidity(formEl))) return;
const { orgName } = serialize(formEl);
this.isSavingOrgName = true;
try {
await this.apiFetch(`/orgs/${this.org.id}/rename`, this.authState!, {
method: "POST",
body: JSON.stringify({ name: orgName }),
});
this.notify({
message: msg("Updated organization name."),
variant: "success",
icon: "check2-circle",
duration: 8000,
});
this.dispatchEvent(
new CustomEvent("update-user-info", { bubbles: true })
);
} catch (e: any) {
this.notify({
message: e.isApiError
? e.message
: msg("Sorry, couldn't update organization name at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
this.isSavingOrgName = false;
this.dispatchEvent(
<OrgNameChangeEvent>new CustomEvent("org-name-change", {
detail: { value: orgName },
})
);
}
private selectUserRole = (user: User) => (e: CustomEvent) => {
this.dispatchEvent(
<UserRoleChangeEvent>new CustomEvent("org-user-role-change", {
detail: {
user,
newRole: Number(e.detail.item.value),
},
})
);
};
async onOrgInviteSubmit(e: SubmitEvent) {
e.preventDefault();
@ -333,9 +430,9 @@ export class OrgSettings extends LiteElement {
message: msg(str`Successfully invited ${inviteEmail}.`),
variant: "success",
icon: "check2-circle",
duration: 8000,
});
this.fetchPendingInvites();
this.hideInviteDialog();
} catch (e: any) {
this.notify({
@ -349,6 +446,43 @@ export class OrgSettings extends LiteElement {
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);