diff --git a/frontend/src/components/data-table.ts b/frontend/src/components/data-table.ts new file mode 100644 index 00000000..0c1a53c7 --- /dev/null +++ b/frontend/src/components/data-table.ts @@ -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 + * + * + * ``` + */ +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 = []; + + // Array of CSS widths + @property({ type: Array }) + columnWidths: string[] = []; + + render() { + return html` +
+
+
+ ${this.columns.map(this.renderColumnHeader)} +
+
+
+ ${this.rows.map(this.renderRow)} +
+
+ `; + } + + private renderColumnHeader = (cell: CellContent, index: number) => html` +
+ ${cell} +
+ `; + + private renderRow = (cells: CellContent[]) => html` +
${cells.map(this.renderCell)}
+ `; + + private renderCell = (cell: CellContent) => { + const shouldPadSmall = + typeof cell === "string" + ? false + : // TODO better logic to check template component + cell.strings[0].startsWith(" + ${cell} + + `; + }; +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 9be28e66..455a460e 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -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); diff --git a/frontend/src/components/orgs-list.ts b/frontend/src/components/orgs-list.ts index f0f84ef1..f925a1aa 100644 --- a/frontend/src/components/orgs-list.ts +++ b/frontend/src/components/orgs-list.ts @@ -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`${msg("Admin")}` diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index 9a661530..eb3fded0 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -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} >`; } @@ -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", + }); + } + } } diff --git a/frontend/src/pages/org/settings.ts b/frontend/src/pages/org/settings.ts index 7432adc8..c52cde27 100644 --- a/frontend/src/pages/org/settings.ts +++ b/frontend/src/pages/org/settings.ts @@ -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} * > * ``` + * + * @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 {
-

${this.tabLabels[this.activePanel]}

${when( this.activePanel === "members", () => html` +

${msg("Active Members")}

${msg("Invite New Member")} - ` + `, + () => html`

${this.tabLabels[this.activePanel]}

` )}
${this.renderTab("information", "settings")} @@ -146,42 +182,44 @@ export class OrgSettings extends LiteElement { } private renderMembers() { + const columnWidths = ["100%", "10rem", "1.5rem"]; return html` -
-
-
-
- ${msg("Name")} -
-
- ${msg("Role", { desc: "Organization member's role" })} -
-
-
-
- ${Object.entries(this.org.users!).map( - ([id, user]) => html` -
+ [ + user.name, + this.renderUserRoleSelect(user), + this.renderRemoveMemberButton(user), + ])} + .columnWidths=${columnWidths} + > + + + + ${when( + this.pendingInvites.length, + () => html` +
+

+ ${msg("Pending Invites")} +

+ +
+ [ + user.email, + this.renderUserRole(user), + this.renderRemoveInviteButton(user), + ])} + .columnWidths=${columnWidths} > -
${user.name}
-
- ${isOwner(user.role) - ? msg("Admin") - : user.role === AccessCode.crawler - ? msg("Crawler") - : msg("Viewer")} -
-
- ` - )} -
-
+ +
+ + ` + )} - ${"Admin"} - ${"Crawler"} - ${"Viewer"} + 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` + ${"Admin"} + ${"Crawler"} + ${"Viewer"} `; } + 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` + this.dispatchEvent( + new CustomEvent("org-remove-member", { + detail: { member }, + }) + )} + >`; + } + + private renderRemoveInviteButton(invite: Invite) { + return html` this.removeInvite(invite)} + >`; + } + 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 { + 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( + new CustomEvent("org-name-change", { + detail: { value: orgName }, + }) + ); } + private selectUserRole = (user: User) => (e: CustomEvent) => { + this.dispatchEvent( + 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);