browsertrix/frontend/src/pages/org/settings/settings.ts
2024-07-16 12:59:54 -07:00

653 lines
18 KiB
TypeScript

import { localized, msg, str } from "@lit/localize";
import type { SlInput } from "@shoelace-style/shoelace";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import { html, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { when } from "lit/directives/when.js";
import { columns } from "./ui/columns";
import { TailwindElement } from "@/classes/TailwindElement";
import { APIController } from "@/controllers/api";
import { NavigateController } from "@/controllers/navigate";
import { NotifyController } from "@/controllers/notify";
import type { APIUser } from "@/index";
import type { APIPaginatedList } from "@/types/api";
import type { CurrentUser } from "@/types/user";
import { isApiError } from "@/utils/api";
import type { AuthState } from "@/utils/AuthService";
import { maxLengthValidator } from "@/utils/form";
import { AccessCode, isAdmin, isCrawler, type OrgData } from "@/utils/orgs";
import slugifyStrict from "@/utils/slugify";
import appState, { AppStateService, use } from "@/utils/state";
import { formatAPIUser } from "@/utils/user";
import "./components/billing";
type Tab = "information" | "members" | "billing";
type User = {
email: string;
role: number;
};
type Invite = User & {
created: string;
inviterEmail: string;
};
export type Member = User & {
name: 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>
* ```
*
* @fires org-user-role-change
* @fires org-remove-member
*/
@localized()
@customElement("btrix-org-settings")
export class OrgSettings extends TailwindElement {
@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;
@use()
appState = appState;
@state()
private isSavingOrgName = false;
@state()
private pendingInvites: Invite[] = [];
@state()
private isAddMemberFormVisible = false;
@state()
private isSubmittingInvite = false;
@state()
private slugValue = "";
private readonly api = new APIController(this);
private readonly navigate = new NavigateController(this);
private readonly notify = new NotifyController(this);
private get tabLabels(): Record<Tab, string> {
return {
information: msg("General"),
members: msg("Members"),
billing: msg("Billing"),
};
}
private readonly validateOrgNameMax = maxLengthValidator(40);
async willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("isAddingMember") && this.isAddingMember) {
this.isAddMemberFormVisible = true;
}
if (
changedProperties.has("activePanel") &&
this.activePanel === "members"
) {
void 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 h-5 items-end justify-between">
${when(
this.activePanel === "members",
() => html`
<h3>${msg("Active Members")}</h3>
<sl-button
href=${`${this.navigate.orgBasePath}/settings/members?invite`}
variant="primary"
size="small"
@click=${this.navigate.link}
>
<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")}
${when(this.appState.settings?.billingEnabled, () =>
this.renderTab("billing", "settings/billing"),
)}
<btrix-tab-panel name="information">
${this.renderInformation()}
</btrix-tab-panel>
<btrix-tab-panel name="members">
${this.renderMembers()}
</btrix-tab-panel>
<btrix-tab-panel name="billing">
<btrix-org-settings-billing
.org=${this.org}
.authState=${this.authState}
.salesEmail=${this.appState.settings?.salesEmail}
></btrix-org-settings-billing>
</btrix-tab-panel>
</btrix-tab-list>`;
}
private renderTab(name: Tab, path: string) {
const isActive = name === this.activePanel;
return html`
<btrix-navigation-button
slot="nav"
href=${`${this.navigate.orgBasePath}/${path}`}
.active=${isActive}
@click=${this.navigate.link}
aria-selected=${isActive}
>
${this.tabLabels[name]}
</btrix-navigation-button>
`;
}
private renderInformation() {
return html`<div class="rounded-lg border">
<form @submit=${this.onOrgInfoSubmit}>
${columns([
[
html`
<sl-input
class="with-max-help-text mb-2"
name="orgName"
size="small"
label=${msg("Org Name")}
placeholder=${msg("My Organization")}
autocomplete="off"
value=${this.org.name}
minlength="2"
required
help-text=${this.validateOrgNameMax.helpText}
@sl-input=${this.validateOrgNameMax.validate}
></sl-input>
`,
msg(
"Name of your organization that is visible to all org members.",
),
],
[
html`
<sl-input
class="mb-2"
name="orgSlug"
size="small"
label=${msg("Custom URL Identifier")}
placeholder="my-organization"
autocomplete="off"
value=${this.org.slug}
minlength="2"
maxlength="30"
required
help-text=${msg(
str`Org home page: ${window.location.protocol}//${
window.location.hostname
}/orgs/${
this.slugValue
? slugifyStrict(this.slugValue)
: this.org.slug
}`,
)}
@sl-input=${(e: InputEvent) => {
const input = e.target as SlInput;
this.slugValue = input.value;
}}
></sl-input>
`,
msg(
"Customize your organization's web address for accessing Browsertrix.",
),
],
[
html`
<btrix-copy-field
class="mb-2"
label=${msg("Org ID")}
value=${this.org.id}
></btrix-copy-field>
`,
msg("Use this ID to reference this org in the Browsertrix API."),
],
])}
<footer class="flex justify-end border-t px-4 py-3">
<sl-button
class="inline-control-button"
type="submit"
size="small"
variant="primary"
?disabled=${this.isSavingOrgName}
?loading=${this.isSavingOrgName}
>${msg("Save Changes")}</sl-button
>
</footer>
</form>
</div>`;
}
private renderMembers() {
const columnWidths = ["1fr", "2fr", "auto", "min-content"];
const rows = Object.entries(this.org.users!).map(([_id, user]) => [
user.name,
user.email,
this.renderUserRoleSelect(user),
this.renderRemoveMemberButton(user),
]);
return html`
<section>
<btrix-data-table
.columns=${[
msg("Name"),
msg("Email"),
msg("Role"),
html`<span class="sr-only">${msg("Delete")}</span>`,
]}
.rows=${rows}
.columnWidths=${columnWidths}
>
</btrix-data-table>
</section>
${when(
this.pendingInvites.length,
() => html`
<section class="mt-7">
<h3 class="mb-2 text-lg font-semibold">
${msg("Pending Invites")}
</h3>
<btrix-data-table
.columns=${[
msg("Email"),
msg("Role"),
html`<span class="sr-only">${msg("Remove")}</span>`,
]}
.rows=${this.pendingInvites.map((user) => [
user.email,
this.renderUserRole(user),
this.renderRemoveInviteButton(user),
])}
.columnWidths=${columnWidths}
>
</btrix-data-table>
</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`<sl-icon-button
class="text-base hover:text-danger"
name="trash3"
label=${msg("Remove org member")}
?disabled=${disableButton}
aria-details=${ifDefined(
disableButton ? msg("Cannot remove only admin member") : undefined,
)}
@click=${() =>
this.dispatchEvent(
new CustomEvent("org-remove-member", {
detail: { member },
}) as OrgRemoveMemberEvent,
)}
></sl-icon-button>`;
}
private renderRemoveInviteButton(invite: Invite) {
return html`<sl-icon-button
class="text-base hover:text-danger"
name="trash3"
label=${msg("Revoke invite")}
@click=${() => void this.removeInvite(invite)}
></sl-icon-button>`;
}
private hideInviteDialog() {
this.navigate.to(`${this.navigate.orgBasePath}/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() {
const data = await this.api.fetch<APIPaginatedList<Invite>>(
`/orgs/${this.org.id}/invites`,
this.authState!,
);
return data.items;
}
private async fetchPendingInvites() {
try {
this.pendingInvites = await this.getPendingInvites();
} catch (e) {
console.debug(e);
this.notify.toast({
message: msg("Sorry, couldn't retrieve pending invites at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async onOrgInfoSubmit(e: SubmitEvent) {
e.preventDefault();
const formEl = e.target as HTMLFormElement;
if (!(await this.checkFormValidity(formEl))) return;
const { orgName } = serialize(formEl) as { orgName: string };
const params = {
name: orgName,
slug: this.org.slug,
};
if (this.slugValue) {
params.slug = slugifyStrict(this.slugValue);
}
this.isSavingOrgName = true;
await this.renameOrg(params);
this.isSavingOrgName = false;
}
private readonly selectUserRole = (user: User) => (e: Event) => {
this.dispatchEvent(
new CustomEvent("org-user-role-change", {
detail: {
user,
newRole: Number((e.target as HTMLSelectElement).value),
},
}) as UserRoleChangeEvent,
);
};
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.api.fetch(
`/orgs/${this.orgId}/invite`,
this.authState!,
{
method: "POST",
body: JSON.stringify({
email: inviteEmail,
role: Number(role),
}),
},
);
this.notify.toast({
message: msg(str`Successfully invited ${inviteEmail}.`),
variant: "success",
icon: "check2-circle",
});
void this.fetchPendingInvites();
this.hideInviteDialog();
} catch (e) {
this.notify.toast({
message: isApiError(e)
? 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.api.fetch(
`/orgs/${this.orgId}/invites/delete`,
this.authState!,
{
method: "POST",
body: JSON.stringify({
email: invite.email,
}),
},
);
this.notify.toast({
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) {
console.debug(e);
this.notify.toast({
message: isApiError(e)
? e.message
: msg(str`Sorry, couldn't remove ${invite.email} at this time.`),
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async renameOrg({ name, slug }: { name: string; slug: string }) {
try {
await this.api.fetch(`/orgs/${this.orgId}/rename`, this.authState!, {
method: "POST",
body: JSON.stringify({ name, slug }),
});
const user = await this.getCurrentUser();
AppStateService.updateUserInfo(formatAPIUser(user));
AppStateService.updateOrgSlug(slug);
this.navigate.to(`${this.navigate.orgBasePath}/settings`);
this.notify.toast({
message: msg("Org successfully updated."),
variant: "success",
icon: "check2-circle",
});
} catch (e) {
console.debug(e);
let message = msg(
"Sorry, couldn't rename organization at this time. Try again later from org settings.",
);
if (isApiError(e)) {
if (e.details === "duplicate_org_name") {
message = msg("This org name is already taken, try another one.");
} else if (e.details === "duplicate_org_slug") {
message = msg(
"This org URL identifier is already taken, try another one.",
);
} else if (e.details === "invalid_slug") {
message = msg(
"This org URL identifier is invalid. Please use alphanumeric characters and dashes (-) only.",
);
}
}
this.notify.toast({
message: message,
variant: "danger",
icon: "exclamation-octagon",
});
}
}
private async getCurrentUser(): Promise<APIUser> {
return this.api.fetch("/users/me", this.authState!);
}
}