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:
sua yoo 2024-05-15 19:47:23 -07:00 committed by GitHub
parent 3b2e382ba0
commit 24b20215d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 73 additions and 55 deletions

View File

@ -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) {

View File

@ -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>
`;
}

View File

@ -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>`;