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 * * ``` * * @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) { if (changedProperties.has("isAddingMember") && this.isAddingMember) { this.isAddMemberFormVisible = true; } if ( changedProperties.has("activePanel") && this.activePanel === "members" ) { this.fetchPendingInvites(); } } render() { return html`

${msg("Org Settings")}

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

${msg("Active Members")}

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

${this.tabLabels[this.activePanel]}

` )}
${this.renderTab("information", "settings")} ${this.renderTab("members", "settings/members")} ${this.renderInformation()} ${this.renderMembers()}
`; } private renderTab(name: Tab, path: string) { const isActive = name === this.activePanel; return html` ${this.tabLabels[name]} `; } private renderInformation() { return html`
${msg("Save Changes")}
`; } private renderMembers() { const columnWidths = ["100%", "10rem", "1.5rem"]; return 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} >
` )} (this.isAddMemberFormVisible = true)} @sl-after-hide=${() => (this.isAddMemberFormVisible = false)} > ${this.isAddMemberFormVisible ? this.renderInviteForm() : ""} `; } 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`); } private renderInviteForm() { return html`
${msg("Admin — Can create crawls and manage org members")} ${msg("Crawler — Can create crawls")} ${msg("Viewer — Can view crawls")}
${msg("Cancel")} ${msg("Invite")}
`; } private async checkFormValidity(formEl: HTMLFormElement) { await this.updateComplete; return !formEl.querySelector("[data-invalid]"); } private async getPendingInvites(): Promise { 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( new CustomEvent("org-name-change", { detail: { value: orgName }, }) ); } private selectUserRole = (user: User) => (e: Event) => { this.dispatchEvent( 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);