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 }) => { import("./dialog").then(({ Dialog }) => {
customElements.define("btrix-dialog", 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-alert", Alert);
customElements.define("btrix-input", Input); customElements.define("btrix-input", Input);

View File

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

View File

@ -18,6 +18,12 @@ import "./browser-profiles-detail";
import "./browser-profiles-list"; import "./browser-profiles-list";
import "./browser-profiles-new"; import "./browser-profiles-new";
import "./settings"; import "./settings";
import type {
Member,
OrgNameChangeEvent,
UserRoleChangeEvent,
OrgRemoveMemberEvent,
} from "./settings";
export type OrgTab = export type OrgTab =
| "crawls" | "crawls"
@ -62,6 +68,9 @@ export class Org extends LiteElement {
@state() @state()
private org?: OrgData | null; private org?: OrgData | null;
@state()
private isSavingOrgName = false;
get userOrg() { get userOrg() {
if (!this.userInfo) return null; if (!this.userInfo) return null;
return this.userInfo.orgs.find(({ id }) => id === this.orgId)!; return this.userInfo.orgs.find(({ id }) => id === this.orgId)!;
@ -295,6 +304,10 @@ export class Org extends LiteElement {
.orgId=${this.orgId} .orgId=${this.orgId}
activePanel=${activePanel} activePanel=${activePanel}
?isAddingMember=${isAddingMember} ?isAddingMember=${isAddingMember}
?isSavingOrgName=${this.isSavingOrgName}
@org-name-change=${this.onOrgNameChange}
@org-user-role-change=${this.onUserRoleChange}
@org-remove-member=${this.onOrgRemoveMember}
></btrix-org-settings>`; ></btrix-org-settings>`;
} }
@ -303,4 +316,130 @@ export class Org extends LiteElement {
return data; 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 type { AuthState } from "../../utils/AuthService";
import LiteElement, { html } from "../../utils/LiteElement"; 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 { OrgData } from "../../utils/orgs";
import type { CurrentUser } from "../../types/user"; import type { CurrentUser } from "../../types/user";
type Tab = "information" | "members"; 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: * Usage:
@ -23,6 +44,11 @@ type Tab = "information" | "members";
* ?isAddingMember=${isAddingMember} * ?isAddingMember=${isAddingMember}
* ></btrix-org-settings> * ></btrix-org-settings>
* ``` * ```
*
* @events
* org-name-change
* org-user-role-change
* org-remove-member
*/ */
@localized() @localized()
export class OrgSettings extends LiteElement { export class OrgSettings extends LiteElement {
@ -44,11 +70,14 @@ export class OrgSettings extends LiteElement {
@property({ type: Boolean }) @property({ type: Boolean })
isAddingMember = false; isAddingMember = false;
@state() @property({ type: Boolean })
private isAddMemberFormVisible = false; isSavingOrgName = false;
@state() @state()
private isSavingOrgName = false; pendingInvites: Invite[] = [];
@state()
private isAddMemberFormVisible = false;
@state() @state()
private isSubmittingInvite = false; private isSubmittingInvite = false;
@ -64,6 +93,12 @@ export class OrgSettings extends LiteElement {
if (changedProperties.has("isAddingMember") && this.isAddingMember) { if (changedProperties.has("isAddingMember") && this.isAddingMember) {
this.isAddMemberFormVisible = true; this.isAddMemberFormVisible = true;
} }
if (
changedProperties.has("activePanel") &&
this.activePanel === "members"
) {
this.fetchPendingInvites();
}
} }
render() { render() {
@ -73,10 +108,10 @@ export class OrgSettings extends LiteElement {
<btrix-tab-list activePanel=${this.activePanel} ?hideIndicator=${true}> <btrix-tab-list activePanel=${this.activePanel} ?hideIndicator=${true}>
<header slot="header" class="flex items-end justify-between h-5"> <header slot="header" class="flex items-end justify-between h-5">
<h3>${this.tabLabels[this.activePanel]}</h3>
${when( ${when(
this.activePanel === "members", this.activePanel === "members",
() => html` () => html`
<h3>${msg("Active Members")}</h3>
<sl-button <sl-button
href=${`/orgs/${this.orgId}/settings/members?invite`} href=${`/orgs/${this.orgId}/settings/members?invite`}
variant="primary" variant="primary"
@ -84,7 +119,8 @@ export class OrgSettings extends LiteElement {
@click=${this.navLink} @click=${this.navLink}
>${msg("Invite New Member")}</sl-button >${msg("Invite New Member")}</sl-button
> >
` `,
() => html` <h3>${this.tabLabels[this.activePanel]}</h3> `
)} )}
</header> </header>
${this.renderTab("information", "settings")} ${this.renderTab("information", "settings")}
@ -146,42 +182,44 @@ export class OrgSettings extends LiteElement {
} }
private renderMembers() { private renderMembers() {
const columnWidths = ["100%", "10rem", "1.5rem"];
return html` return html`
<div role="table" class="rounded border"> <section class="rounded border overflow-hidden">
<div class="border-b bg-neutral-50" role="rowgroup"> <btrix-data-table
<div class="flex font-medium" role="row"> .columns=${[msg("Name"), msg("Role"), ""]}
<div class="flex-1 px-3 py-1" role="columnheader" aria-sort="none"> .rows=${Object.entries(this.org.users!).map(([id, user]) => [
${msg("Name")} user.name,
</div> this.renderUserRoleSelect(user),
<div this.renderRemoveMemberButton(user),
class="flex-0 w-52 px-3 py-1" ])}
role="columnheader" .columnWidths=${columnWidths}
aria-sort="none" >
> </btrix-data-table>
${msg("Role", { desc: "Organization member's role" })} </section>
</div>
</div> ${when(
</div> this.pendingInvites.length,
<div role="rowgroup"> () => html`
${Object.entries(this.org.users!).map( <section class="mt-7">
([id, user]) => html` <h3 class="text-lg font-semibold mb-2">
<div ${msg("Pending Invites")}
class="border-b last:border-none flex items-center" </h3>
role="row"
<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> </btrix-data-table>
<div class="flex-0 w-52 p-3" role="cell"> </div>
${isOwner(user.role) </section>
? msg("Admin") `
: user.role === AccessCode.crawler )}
? msg("Crawler")
: msg("Viewer")}
</div>
</div>
`
)}
</div>
</div>
<btrix-dialog <btrix-dialog
label=${msg("Invite New Member")} label=${msg("Invite New Member")}
@ -195,14 +233,67 @@ export class OrgSettings extends LiteElement {
`; `;
} }
private renderUserRole(user: { name: string; role: typeof AccessCode }) { private renderUserRole({ role }: User) {
return html`<sl-select value=${user.role} size="small"> if (isAdmin(role)) return msg("Admin");
<sl-menu-item value=${AccessCode.owner}> ${"Admin"} </sl-menu-item> if (isCrawler(role)) return msg("Crawler");
<sl-menu-item value=${AccessCode.crawler}> ${"Crawler"} </sl-menu-item> return msg("Viewer");
<sl-menu-item value=${AccessCode.viewer}> ${"Viewer"} </sl-menu-item> }
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>`; </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() { private hideInviteDialog() {
this.navTo(`/orgs/${this.orgId}/settings/members`); 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; await this.updateComplete;
return !formEl.querySelector("[data-invalid]"); 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) { private async onOrgNameSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
@ -274,38 +385,24 @@ export class OrgSettings extends LiteElement {
if (!(await this.checkFormValidity(formEl))) return; if (!(await this.checkFormValidity(formEl))) return;
const { orgName } = serialize(formEl); const { orgName } = serialize(formEl);
this.dispatchEvent(
this.isSavingOrgName = true; <OrgNameChangeEvent>new CustomEvent("org-name-change", {
detail: { value: orgName },
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;
} }
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) { async onOrgInviteSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
@ -333,9 +430,9 @@ export class OrgSettings extends LiteElement {
message: msg(str`Successfully invited ${inviteEmail}.`), message: msg(str`Successfully invited ${inviteEmail}.`),
variant: "success", variant: "success",
icon: "check2-circle", icon: "check2-circle",
duration: 8000,
}); });
this.fetchPendingInvites();
this.hideInviteDialog(); this.hideInviteDialog();
} catch (e: any) { } catch (e: any) {
this.notify({ this.notify({
@ -349,6 +446,43 @@ export class OrgSettings extends LiteElement {
this.isSubmittingInvite = false; 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); customElements.define("btrix-org-settings", OrgSettings);