Allow org admins to update slug (#1276)
- Allows editing of org slugs (actual URL updates will be handled in https://github.com/webrecorder/browsertrix-cloud/issues/1258.) - Converts user input to slug using slugify - Adds help text to org name and slug - Renames tab from "information" to "general" settings
This commit is contained in:
		
							parent
							
								
									0bd8748e68
								
							
						
					
					
						commit
						8466caf1d9
					
				@ -50,6 +50,7 @@
 | 
			
		||||
    "pretty-ms": "^7.0.1",
 | 
			
		||||
    "query-string": "^8.1.0",
 | 
			
		||||
    "regex-colorize": "^0.0.3",
 | 
			
		||||
    "slugify": "^1.6.6",
 | 
			
		||||
    "style-loader": "^3.3.0",
 | 
			
		||||
    "tailwindcss": "^3.2.7",
 | 
			
		||||
    "ts-loader": "^9.2.6",
 | 
			
		||||
 | 
			
		||||
@ -31,7 +31,7 @@ import "./components/new-collection-dialog";
 | 
			
		||||
import "./components/new-workflow-dialog";
 | 
			
		||||
import type {
 | 
			
		||||
  Member,
 | 
			
		||||
  OrgNameChangeEvent,
 | 
			
		||||
  OrgInfoChangeEvent,
 | 
			
		||||
  UserRoleChangeEvent,
 | 
			
		||||
  OrgRemoveMemberEvent,
 | 
			
		||||
} from "./settings";
 | 
			
		||||
@ -104,7 +104,7 @@ export class Org extends LiteElement {
 | 
			
		||||
  private org?: OrgData | null;
 | 
			
		||||
 | 
			
		||||
  @state()
 | 
			
		||||
  private isSavingOrgName = false;
 | 
			
		||||
  private isSavingOrgInfo = false;
 | 
			
		||||
 | 
			
		||||
  get userOrg() {
 | 
			
		||||
    if (!this.userInfo) return null;
 | 
			
		||||
@ -535,8 +535,8 @@ export class Org extends LiteElement {
 | 
			
		||||
      .orgId=${this.orgId}
 | 
			
		||||
      activePanel=${activePanel}
 | 
			
		||||
      ?isAddingMember=${isAddingMember}
 | 
			
		||||
      ?isSavingOrgName=${this.isSavingOrgName}
 | 
			
		||||
      @org-name-change=${this.onOrgNameChange}
 | 
			
		||||
      ?isSavingOrgName=${this.isSavingOrgInfo}
 | 
			
		||||
      @org-info-change=${this.onOrgInfoChange}
 | 
			
		||||
      @org-user-role-change=${this.onUserRoleChange}
 | 
			
		||||
      @org-remove-member=${this.onOrgRemoveMember}
 | 
			
		||||
    ></btrix-org-settings>`;
 | 
			
		||||
@ -554,18 +554,17 @@ export class Org extends LiteElement {
 | 
			
		||||
 | 
			
		||||
    return data;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async onOrgNameChange(e: OrgNameChangeEvent) {
 | 
			
		||||
    this.isSavingOrgName = true;
 | 
			
		||||
  private async onOrgInfoChange(e: OrgInfoChangeEvent) {
 | 
			
		||||
    this.isSavingOrgInfo = true;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await this.apiFetch(`/orgs/${this.org!.id}/rename`, this.authState!, {
 | 
			
		||||
        method: "POST",
 | 
			
		||||
        body: JSON.stringify({ name: e.detail.value }),
 | 
			
		||||
        body: JSON.stringify(e.detail),
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.notify({
 | 
			
		||||
        message: msg("Updated organization name."),
 | 
			
		||||
        message: msg("Updated organization."),
 | 
			
		||||
        variant: "success",
 | 
			
		||||
        icon: "check2-circle",
 | 
			
		||||
      });
 | 
			
		||||
@ -577,13 +576,13 @@ export class Org extends LiteElement {
 | 
			
		||||
      this.notify({
 | 
			
		||||
        message: e.isApiError
 | 
			
		||||
          ? e.message
 | 
			
		||||
          : msg("Sorry, couldn't update organization name at this time."),
 | 
			
		||||
          : msg("Sorry, couldn't update organization at this time."),
 | 
			
		||||
        variant: "danger",
 | 
			
		||||
        icon: "exclamation-octagon",
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.isSavingOrgName = false;
 | 
			
		||||
    this.isSavingOrgInfo = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async onOrgRemoveMember(e: OrgRemoveMemberEvent) {
 | 
			
		||||
 | 
			
		||||
@ -3,6 +3,8 @@ 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 slugify from "slugify";
 | 
			
		||||
import type { SlInput } from "@shoelace-style/shoelace";
 | 
			
		||||
 | 
			
		||||
import type { AuthState } from "../../utils/AuthService";
 | 
			
		||||
import LiteElement, { html } from "../../utils/LiteElement";
 | 
			
		||||
@ -24,8 +26,9 @@ type Invite = User & {
 | 
			
		||||
export type Member = User & {
 | 
			
		||||
  name: string;
 | 
			
		||||
};
 | 
			
		||||
export type OrgNameChangeEvent = CustomEvent<{
 | 
			
		||||
  value: string;
 | 
			
		||||
export type OrgInfoChangeEvent = CustomEvent<{
 | 
			
		||||
  name: string;
 | 
			
		||||
  slug?: string;
 | 
			
		||||
}>;
 | 
			
		||||
export type UserRoleChangeEvent = CustomEvent<{
 | 
			
		||||
  user: Member;
 | 
			
		||||
@ -48,7 +51,7 @@ export type OrgRemoveMemberEvent = CustomEvent<{
 | 
			
		||||
 * ```
 | 
			
		||||
 *
 | 
			
		||||
 * @events
 | 
			
		||||
 * org-name-change
 | 
			
		||||
 * org-info-change
 | 
			
		||||
 * org-user-role-change
 | 
			
		||||
 * org-remove-member
 | 
			
		||||
 */
 | 
			
		||||
@ -84,14 +87,17 @@ export class OrgSettings extends LiteElement {
 | 
			
		||||
  @state()
 | 
			
		||||
  private isSubmittingInvite = false;
 | 
			
		||||
 | 
			
		||||
  private get tabLabels() {
 | 
			
		||||
  @state()
 | 
			
		||||
  private slugValue = "";
 | 
			
		||||
 | 
			
		||||
  private get tabLabels(): Record<Tab, string> {
 | 
			
		||||
    return {
 | 
			
		||||
      information: msg("Org Information"),
 | 
			
		||||
      information: msg("General"),
 | 
			
		||||
      members: msg("Members"),
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private validateOrgNameMax = maxLengthValidator(50);
 | 
			
		||||
  private validateOrgNameMax = maxLengthValidator(40);
 | 
			
		||||
 | 
			
		||||
  async willUpdate(changedProperties: Map<string, any>) {
 | 
			
		||||
    if (changedProperties.has("isAddingMember") && this.isAddingMember) {
 | 
			
		||||
@ -164,28 +170,80 @@ export class OrgSettings extends LiteElement {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private renderInformation() {
 | 
			
		||||
    return html`<div class="rounded border p-5">
 | 
			
		||||
      <form class="inline-control-form" @submit=${this.onOrgNameSubmit}>
 | 
			
		||||
        <sl-input
 | 
			
		||||
          class="inline-control-input with-max-help-text"
 | 
			
		||||
          name="orgName"
 | 
			
		||||
          size="small"
 | 
			
		||||
          label=${msg("Org Name")}
 | 
			
		||||
          autocomplete="off"
 | 
			
		||||
          value=${this.org.name}
 | 
			
		||||
          required
 | 
			
		||||
          help-text=${this.validateOrgNameMax.helpText}
 | 
			
		||||
          @sl-input=${this.validateOrgNameMax.validate}
 | 
			
		||||
        ></sl-input>
 | 
			
		||||
        <sl-button
 | 
			
		||||
          class="inline-control-button"
 | 
			
		||||
          type="submit"
 | 
			
		||||
          size="small"
 | 
			
		||||
          variant="primary"
 | 
			
		||||
          ?disabled=${this.isSavingOrgName}
 | 
			
		||||
          ?loading=${this.isSavingOrgName}
 | 
			
		||||
          >${msg("Save Changes")}</sl-button
 | 
			
		||||
        >
 | 
			
		||||
    return html`<div class="rounded border">
 | 
			
		||||
      <form @submit=${this.onOrgInfoSubmit}>
 | 
			
		||||
        <div class="grid grid-cols-5 gap-x-4 p-4">
 | 
			
		||||
          <div class="col-span-5 md:col-span-3">
 | 
			
		||||
            <sl-input
 | 
			
		||||
              class="with-max-help-text"
 | 
			
		||||
              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>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="col-span-5 md:col-span-2 flex gap-2 pt-6">
 | 
			
		||||
            <div class="text-base">
 | 
			
		||||
              <sl-icon name="info-circle"></sl-icon>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="mt-0.5 text-xs text-neutral-500">
 | 
			
		||||
              ${msg(
 | 
			
		||||
                "Name of your organization that is visible to all org members."
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="col-span-5 md:col-span-3">
 | 
			
		||||
            <sl-input
 | 
			
		||||
              name="orgSlug"
 | 
			
		||||
              size="small"
 | 
			
		||||
              label=${msg("Org ID")}
 | 
			
		||||
              placeholder="my-organization"
 | 
			
		||||
              autocomplete="off"
 | 
			
		||||
              value=${this.org.slug}
 | 
			
		||||
              minlength="2"
 | 
			
		||||
              maxlength="30"
 | 
			
		||||
              required
 | 
			
		||||
              help-text=${msg(
 | 
			
		||||
                str`Org URL will be ${window.location.protocol}//${
 | 
			
		||||
                  window.location.hostname
 | 
			
		||||
                }/${
 | 
			
		||||
                  this.slugValue ? this.slugify(this.slugValue) : this.org.slug
 | 
			
		||||
                }`
 | 
			
		||||
              )}
 | 
			
		||||
              @sl-input=${(e: InputEvent) => {
 | 
			
		||||
                const input = e.target as SlInput;
 | 
			
		||||
                this.slugValue = input.value;
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
            </sl-input>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="col-span-5 md:col-span-2 flex gap-2 pt-6">
 | 
			
		||||
            <div class="text-base">
 | 
			
		||||
              <sl-icon name="info-circle"></sl-icon>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="mt-0.5 text-xs text-neutral-500">
 | 
			
		||||
              ${msg("Unique URL for this organization.")}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
        <footer class="border-t flex justify-end 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>`;
 | 
			
		||||
  }
 | 
			
		||||
@ -362,6 +420,12 @@ export class OrgSettings extends LiteElement {
 | 
			
		||||
    `;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private slugify(value: string) {
 | 
			
		||||
    return slugify(value, {
 | 
			
		||||
      strict: true,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async checkFormValidity(formEl: HTMLFormElement) {
 | 
			
		||||
    await this.updateComplete;
 | 
			
		||||
    return !formEl.querySelector("[data-invalid]");
 | 
			
		||||
@ -390,16 +454,23 @@ export class OrgSettings extends LiteElement {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async onOrgNameSubmit(e: SubmitEvent) {
 | 
			
		||||
  private async onOrgInfoSubmit(e: SubmitEvent) {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
 | 
			
		||||
    const formEl = e.target as HTMLFormElement;
 | 
			
		||||
    if (!(await this.checkFormValidity(formEl))) return;
 | 
			
		||||
 | 
			
		||||
    const { orgName } = serialize(formEl);
 | 
			
		||||
    const orgSlug = this.slugify(this.slugValue);
 | 
			
		||||
    const detail: any = { name: orgName };
 | 
			
		||||
 | 
			
		||||
    if (orgSlug !== this.org.slug) {
 | 
			
		||||
      detail.slug = orgSlug;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.dispatchEvent(
 | 
			
		||||
      <OrgNameChangeEvent>new CustomEvent("org-name-change", {
 | 
			
		||||
        detail: { value: orgName },
 | 
			
		||||
      <OrgInfoChangeEvent>new CustomEvent("org-info-change", {
 | 
			
		||||
        detail,
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -11,6 +11,7 @@ export const AccessCode: Record<UserRole, number> = {
 | 
			
		||||
export type OrgData = {
 | 
			
		||||
  id: string;
 | 
			
		||||
  name: string;
 | 
			
		||||
  slug: string;
 | 
			
		||||
  quotas: Record<string, number>;
 | 
			
		||||
  bytesStored: number;
 | 
			
		||||
  users?: {
 | 
			
		||||
 | 
			
		||||
@ -6191,6 +6191,11 @@ slice-ansi@^5.0.0:
 | 
			
		||||
    ansi-styles "^6.0.0"
 | 
			
		||||
    is-fullwidth-code-point "^4.0.0"
 | 
			
		||||
 | 
			
		||||
slugify@^1.6.6:
 | 
			
		||||
  version "1.6.6"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
 | 
			
		||||
  integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
 | 
			
		||||
 | 
			
		||||
sockjs@^0.3.24:
 | 
			
		||||
  version "0.3.24"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user