Updates browser title when visiting the following pages: - Superadmin dashboard - Org top-level pages - Account settings
424 lines
12 KiB
TypeScript
424 lines
12 KiB
TypeScript
import { localized, msg, str } from "@lit/localize";
|
|
import type { SlInput, SlInputEvent } from "@shoelace-style/shoelace";
|
|
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
|
|
import { type PropertyValues } from "lit";
|
|
import { customElement, state } from "lit/decorators.js";
|
|
|
|
import needLogin from "@/decorators/needLogin";
|
|
import type { InviteSuccessDetail } from "@/features/accounts/invite-form";
|
|
import type { APIUser } from "@/index";
|
|
import type { APIPaginatedList } from "@/types/api";
|
|
import { isApiError } from "@/utils/api";
|
|
import { maxLengthValidator } from "@/utils/form";
|
|
import LiteElement, { html } from "@/utils/LiteElement";
|
|
import { type OrgData } from "@/utils/orgs";
|
|
import slugifyStrict from "@/utils/slugify";
|
|
import { AppStateService } from "@/utils/state";
|
|
import { formatAPIUser } from "@/utils/user";
|
|
|
|
/**
|
|
* Home page when org is not selected.
|
|
*
|
|
* Uses custom redirect instead of needLogin decorator to suppress "need login"
|
|
* message when accessing root URL.
|
|
*
|
|
* Only accessed by superadmins. Regular users will be redirected their org.
|
|
* See https://github.com/webrecorder/browsertrix/issues/1972
|
|
*/
|
|
@localized()
|
|
@customElement("btrix-home")
|
|
@needLogin
|
|
export class Home extends LiteElement {
|
|
@state()
|
|
private orgList?: OrgData[];
|
|
|
|
@state()
|
|
private orgSlugs: string[] = [];
|
|
|
|
@state()
|
|
private isAddingOrg = false;
|
|
|
|
@state()
|
|
private isAddOrgFormVisible = false;
|
|
|
|
@state()
|
|
private isSubmittingNewOrg = false;
|
|
|
|
@state()
|
|
private isOrgNameValid: boolean | null = null;
|
|
|
|
private get slug() {
|
|
return this.appState.orgSlug;
|
|
}
|
|
|
|
private readonly validateOrgNameMax = maxLengthValidator(40);
|
|
|
|
connectedCallback() {
|
|
if (this.authState) {
|
|
if (this.slug) {
|
|
this.navTo(`/orgs/${this.slug}`);
|
|
} else {
|
|
super.connectedCallback();
|
|
}
|
|
} else {
|
|
this.navTo("/log-in");
|
|
}
|
|
}
|
|
|
|
willUpdate(changedProperties: PropertyValues) {
|
|
if (changedProperties.has("appState.userInfo") && this.userInfo) {
|
|
if (this.userInfo.isSuperAdmin) {
|
|
this.initSuperAdmin();
|
|
} else if (this.userInfo.orgs.length) {
|
|
this.navTo(`/orgs/${this.userInfo.orgs[0].slug}`);
|
|
} else {
|
|
this.navTo(`/account/settings`);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected firstUpdated(): void {
|
|
this.initSuperAdmin();
|
|
}
|
|
|
|
private initSuperAdmin() {
|
|
if (this.userInfo?.isSuperAdmin && !this.orgList) {
|
|
if (this.userInfo.orgs.length) {
|
|
void this.fetchOrgs();
|
|
} else {
|
|
this.isAddingOrg = true;
|
|
this.isAddOrgFormVisible = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (!this.userInfo || !this.userInfo.isSuperAdmin) {
|
|
return;
|
|
}
|
|
|
|
if (this.userInfo.orgs.length && !this.orgList) {
|
|
return html`
|
|
<btrix-document-title
|
|
title=${msg("Admin dashboard")}
|
|
></btrix-document-title>
|
|
|
|
<div class="my-24 flex items-center justify-center text-3xl">
|
|
<sl-spinner></sl-spinner>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return html`
|
|
<btrix-document-title
|
|
title=${msg("Admin dashboard")}
|
|
></btrix-document-title>
|
|
|
|
<div class="bg-white">
|
|
<header
|
|
class="mx-auto box-border w-full max-w-screen-desktop px-3 py-4 md:py-8"
|
|
>
|
|
<h1 class="text-xl font-medium">${msg("Welcome")}</h1>
|
|
</header>
|
|
<hr />
|
|
</div>
|
|
<main class="mx-auto box-border w-full max-w-screen-desktop px-3 py-4">
|
|
${this.renderAdminOrgs()}
|
|
</main>
|
|
${this.renderAddOrgDialog()}
|
|
`;
|
|
}
|
|
|
|
private renderAdminOrgs() {
|
|
return html`
|
|
<section class="mb-5 rounded-lg border bg-white p-4 md:p-6">
|
|
<form
|
|
@submit=${(e: SubmitEvent) => {
|
|
const formData = new FormData(e.target as HTMLFormElement);
|
|
const id = formData.get("crawlId");
|
|
this.navTo(`/crawls/crawl/${id?.toString()}`);
|
|
}}
|
|
>
|
|
<div class="flex flex-wrap items-center">
|
|
<div
|
|
class="mr-8 w-full grow-0 whitespace-nowrap text-lg font-medium md:w-min"
|
|
>
|
|
${msg("Go to Crawl")}
|
|
</div>
|
|
<div class="mt-2 grow md:mr-2 md:mt-0">
|
|
<sl-input
|
|
name="crawlId"
|
|
placeholder=${msg("Enter Crawl ID")}
|
|
required
|
|
></sl-input>
|
|
</div>
|
|
<div class="mt-2 grow-0 text-right md:mt-0">
|
|
<sl-button variant="neutral" type="submit">
|
|
<sl-icon slot="suffix" name="arrow-right"></sl-icon>
|
|
${msg("Go")}</sl-button
|
|
>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<div class="grid grid-cols-3 gap-6">
|
|
<div class="col-span-3 md:col-span-2">
|
|
<section>
|
|
<header
|
|
class="mb-3 flex items-center justify-between border-b pb-3"
|
|
>
|
|
<h2 class="text-lg font-medium">${msg("All Organizations")}</h2>
|
|
<sl-button
|
|
variant="primary"
|
|
size="small"
|
|
@click=${() => (this.isAddingOrg = true)}
|
|
>
|
|
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
|
|
${msg("New Organization")}
|
|
</sl-button>
|
|
</header>
|
|
<btrix-orgs-list
|
|
.orgList=${this.orgList}
|
|
@update-quotas=${this.onUpdateOrgQuotas}
|
|
></btrix-orgs-list>
|
|
</section>
|
|
</div>
|
|
<div class="col-span-3 md:col-span-1">
|
|
<section class="p-3 md:rounded-lg md:border md:bg-white md:p-8">
|
|
<h2 class="mb-3 text-lg font-medium">
|
|
${msg("Invite User to Org")}
|
|
</h2>
|
|
${this.renderInvite()}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderAddOrgDialog() {
|
|
let orgNameStatusLabel = msg("Start typing to see availability");
|
|
let orgNameStatusIcon = html`
|
|
<sl-icon class="mr-3 text-neutral-300" name="check-lg"></sl-icon>
|
|
`;
|
|
|
|
if (this.isOrgNameValid) {
|
|
orgNameStatusLabel = msg("This org name is available");
|
|
orgNameStatusIcon = html`
|
|
<sl-icon class="mr-3 text-success" name="check-lg"></sl-icon>
|
|
`;
|
|
} else if (this.isOrgNameValid === false) {
|
|
orgNameStatusLabel = msg("This org name is taken");
|
|
orgNameStatusIcon = html`
|
|
<sl-icon class="mr-3 text-danger" name="x-lg"></sl-icon>
|
|
`;
|
|
}
|
|
|
|
return html`
|
|
<btrix-dialog
|
|
.label=${msg("New Organization")}
|
|
.open=${this.isAddingOrg}
|
|
@sl-request-close=${(e: CustomEvent) => {
|
|
// Disable closing if there are no orgs
|
|
if (this.orgList?.length) {
|
|
this.isAddingOrg = false;
|
|
} else {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
@sl-show=${() => (this.isAddOrgFormVisible = true)}
|
|
@sl-after-hide=${() => {
|
|
this.isAddOrgFormVisible = false;
|
|
this.isOrgNameValid = null;
|
|
}}
|
|
>
|
|
${this.isAddOrgFormVisible
|
|
? html`
|
|
<form
|
|
id="newOrgForm"
|
|
@reset=${() => (this.isAddingOrg = false)}
|
|
@submit=${this.onSubmitNewOrg}
|
|
>
|
|
<div class="mb-5">
|
|
<sl-input
|
|
class="with-max-help-text"
|
|
name="name"
|
|
label=${msg("Org Name")}
|
|
placeholder=${msg("My Organization")}
|
|
autocomplete="off"
|
|
required
|
|
help-text=${this.validateOrgNameMax.helpText}
|
|
@sl-input=${this.onOrgNameInput}
|
|
>
|
|
<sl-tooltip
|
|
slot="suffix"
|
|
content=${orgNameStatusLabel}
|
|
@sl-hide=${(e: CustomEvent) => e.stopPropagation()}
|
|
@sl-after-hide=${(e: CustomEvent) => e.stopPropagation()}
|
|
hoist
|
|
>
|
|
${orgNameStatusIcon}
|
|
</sl-tooltip>
|
|
</sl-input>
|
|
</div>
|
|
</form>
|
|
<div slot="footer" class="flex justify-between">
|
|
${this.orgList?.length
|
|
? html`<sl-button form="newOrgForm" type="reset" size="small">
|
|
${msg("Cancel")}
|
|
</sl-button>`
|
|
: ""}
|
|
|
|
<sl-button
|
|
form="newOrgForm"
|
|
variant="primary"
|
|
type="submit"
|
|
size="small"
|
|
?loading=${this.isSubmittingNewOrg}
|
|
?disabled=${this.isSubmittingNewOrg}
|
|
>
|
|
${msg("Create Org")}
|
|
</sl-button>
|
|
</div>
|
|
`
|
|
: ""}
|
|
</btrix-dialog>
|
|
`;
|
|
}
|
|
|
|
private renderInvite() {
|
|
return html`
|
|
<btrix-invite-form
|
|
.orgs=${this.orgList}
|
|
@btrix-invite-success=${(e: CustomEvent<InviteSuccessDetail>) => {
|
|
const org = this.orgList?.find(({ id }) => id === e.detail.orgId);
|
|
|
|
this.notify({
|
|
message: html`
|
|
${msg("Invite sent!")}
|
|
<br />
|
|
<a
|
|
class="underline hover:no-underline"
|
|
href="/orgs/${org?.slug || e.detail.orgId}/settings/members"
|
|
@click=${this.navLink.bind(this)}
|
|
>
|
|
${msg("View org members")}
|
|
</a>
|
|
`,
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
});
|
|
}}
|
|
></btrix-invite-form>
|
|
`;
|
|
}
|
|
|
|
private async fetchOrgs() {
|
|
try {
|
|
this.orgList = await this.getOrgs();
|
|
this.orgSlugs = await this.getOrgSlugs();
|
|
} catch (e) {
|
|
console.debug(e);
|
|
}
|
|
}
|
|
|
|
private async getOrgs() {
|
|
const data =
|
|
await this.apiFetch<APIPaginatedList<OrgData>>("/orgs?sortBy=name");
|
|
|
|
return data.items;
|
|
}
|
|
|
|
private async getOrgSlugs() {
|
|
const data = await this.apiFetch<{ slugs: string[] }>("/orgs/slugs");
|
|
|
|
return data.slugs;
|
|
}
|
|
|
|
private async onOrgNameInput(e: SlInputEvent) {
|
|
this.validateOrgNameMax.validate(e);
|
|
|
|
const input = e.target as SlInput;
|
|
const slug = slugifyStrict(input.value);
|
|
const isInvalid = this.orgSlugs.includes(slug);
|
|
|
|
if (isInvalid) {
|
|
input.setCustomValidity(msg("This org name is already taken."));
|
|
} else {
|
|
input.setCustomValidity("");
|
|
}
|
|
|
|
this.isOrgNameValid = !isInvalid;
|
|
}
|
|
|
|
private async onSubmitNewOrg(e: SubmitEvent) {
|
|
e.preventDefault();
|
|
|
|
const formEl = e.target as HTMLFormElement;
|
|
if (!(await this.checkFormValidity(formEl))) return;
|
|
|
|
const params = serialize(formEl);
|
|
this.isSubmittingNewOrg = true;
|
|
|
|
try {
|
|
// TODO return entire object from API
|
|
await this.apiFetch<{ added: true; id: string }>(`/orgs/create`, {
|
|
method: "POST",
|
|
body: JSON.stringify(params),
|
|
});
|
|
const userInfo = await this.getUserInfo();
|
|
AppStateService.updateUser(formatAPIUser(userInfo));
|
|
|
|
this.notify({
|
|
message: msg(str`Created new org named "${params.name}".`),
|
|
variant: "success",
|
|
icon: "check2-circle",
|
|
duration: 8000,
|
|
});
|
|
this.isAddingOrg = false;
|
|
} catch (e) {
|
|
let message = msg("Sorry, couldn't create organization at this time.");
|
|
|
|
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({
|
|
message,
|
|
variant: "danger",
|
|
icon: "exclamation-octagon",
|
|
});
|
|
}
|
|
|
|
this.isSubmittingNewOrg = false;
|
|
}
|
|
|
|
async onUpdateOrgQuotas(e: CustomEvent) {
|
|
const org = e.detail as OrgData;
|
|
|
|
await this.apiFetch(`/orgs/${org.id}/quotas`, {
|
|
method: "POST",
|
|
body: JSON.stringify(org.quotas),
|
|
});
|
|
}
|
|
|
|
async checkFormValidity(formEl: HTMLFormElement) {
|
|
await this.updateComplete;
|
|
return !formEl.querySelector("[data-invalid]");
|
|
}
|
|
|
|
async getUserInfo(): Promise<APIUser> {
|
|
return this.apiFetch("/users/me");
|
|
}
|
|
}
|