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