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",
 | 
					    "pretty-ms": "^7.0.1",
 | 
				
			||||||
    "query-string": "^8.1.0",
 | 
					    "query-string": "^8.1.0",
 | 
				
			||||||
    "regex-colorize": "^0.0.3",
 | 
					    "regex-colorize": "^0.0.3",
 | 
				
			||||||
 | 
					    "slugify": "^1.6.6",
 | 
				
			||||||
    "style-loader": "^3.3.0",
 | 
					    "style-loader": "^3.3.0",
 | 
				
			||||||
    "tailwindcss": "^3.2.7",
 | 
					    "tailwindcss": "^3.2.7",
 | 
				
			||||||
    "ts-loader": "^9.2.6",
 | 
					    "ts-loader": "^9.2.6",
 | 
				
			||||||
 | 
				
			|||||||
@ -31,7 +31,7 @@ import "./components/new-collection-dialog";
 | 
				
			|||||||
import "./components/new-workflow-dialog";
 | 
					import "./components/new-workflow-dialog";
 | 
				
			||||||
import type {
 | 
					import type {
 | 
				
			||||||
  Member,
 | 
					  Member,
 | 
				
			||||||
  OrgNameChangeEvent,
 | 
					  OrgInfoChangeEvent,
 | 
				
			||||||
  UserRoleChangeEvent,
 | 
					  UserRoleChangeEvent,
 | 
				
			||||||
  OrgRemoveMemberEvent,
 | 
					  OrgRemoveMemberEvent,
 | 
				
			||||||
} from "./settings";
 | 
					} from "./settings";
 | 
				
			||||||
