Allow superadmins to create org from UI (#563)

This commit is contained in:
sua yoo 2023-02-06 14:58:28 -08:00 committed by GitHub
parent 4875d7727d
commit 17e1628d2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 213 additions and 64 deletions

View File

@ -55,7 +55,7 @@ export class InviteForm extends LiteElement {
<div>
<sl-button
variant="primary"
size="small"
type="submit"
?loading=${this.isSubmitting}
?disabled=${this.isSubmitting}

View File

@ -41,7 +41,7 @@ export class OrgsList extends LiteElement {
(this.userInfo.isAdmin ||
isOwner(org.users[this.userInfo.id].role))
? html`<sl-tag size="small" variant="primary"
>${msg("Owner")}</sl-tag
>${msg("Admin")}</sl-tag
>`
: ""}
</li>

View File

@ -268,7 +268,14 @@ export class App extends LiteElement {
class="max-w-screen-lg mx-auto pl-3 box-border h-12 flex items-center justify-between"
>
<div>
<a href="${homeHref}" @click="${this.navLink}"
<a
href=${homeHref}
@click=${(e: any) => {
if (isAdmin) {
this.clearSelectedOrg();
}
this.navLink(e);
}}
><h1 class="text-sm hover:text-neutral-400 font-medium">
${msg("Browsertrix Cloud")}
</h1></a
@ -280,6 +287,15 @@ export class App extends LiteElement {
<div
class="text-xs md:text-sm grid grid-flow-col gap-3 md:gap-5 items-center"
>
<a
class="text-neutral-500 hover:text-neutral-400 font-medium"
href="/"
@click=${(e: any) => {
this.clearSelectedOrg();
this.navLink(e);
}}
>${msg("Dashboard")}</a
>
<a
class="text-neutral-500 hover:text-neutral-400 font-medium"
href="/crawls"
@ -366,11 +382,16 @@ export class App extends LiteElement {
<sl-menu
@sl-select=${(e: CustomEvent) => {
const { value } = e.detail.item;
this.navigate(`/orgs/${value}${value ? "/crawls" : ""}`);
if (this.userInfo) {
this.persistUserSettings(this.userInfo.id, { orgId: value });
if (value) {
this.navigate(`/orgs/${value}/crawls`);
if (this.userInfo) {
this.persistUserSettings(this.userInfo.id, { orgId: value });
}
} else {
console.debug("User info not set");
if (this.userInfo) {
this.clearSelectedOrg();
}
this.navigate(`/`);
}
}}
>
@ -544,6 +565,11 @@ export class App extends LiteElement {
class="w-full md:bg-neutral-50"
@navigate=${this.onNavigateTo}
@logged-in=${this.onLoggedIn}
@update-user-info=${(e: CustomEvent) => {
e.stopPropagation();
this.updateUserInfo();
}}
@notify="${this.onNotify}"
.authState=${this.authService.authState}
.userInfo=${this.userInfo}
.orgId=${this.selectedOrgId}
@ -878,6 +904,13 @@ export class App extends LiteElement {
private unpersistUserSettings(userId: string) {
window.localStorage.removeItem(`${App.storageKey}.${userId}`);
}
private clearSelectedOrg() {
this.selectedOrgId = undefined;
if (this.userInfo) {
this.persistUserSettings(this.userInfo.id, { orgId: "" });
}
}
}
customElements.define("browsertrix-app", App);

View File

@ -1,5 +1,6 @@
import { state, property } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import { msg, localized, str } from "@lit/localize";
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
import type { AuthState } from "../utils/AuthService";
import type { CurrentUser } from "../types/user";
@ -23,6 +24,15 @@ export class Home extends LiteElement {
@state()
private orgList?: OrgData[];
@state()
private isAddingOrg = false;
@state()
private isAddOrgFormVisible = false;
@state()
private isSubmittingNewOrg = false;
connectedCallback() {
if (this.authState) {
super.connectedCallback();
@ -81,56 +91,7 @@ export class Home extends LiteElement {
private renderLoggedInAdmin() {
if (this.orgList!.length) {
return html`
<section class="border rounded-lg bg-white p-4 md:p-6 mb-5">
<form
@submit=${(e: SubmitEvent) => {
const formData = new FormData(e.target as HTMLFormElement);
const id = formData.get("crawlId");
this.navTo(`/crawls/crawl/${id}`);
}}
>
<div class="flex flex-wrap items-center">
<div
class="w-full md:w-min grow-0 mr-8 text-lg font-medium whitespace-nowrap"
>
${msg("Go to Crawl")}
</div>
<div class="grow mt-2 md:mt-0 md:mr-2">
<sl-input
name="crawlId"
placeholder=${msg("Enter Crawl ID")}
required
></sl-input>
</div>
<div class="grow-0 mt-2 md:mt-0 text-right">
<sl-button variant="neutral" type="submit">
<sl-icon slot="prefix" name="arrow-right-circle"></sl-icon>
${msg("Go")}</sl-button
>
</div>
</div>
</form>
</section>
<div class="grid grid-cols-3 gap-8">
<div class="col-span-3 md:col-span-2">
<section>
<h2 class="text-lg font-medium mb-3 mt-2">${msg("All Organizations")}</h2>
<btrix-orgs-list
.userInfo=${this.userInfo}
.orgList=${this.orgList}
></btrix-orgs-list>
</section>
</div>
<div class="col-span-3 md:col-span-1 md:mt-12">
<section class="md:border md:rounded-lg md:bg-white p-3 md:p-8">
<h2 class="text-lg font-medium mb-4">${msg("Invite a User")}</h2>
${this.renderInvite()}
</section>
</div>
</div>
`;
return this.renderAdminOrgs();
}
return html`
@ -144,6 +105,113 @@ export class Home extends LiteElement {
`;
}
private renderAdminOrgs() {
return html`
<section class="border rounded-lg bg-white p-4 md:p-6 mb-5">
<form
@submit=${(e: SubmitEvent) => {
const formData = new FormData(e.target as HTMLFormElement);
const id = formData.get("crawlId");
this.navTo(`/crawls/crawl/${id}`);
}}
>
<div class="flex flex-wrap items-center">
<div
class="w-full md:w-min grow-0 mr-8 text-lg font-medium whitespace-nowrap"
>
${msg("Go to Crawl")}
</div>
<div class="grow mt-2 md:mt-0 md:mr-2">
<sl-input
name="crawlId"
placeholder=${msg("Enter Crawl ID")}
required
></sl-input>
</div>
<div class="grow-0 mt-2 md:mt-0 text-right">
<sl-button variant="neutral" type="submit">
<sl-icon slot="prefix" name="arrow-right-circle"></sl-icon>
${msg("Go")}</sl-button
>
</div>
</div>
</form>
</section>
<div class="grid grid-cols-3 gap-8">
<div class="col-span-3 md:col-span-2">
<section>
<header class="flex items-start justify-between">
<h2 class="text-lg font-medium mb-3 mt-2">
${msg("All Organizations")}
</h2>
<sl-button
variant="primary"
@click=${() => (this.isAddingOrg = true)}
>
<sl-icon slot="prefix" name="plus-lg"></sl-icon>
${msg("New Organization")}
</sl-button>
</header>
<btrix-orgs-list
.userInfo=${this.userInfo}
.orgList=${this.orgList}
></btrix-orgs-list>
</section>
</div>
<div class="col-span-3 md:col-span-1">
<section class="md:border md:rounded-lg md:bg-white p-3 md:p-8">
<h2 class="text-lg font-medium mb-4">${msg("Invite a User")}</h2>
${this.renderInvite()}
</section>
</div>
</div>
<btrix-dialog
label=${msg("New Organization")}
?open=${this.isAddingOrg}
@sl-request-close=${() => (this.isAddingOrg = false)}
@sl-show=${() => (this.isAddOrgFormVisible = true)}
@sl-after-hide=${() => (this.isAddOrgFormVisible = false)}
>
${this.isAddOrgFormVisible
? html`
<form
id="newOrgForm"
@reset=${() => (this.isAddingOrg = false)}
@submit=${this.onSubmitNewOrg}
>
<div class="mb-5">
<sl-input
name="name"
label=${msg("Org Name")}
placeholder=${msg("My Organization")}
autocomplete="off"
required
>
</sl-input>
</div>
</form>
<div slot="footer" class="flex justify-between">
<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 renderLoggedInNonAdmin() {
if (this.orgList && !this.orgList.length) {
return html`<div class="border rounded-lg bg-white p-4 md:p-8">
@ -188,4 +256,45 @@ export class Home extends LiteElement {
return data.orgs;
}
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 {
await this.apiFetch(`/orgs/create`, this.authState!, {
method: "POST",
body: JSON.stringify(params),
});
this.fetchOrgs();
this.notify({
message: msg(str`Created new org named "${params.name}".`),
variant: "success",
icon: "check2-circle",
duration: 8000,
});
this.isAddingOrg = false;
} catch (e: any) {
this.notify({
message: e.isApiError
? e.message
: msg("Sorry, couldn't create organization at this time."),
variant: "danger",
icon: "exclamation-octagon",
});
}
this.isSubmittingNewOrg = false;
}
async checkFormValidity(formEl: HTMLFormElement) {
await this.updateComplete;
return !formEl.querySelector("[data-invalid]");
}
}

View File

@ -6,7 +6,7 @@ import type { ViewState } from "../../utils/APIRouter";
import type { AuthState } from "../../utils/AuthService";
import type { CurrentUser } from "../../types/user";
import type { OrgData } from "../../utils/orgs";
import { isOwner, isCrawler } from "../../utils/orgs";
import { isAdmin, isCrawler } from "../../utils/orgs";
import LiteElement, { html } from "../../utils/LiteElement";
import { needLogin } from "../../utils/auth";
import "./crawl-configs-detail";
@ -67,9 +67,9 @@ export class Org extends LiteElement {
return this.userInfo.orgs.find(({ id }) => id === this.orgId)!;
}
get isOwner() {
get isAdmin() {
const userOrg = this.userOrg;
if (userOrg) return isOwner(userOrg.role);
if (userOrg) return isAdmin(userOrg.role);
return false;
}
@ -125,7 +125,7 @@ export class Org extends LiteElement {
tabPanelContent = this.renderBrowserProfiles();
break;
case "settings": {
if (this.isOwner) {
if (this.isAdmin) {
tabPanelContent = this.renderOrgSettings();
break;
}
@ -167,7 +167,7 @@ export class Org extends LiteElement {
label: msg("Browser Profiles"),
})
)}
${when(this.isOwner, () =>
${when(this.isAdmin || this.userInfo?.isAdmin, () =>
this.renderNavTab({
tabName: "settings",
label: msg("Org Settings"),

View File

@ -1,7 +1,8 @@
// From UserRole in backend
export type UserRole = "viewer" | "crawler" | "owner";
export type UserRole = "viewer" | "crawler" | "owner" | "superadmin";
export const AccessCode: Record<UserRole, number> = {
superadmin: 100,
viewer: 10,
crawler: 20,
owner: 40,

View File

@ -7,6 +7,12 @@ export function isOwner(accessCode?: typeof AccessCode[UserRole]): boolean {
return accessCode === AccessCode.owner;
}
export function isAdmin(accessCode?: typeof AccessCode[UserRole]): boolean {
if (!accessCode) return false;
return accessCode >= AccessCode.owner;
}
export function isCrawler(accessCode?: typeof AccessCode[UserRole]): boolean {
if (!accessCode) return false;