Allow superadmins to set role when inviting user (#1801)
- Adds user role select to superadmin dashboard - Requires org selection (unless there's only one org) to prevent accidental crawler invites to default testing org - Shows link to org members after invite, retaining form for quick re-invite - Refactor `invite-form` into `TailwindComponent`
This commit is contained in:
		
							parent
							
								
									3b2e382ba0
								
							
						
					
					
						commit
						24b20215d0
					
				| @ -1,29 +1,38 @@ | ||||
| import { localized, msg } from "@lit/localize"; | ||||
| import { type PropertyValues } from "lit"; | ||||
| import type { SlSelect } from "@shoelace-style/shoelace"; | ||||
| import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; | ||||
| import { html } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
| import { ifDefined } from "lit/directives/if-defined.js"; | ||||
| import sortBy from "lodash/fp/sortBy"; | ||||
| 
 | ||||
| import type { OrgData } from "@/types/org"; | ||||
| import { TailwindElement } from "@/classes/TailwindElement"; | ||||
| import { APIController } from "@/controllers/api"; | ||||
| import { AccessCode, type OrgData } from "@/types/org"; | ||||
| import { isApiError } from "@/utils/api"; | ||||
| import type { AuthState } from "@/utils/AuthService"; | ||||
| import LiteElement, { html } from "@/utils/LiteElement"; | ||||
| 
 | ||||
| export type InviteSuccessDetail = { | ||||
|   inviteEmail: string; | ||||
|   orgId: string; | ||||
|   isExistingUser: boolean; | ||||
| }; | ||||
| 
 | ||||
| const sortByName = sortBy("name"); | ||||
| 
 | ||||
| /** | ||||
|  * @event success | ||||
|  * @event btrix-invite-success | ||||
|  */ | ||||
| @localized() | ||||
| @customElement("btrix-invite-form") | ||||
| export class InviteForm extends LiteElement { | ||||
|   @property({ type: Object }) | ||||
| export class InviteForm extends TailwindElement { | ||||
|   @property({ type: Object, attribute: false }) | ||||
|   authState?: AuthState; | ||||
| 
 | ||||
|   @property({ type: Array }) | ||||
|   @property({ type: Array, attribute: false }) | ||||
|   orgs?: OrgData[] = []; | ||||
| 
 | ||||
|   @property({ type: Object }) | ||||
|   @property({ type: Object, attribute: false }) | ||||
|   defaultOrg: Partial<OrgData> | null = null; | ||||
| 
 | ||||
|   @state() | ||||
| @ -32,18 +41,7 @@ export class InviteForm extends LiteElement { | ||||
|   @state() | ||||
|   private serverError?: string; | ||||
| 
 | ||||
|   @state() | ||||
|   private selectedOrgId?: string; | ||||
| 
 | ||||
|   willUpdate(changedProperties: PropertyValues<this> & Map<string, unknown>) { | ||||
|     if ( | ||||
|       changedProperties.has("defaultOrg") && | ||||
|       this.defaultOrg && | ||||
|       !this.selectedOrgId | ||||
|     ) { | ||||
|       this.selectedOrgId = this.defaultOrg.id; | ||||
|     } | ||||
|   } | ||||
|   private readonly api = new APIController(this); | ||||
| 
 | ||||
|   render() { | ||||
|     let formError; | ||||
| @ -68,13 +66,13 @@ export class InviteForm extends LiteElement { | ||||
|       > | ||||
|         <div class="mb-5"> | ||||
|           <sl-select | ||||
|             name="orgId" | ||||
|             label=${msg("Organization")} | ||||
|             placeholder=${msg("Select an org")} | ||||
|             value=${ifDefined( | ||||
|               this.defaultOrg ? this.defaultOrg.id : sortedOrgs[0]?.id, | ||||
|               this.defaultOrg?.id || | ||||
|                 (this.orgs?.length === 1 ? this.orgs[0].id : undefined), | ||||
|             )} | ||||
|             @sl-change=${(e: Event) => { | ||||
|               this.selectedOrgId = (e.target as HTMLSelectElement).value; | ||||
|             }} | ||||
|             ?disabled=${sortedOrgs.length === 1} | ||||
|             required | ||||
|           > | ||||
| @ -85,6 +83,17 @@ export class InviteForm extends LiteElement { | ||||
|             )} | ||||
|           </sl-select> | ||||
|         </div> | ||||
|         <div class="mb-5"> | ||||
|           <sl-select | ||||
|             label=${msg("Role")} | ||||
|             value=${AccessCode.owner} | ||||
|             name="inviteRole" | ||||
|           > | ||||
|             <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> | ||||
|         </div> | ||||
|         <div class="mb-5"> | ||||
|           <sl-input | ||||
|             id="inviteEmail" | ||||
| @ -107,7 +116,7 @@ export class InviteForm extends LiteElement { | ||||
|             size="small" | ||||
|             type="submit" | ||||
|             ?loading=${this.isSubmitting} | ||||
|             ?disabled=${!this.selectedOrgId || this.isSubmitting} | ||||
|             ?disabled=${this.isSubmitting} | ||||
|             >${msg("Invite")}</sl-button | ||||
|           > | ||||
|         </div> | ||||
| @ -116,37 +125,45 @@ export class InviteForm extends LiteElement { | ||||
|   } | ||||
| 
 | ||||
|   async onSubmit(event: SubmitEvent) { | ||||
|     event.preventDefault(); | ||||
|     if (!this.authState || !this.selectedOrgId) return; | ||||
| 
 | ||||
|     const formEl = event.target as HTMLFormElement; | ||||
|     event.preventDefault(); | ||||
| 
 | ||||
|     if (!(await this.checkFormValidity(formEl))) return; | ||||
| 
 | ||||
|     this.serverError = undefined; | ||||
|     this.isSubmitting = true; | ||||
| 
 | ||||
|     const formData = new FormData(event.target as HTMLFormElement); | ||||
|     const inviteEmail = formData.get("inviteEmail") as string; | ||||
|     const { orgId, inviteEmail, inviteRole } = serialize(formEl) as { | ||||
|       orgId: string; | ||||
|       inviteEmail: string; | ||||
|       inviteRole: string; | ||||
|     }; | ||||
| 
 | ||||
|     try { | ||||
|       const data = await this.apiFetch<{ invited: string }>( | ||||
|         `/orgs/${this.selectedOrgId}/invite`, | ||||
|         this.authState, | ||||
|       const data = await this.api.fetch<{ invited: string }>( | ||||
|         `/orgs/${orgId}/invite`, | ||||
|         this.authState!, | ||||
|         { | ||||
|           method: "POST", | ||||
|           body: JSON.stringify({ | ||||
|             email: inviteEmail, | ||||
|             role: 10, | ||||
|             role: +inviteRole, | ||||
|           }), | ||||
|         }, | ||||
|       ); | ||||
| 
 | ||||
|       // Reset fields except selected org ID
 | ||||
|       formEl.reset(); | ||||
|       formEl.querySelector<SlSelect>('[name="orgId"]')!.value = orgId; | ||||
| 
 | ||||
|       this.dispatchEvent( | ||||
|         new CustomEvent("success", { | ||||
|         new CustomEvent<InviteSuccessDetail>("btrix-invite-success", { | ||||
|           detail: { | ||||
|             inviteEmail, | ||||
|             orgId, | ||||
|             isExistingUser: data.invited === "existing_user", | ||||
|           }, | ||||
|           composed: true, | ||||
|         }), | ||||
|       ); | ||||
|     } catch (e) { | ||||
|  | ||||
| @ -3,6 +3,7 @@ import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js"; | ||||
| import { type PropertyValues, type TemplateResult } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
| 
 | ||||
| import type { InviteSuccessDetail } from "@/features/accounts/invite-form"; | ||||
| import type { APIPaginatedList } from "@/types/api"; | ||||
| import type { CurrentUser } from "@/types/user"; | ||||
| import { isApiError } from "@/utils/api"; | ||||
| @ -23,9 +24,6 @@ export class Home extends LiteElement { | ||||
|   @property({ type: String }) | ||||
|   slug?: string; | ||||
| 
 | ||||
|   @state() | ||||
|   private isInviteComplete?: boolean; | ||||
| 
 | ||||
|   @state() | ||||
|   private orgList?: OrgData[]; | ||||
| 
 | ||||
| @ -249,23 +247,29 @@ export class Home extends LiteElement { | ||||
|   } | ||||
| 
 | ||||
|   private renderInvite() { | ||||
|     if (this.isInviteComplete) { | ||||
|       return html` | ||||
|         <sl-button @click=${() => (this.isInviteComplete = false)} | ||||
|           >${msg("Send another invite")}</sl-button | ||||
|         > | ||||
|       `;
 | ||||
|     } | ||||
| 
 | ||||
|     const defaultOrg = this.userInfo?.orgs.find( | ||||
|       (org) => org.default === true, | ||||
|     ) || { name: "" }; | ||||
|     return html` | ||||
|       <btrix-invite-form | ||||
|         .authState=${this.authState} | ||||
|         .orgs=${this.orgList} | ||||
|         .defaultOrg=${defaultOrg} | ||||
|         @success=${() => (this.isInviteComplete = true)} | ||||
|         @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> | ||||
|     `;
 | ||||
|   } | ||||
|  | ||||
| @ -46,10 +46,7 @@ export class UsersInvite extends LiteElement { | ||||
|         <btrix-invite-form | ||||
|           .authState=${this.authState} | ||||
|           .orgs=${this.userInfo?.orgs || []} | ||||
|           .defaultOrg=${this.userInfo?.orgs.find( | ||||
|             (org) => org.default === true, | ||||
|           ) ?? null} | ||||
|           @success=${this.onSuccess} | ||||
|           @btrix-invite-success=${this.onSuccess} | ||||
|         ></btrix-invite-form> | ||||
|       </main> | ||||
|     </div>`;
 | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user