@ -104,7 +104,7 @@ export class Org extends LiteElement {
 | 
				
			|||||||
  private org?: OrgData | null;
 | 
					  private org?: OrgData | null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @state()
 | 
					  @state()
 | 
				
			||||||
  private isSavingOrgName = false;
 | 
					  private isSavingOrgInfo = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  get userOrg() {
 | 
					  get userOrg() {
 | 
				
			||||||
    if (!this.userInfo) return null;
 | 
					    if (!this.userInfo) return null;
 | 
				
			||||||
@ -535,8 +535,8 @@ export class Org extends LiteElement {
 | 
				
			|||||||
      .orgId=${this.orgId}
 | 
					      .orgId=${this.orgId}
 | 
				
			||||||
      activePanel=${activePanel}
 | 
					      activePanel=${activePanel}
 | 
				
			||||||
      ?isAddingMember=${isAddingMember}
 | 
					      ?isAddingMember=${isAddingMember}
 | 
				
			||||||
      ?isSavingOrgName=${this.isSavingOrgName}
 | 
					      ?isSavingOrgName=${this.isSavingOrgInfo}
 | 
				
			||||||
      @org-name-change=${this.onOrgNameChange}
 | 
					      @org-info-change=${this.onOrgInfoChange}
 | 
				
			||||||
      @org-user-role-change=${this.onUserRoleChange}
 | 
					      @org-user-role-change=${this.onUserRoleChange}
 | 
				
			||||||
      @org-remove-member=${this.onOrgRemoveMember}
 | 
					      @org-remove-member=${this.onOrgRemoveMember}
 | 
				
			||||||
    ></btrix-org-settings>`;
 | 
					    ></btrix-org-settings>`;
 | 
				
			||||||
@ -554,18 +554,17 @@ export class Org extends LiteElement {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    return data;
 | 
					    return data;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					  private async onOrgInfoChange(e: OrgInfoChangeEvent) {
 | 
				
			||||||
  private async onOrgNameChange(e: OrgNameChangeEvent) {
 | 
					    this.isSavingOrgInfo = true;
 | 
				
			||||||
    this.isSavingOrgName = true;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      await this.apiFetch(`/orgs/${this.org!.id}/rename`, this.authState!, {
 | 
					      await this.apiFetch(`/orgs/${this.org!.id}/rename`, this.authState!, {
 | 
				
			||||||
        method: "POST",
 | 
					        method: "POST",
 | 
				
			||||||
        body: JSON.stringify({ name: e.detail.value }),
 | 
					        body: JSON.stringify(e.detail),
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.notify({
 | 
					      this.notify({
 | 
				
			||||||
        message: msg("Updated organization name."),
 | 
					        message: msg("Updated organization."),
 | 
				
			||||||
        variant: "success",
 | 
					        variant: "success",
 | 
				
			||||||
        icon: "check2-circle",
 | 
					        icon: "check2-circle",
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
@ -577,13 +576,13 @@ export class Org extends LiteElement {
 | 
				
			|||||||
      this.notify({
 | 
					      this.notify({
 | 
				
			||||||
        message: e.isApiError
 | 
					        message: e.isApiError
 | 
				
			||||||
          ? e.message
 | 
					          ? e.message
 | 
				
			||||||
          : msg("Sorry, couldn't update organization name at this time."),
 | 
					          : msg("Sorry, couldn't update organization at this time."),
 | 
				
			||||||
        variant: "danger",
 | 
					        variant: "danger",
 | 
				
			||||||
        icon: "exclamation-octagon",
 | 
					        icon: "exclamation-octagon",
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.isSavingOrgName = false;
 | 
					    this.isSavingOrgInfo = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async onOrgRemoveMember(e: OrgRemoveMemberEvent) {
 | 
					  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 { msg, localized, str } from "@lit/localize";
 | 
				
			||||||
import { when } from "lit/directives/when.js";
 | 
					import { when } from "lit/directives/when.js";
 | 
				
			||||||
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.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 type { AuthState } from "../../utils/AuthService";
 | 
				
			||||||
import LiteElement, { html } from "../../utils/LiteElement";
 | 
					import LiteElement, { html } from "../../utils/LiteElement";
 | 
				
			||||||
@ -24,8 +26,9 @@ type Invite = User & {
 | 
				
			|||||||
export type Member = User & {
 | 
					export type Member = User & {
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export type OrgNameChangeEvent = CustomEvent<{
 | 
					export type OrgInfoChangeEvent = CustomEvent<{
 | 
				
			||||||
  value: string;
 | 
					  name: string;
 | 
				
			||||||
 | 
					  slug?: string;
 | 
				
			||||||
}>;
 | 
					}>;
 | 
				
			||||||
export type UserRoleChangeEvent = CustomEvent<{
 | 
					export type UserRoleChangeEvent = CustomEvent<{
 | 
				
			||||||
  user: Member;
 | 
					  user: Member;
 | 
				
			||||||
@ -48,7 +51,7 @@ export type OrgRemoveMemberEvent = CustomEvent<{
 | 
				
			|||||||
 * ```
 | 
					 * ```
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * @events
 | 
					 * @events
 | 
				
			||||||
 * org-name-change
 | 
					 * org-info-change
 | 
				
			||||||
 * org-user-role-change
 | 
					 * org-user-role-change
 | 
				
			||||||
 * org-remove-member
 | 
					 * org-remove-member
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
@ -84,14 +87,17 @@ export class OrgSettings extends LiteElement {
 | 
				
			|||||||
  @state()
 | 
					  @state()
 | 
				
			||||||
  private isSubmittingInvite = false;
 | 
					  private isSubmittingInvite = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private get tabLabels() {
 | 
					  @state()
 | 
				
			||||||
 | 
					  private slugValue = "";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private get tabLabels(): Record<Tab, string> {
 | 
				
			||||||
    return {
 | 
					    return {
 | 
				
			||||||
      information: msg("Org Information"),
 | 
					      information: msg("General"),
 | 
				
			||||||
      members: msg("Members"),
 | 
					      members: msg("Members"),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private validateOrgNameMax = maxLengthValidator(50);
 | 
					  private validateOrgNameMax = maxLengthValidator(40);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async willUpdate(changedProperties: Map<string, any>) {
 | 
					  async willUpdate(changedProperties: Map<string, any>) {
 | 
				
			||||||
    if (changedProperties.has("isAddingMember") && this.isAddingMember) {
 | 
					    if (changedProperties.has("isAddingMember") && this.isAddingMember) {
 | 
				
			||||||
@ -164,28 +170,80 @@ export class OrgSettings extends LiteElement {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private renderInformation() {
 | 
					  private renderInformation() {
 | 
				
			||||||
    return html`<div class="rounded border p-5">
 | 
					    return html`<div class="rounded border">
 | 
				
			||||||
      <form class="inline-control-form" @submit=${this.onOrgNameSubmit}>
 | 
					      <form @submit=${this.onOrgInfoSubmit}>
 | 
				
			||||||
        <sl-input
 | 
					        <div class="grid grid-cols-5 gap-x-4 p-4">
 | 
				
			||||||
          class="inline-control-input with-max-help-text"
 | 
					          <div class="col-span-5 md:col-span-3">
 | 
				
			||||||
          name="orgName"
 | 
					            <sl-input
 | 
				
			||||||
          size="small"
 | 
					              class="with-max-help-text"
 | 
				
			||||||
          label=${msg("Org Name")}
 | 
					              name="orgName"
 | 
				
			||||||
          autocomplete="off"
 | 
					              size="small"
 | 
				
			||||||
          value=${this.org.name}
 | 
					              label=${msg("Org Name")}
 | 
				
			||||||
          required
 | 
					              placeholder=${msg("My Organization")}
 | 
				
			||||||
          help-text=${this.validateOrgNameMax.helpText}
 | 
					              autocomplete="off"
 | 
				
			||||||
          @sl-input=${this.validateOrgNameMax.validate}
 | 
					              value=${this.org.name}
 | 
				
			||||||
        ></sl-input>
 | 
					              minlength="2"
 | 
				
			||||||
        <sl-button
 | 
					              required
 | 
				
			||||||
          class="inline-control-button"
 | 
					              help-text=${this.validateOrgNameMax.helpText}
 | 
				
			||||||
          type="submit"
 | 
					              @sl-input=${this.validateOrgNameMax.validate}
 | 
				
			||||||
          size="small"
 | 
					            ></sl-input>
 | 
				
			||||||
          variant="primary"
 | 
					          </div>
 | 
				
			||||||
          ?disabled=${this.isSavingOrgName}
 | 
					          <div class="col-span-5 md:col-span-2 flex gap-2 pt-6">
 | 
				
			||||||
          ?loading=${this.isSavingOrgName}
 | 
					            <div class="text-base">
 | 
				
			||||||
          >${msg("Save Changes")}</sl-button
 | 
					              <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>
 | 
					      </form>
 | 
				
			||||||
    </div>`;
 | 
					    </div>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -362,6 +420,12 @@ export class OrgSettings extends LiteElement {
 | 
				
			|||||||
    `;
 | 
					    `;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private slugify(value: string) {
 | 
				
			||||||
 | 
					    return slugify(value, {
 | 
				
			||||||
 | 
					      strict: true,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async checkFormValidity(formEl: HTMLFormElement) {
 | 
					  private async checkFormValidity(formEl: HTMLFormElement) {
 | 
				
			||||||
    await this.updateComplete;
 | 
					    await this.updateComplete;
 | 
				
			||||||
    return !formEl.querySelector("[data-invalid]");
 | 
					    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();
 | 
					    e.preventDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const formEl = e.target as HTMLFormElement;
 | 
					    const formEl = e.target as HTMLFormElement;
 | 
				
			||||||
    if (!(await this.checkFormValidity(formEl))) return;
 | 
					    if (!(await this.checkFormValidity(formEl))) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { orgName } = serialize(formEl);
 | 
					    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(
 | 
					    this.dispatchEvent(
 | 
				
			||||||
      <OrgNameChangeEvent>new CustomEvent("org-name-change", {
 | 
					      <OrgInfoChangeEvent>new CustomEvent("org-info-change", {
 | 
				
			||||||
        detail: { value: orgName },
 | 
					        detail,
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@ export const AccessCode: Record<UserRole, number> = {
 | 
				
			|||||||
export type OrgData = {
 | 
					export type OrgData = {
 | 
				
			||||||
  id: string;
 | 
					  id: string;
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
 | 
					  slug: string;
 | 
				
			||||||
  quotas: Record<string, number>;
 | 
					  quotas: Record<string, number>;
 | 
				
			||||||
  bytesStored: number;
 | 
					  bytesStored: number;
 | 
				
			||||||
  users?: {
 | 
					  users?: {
 | 
				
			||||||
 | 
				
			|||||||
@ -6191,6 +6191,11 @@ slice-ansi@^5.0.0:
 | 
				
			|||||||
    ansi-styles "^6.0.0"
 | 
					    ansi-styles "^6.0.0"
 | 
				
			||||||
    is-fullwidth-code-point "^4.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:
 | 
					sockjs@^0.3.24:
 | 
				
			||||||
  version "0.3.24"
 | 
					  version "0.3.24"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
 | 
					  resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user