412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||
import { parseWithZod } from "@conform-to/zod";
|
||
import {
|
||
ArrowUpCircleIcon,
|
||
EnvelopeIcon,
|
||
LockOpenIcon,
|
||
UserPlusIcon,
|
||
} from "@heroicons/react/20/solid";
|
||
import { json } from "@remix-run/node";
|
||
import { Form, useActionData } from "@remix-run/react";
|
||
import { Fragment, useRef, useState } from "react";
|
||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||
import simplur from "simplur";
|
||
import { z } from "zod";
|
||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||
import { FormError } from "~/components/primitives/FormError";
|
||
import { FormTitle } from "~/components/primitives/FormTitle";
|
||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||
import { Input } from "~/components/primitives/Input";
|
||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||
import { Label } from "~/components/primitives/Label";
|
||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||
import { $replica } from "~/db.server";
|
||
import { env } from "~/env.server";
|
||
import { useOrganization } from "~/hooks/useOrganizations";
|
||
import { inviteMembers } from "~/models/member.server";
|
||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
|
||
import { scheduleEmail } from "~/services/scheduleEmail.server";
|
||
import { rbac } from "~/services/rbac.server";
|
||
import { ssoController } from "~/services/sso.server";
|
||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
|
||
import { isAtOrBelow } from "~/utils/inviteRoleLadder";
|
||
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
|
||
|
||
const Params = z.object({
|
||
organizationSlug: z.string(),
|
||
});
|
||
|
||
export const loader = dashboardLoader(
|
||
{
|
||
params: Params,
|
||
context: async (params) => {
|
||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||
return organizationId ? { organizationId } : {};
|
||
},
|
||
authorization: {
|
||
action: "manage",
|
||
resource: { type: "members" },
|
||
message: "With your current role, you can't invite team members.",
|
||
},
|
||
},
|
||
async ({ user, context, ability }) => {
|
||
const organizationId = context.organizationId;
|
||
if (!organizationId) {
|
||
throw new Response("Not Found", { status: 404 });
|
||
}
|
||
const userId = user.id;
|
||
|
||
const presenter = new TeamPresenter();
|
||
const result = await presenter.call({
|
||
userId,
|
||
organizationId,
|
||
});
|
||
|
||
if (!result) {
|
||
throw new Response("Not Found", { status: 404 });
|
||
}
|
||
|
||
// Inviter's own role drives the "below their level" filter on the
|
||
// dropdown. Plus assignable role IDs already encode the org's plan
|
||
// tier — the intersection is what we offer.
|
||
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
|
||
rbac.getUserRole({ userId, organizationId }),
|
||
rbac.getAssignableRoleIds(organizationId),
|
||
rbac.systemRoles(organizationId),
|
||
]);
|
||
|
||
// Build the dropdown's offerable set server-side: roles that are
|
||
// (a) assignable on the current plan AND (b) at or below the
|
||
// inviter's own level. The client just renders these — it doesn't
|
||
// need to know about the system-role catalogue or the ladder.
|
||
const assignableSet = new Set(assignableRoleIds);
|
||
const offerableRoleIds = systemRoles
|
||
? result.roles
|
||
.filter(
|
||
(r) =>
|
||
assignableSet.has(r.id) && isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
|
||
)
|
||
.map((r) => r.id)
|
||
: [];
|
||
|
||
// Buying seats is a billing operation: surface whether this user can, so
|
||
// the purchase modal disables its trigger (the team action enforces it).
|
||
const canManageBilling = ability.can("manage", { type: "billing" });
|
||
|
||
return typedjson({ ...result, offerableRoleIds, canManageBilling });
|
||
}
|
||
);
|
||
|
||
// Sentinel for "no RBAC role attached to invite" — the runtime
|
||
// fallback will derive a role from the legacy OrgMember.role write at
|
||
// accept time. Used when the org has no RBAC plugin installed (the
|
||
// dropdown is hidden) or as a defensive default.
|
||
const NO_RBAC_ROLE = "__no_rbac_role__";
|
||
|
||
const schema = z.object({
|
||
emails: z.preprocess((i) => {
|
||
if (typeof i === "string") return [i];
|
||
|
||
if (Array.isArray(i)) {
|
||
const emails = i.filter((v) => typeof v === "string" && v !== "");
|
||
if (emails.length === 0) {
|
||
return [""];
|
||
}
|
||
return emails;
|
||
}
|
||
|
||
return [""];
|
||
}, z.string().email().array().nonempty("At least one email is required")),
|
||
rbacRoleId: z.string().optional(),
|
||
});
|
||
|
||
export const action = dashboardAction(
|
||
{
|
||
params: Params,
|
||
context: async (params) => {
|
||
const organizationId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||
return organizationId ? { organizationId } : {};
|
||
},
|
||
authorization: { action: "manage", resource: { type: "members" } },
|
||
},
|
||
async ({ request, params, user, context }) => {
|
||
const userId = user.id;
|
||
const { organizationSlug } = params;
|
||
|
||
const formData = await request.formData();
|
||
const submission = parseWithZod(formData, { schema });
|
||
|
||
if (submission.status !== "success") {
|
||
return json(submission.reply());
|
||
}
|
||
|
||
// Directory-managed membership: inviting is disabled (the directory is the
|
||
// authority). Enforced here; the Team page also hides the invite button.
|
||
if (context.organizationId) {
|
||
const policy = await ssoController.getMembershipPolicy(context.organizationId);
|
||
if (policy.isOk() && !policy.value.manualMembershipAllowed) {
|
||
return json(
|
||
{ errors: { body: "Membership is managed by Directory Sync" } },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
}
|
||
|
||
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
|
||
// role → don't pass one through; the runtime fallback handles it.
|
||
// Validation: the chosen role must be in the org's assignable set
|
||
// (plan-tier) and at or below the inviter's own level.
|
||
let resolvedRbacRoleId: string | null = null;
|
||
const submittedRbacRoleId = submission.value.rbacRoleId;
|
||
if (submittedRbacRoleId && submittedRbacRoleId !== NO_RBAC_ROLE) {
|
||
const org = await $replica.organization.findFirst({
|
||
where: { slug: organizationSlug },
|
||
select: { id: true },
|
||
});
|
||
if (!org) {
|
||
return json({ errors: { body: "Organization not found" } }, { status: 404 });
|
||
}
|
||
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
|
||
rbac.getUserRole({ userId, organizationId: org.id }),
|
||
rbac.getAssignableRoleIds(org.id),
|
||
rbac.systemRoles(org.id),
|
||
]);
|
||
if (!systemRoles) {
|
||
// No plugin installed but the form somehow submitted a role id —
|
||
// ignore it (fall through to legacy behaviour rather than 400).
|
||
resolvedRbacRoleId = null;
|
||
} else {
|
||
const assignable = new Set(assignableRoleIds);
|
||
if (!assignable.has(submittedRbacRoleId)) {
|
||
return json(
|
||
{ errors: { body: "You can't invite someone with this role on your current plan" } },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
if (!isAtOrBelow(systemRoles, inviterRole?.id ?? null, submittedRbacRoleId)) {
|
||
return json(
|
||
{ errors: { body: "You can only invite members at or below your own role" } },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
resolvedRbacRoleId = submittedRbacRoleId;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const invites = await inviteMembers({
|
||
slug: organizationSlug,
|
||
emails: submission.value.emails,
|
||
userId,
|
||
rbacRoleId: resolvedRbacRoleId,
|
||
});
|
||
|
||
for (const invite of invites) {
|
||
try {
|
||
await scheduleEmail({
|
||
email: "invite",
|
||
to: invite.email,
|
||
orgName: invite.organization.title,
|
||
inviterName: invite.inviter.name ?? undefined,
|
||
inviterEmail: invite.inviter.email,
|
||
inviteLink: `${env.LOGIN_ORIGIN}${acceptInvitePath(invite.token)}`,
|
||
});
|
||
} catch (error) {
|
||
console.error("Failed to send invite email");
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
return redirectWithSuccessMessage(
|
||
organizationTeamPath(invites[0].organization),
|
||
request,
|
||
simplur`${submission.value.emails.length} member[|s] invited`
|
||
);
|
||
} catch (error: any) {
|
||
return json({ errors: { body: error.message } }, { status: 400 });
|
||
}
|
||
}
|
||
);
|
||
|
||
export default function Page() {
|
||
const {
|
||
limits,
|
||
canPurchaseSeats,
|
||
seatPricing,
|
||
extraSeats,
|
||
maxSeatQuota,
|
||
planSeatLimit,
|
||
roles,
|
||
offerableRoleIds,
|
||
canManageBilling,
|
||
} = useTypedLoaderData<typeof loader>();
|
||
const [total, setTotal] = useState(limits.used);
|
||
const organization = useOrganization();
|
||
const lastSubmission = useActionData();
|
||
|
||
// The loader filtered the catalogue to roles this inviter can
|
||
// actually assign (plan tier × strict-below-my-level). With no plugin
|
||
// installed, offerableRoleIds is [] and the picker hides entirely.
|
||
const offerableSet = new Set(offerableRoleIds);
|
||
const offerable = roles.filter((r) => offerableSet.has(r.id));
|
||
const showRolePicker = offerable.length > 0;
|
||
|
||
// Default to the lowest-tier offered role (the loader returns roles
|
||
// in its allRoles order, which the plugin emits Owner→Member; the
|
||
// last entry is the most restrictive).
|
||
const defaultRoleId = showRolePicker ? offerable[offerable.length - 1].id : NO_RBAC_ROLE;
|
||
const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId);
|
||
|
||
const [form, fields] = useForm<z.infer<typeof schema>>({
|
||
id: "invite-members",
|
||
// TODO: type this
|
||
lastResult: lastSubmission as any,
|
||
onValidate({ formData }) {
|
||
return parseWithZod(formData, { schema });
|
||
},
|
||
defaultValue: {
|
||
emails: [""],
|
||
},
|
||
});
|
||
const { emails } = fields;
|
||
|
||
const fieldValues = useRef<string[]>([""]);
|
||
const emailFields = emails.getFieldList();
|
||
|
||
return (
|
||
<MainCenteredContainer className="max-w-104 rounded-lg border border-grid-bright bg-background-dimmed p-5 shadow-lg">
|
||
<div>
|
||
<FormTitle
|
||
LeadingIcon={<UserPlusIcon className="size-6 text-indigo-500" />}
|
||
title="Invite team members"
|
||
description={`Invite new team members to ${organization.title}.`}
|
||
/>
|
||
{total > limits.limit &&
|
||
(canPurchaseSeats && seatPricing ? (
|
||
<InfoPanel
|
||
variant="upgrade"
|
||
icon={LockOpenIcon}
|
||
iconClassName="text-indigo-500"
|
||
title="Need more seats?"
|
||
accessory={
|
||
<PurchaseSeatsModal
|
||
seatPricing={seatPricing}
|
||
extraSeats={extraSeats}
|
||
usedSeats={limits.used}
|
||
maxQuota={maxSeatQuota}
|
||
planSeatLimit={planSeatLimit}
|
||
canManageBilling={canManageBilling}
|
||
triggerButton={<Button variant="primary/small">Purchase more seats…</Button>}
|
||
/>
|
||
}
|
||
panelClassName="mb-4"
|
||
>
|
||
<Paragraph variant="small">
|
||
You've used all {limits.limit} of your available team members. Purchase extra seats
|
||
to add more.
|
||
</Paragraph>
|
||
</InfoPanel>
|
||
) : (
|
||
<InfoPanel
|
||
variant="upgrade"
|
||
icon={LockOpenIcon}
|
||
iconClassName="text-indigo-500"
|
||
title="Unlock more team members"
|
||
accessory={
|
||
<LinkButton
|
||
to={v3BillingPath(organization)}
|
||
variant="secondary/small"
|
||
LeadingIcon={ArrowUpCircleIcon}
|
||
leadingIconClassName="text-indigo-500"
|
||
>
|
||
Upgrade
|
||
</LinkButton>
|
||
}
|
||
panelClassName="mb-4"
|
||
>
|
||
<Paragraph variant="small">
|
||
You've used all {limits.limit} of your available team members. Upgrade your plan to
|
||
add more.
|
||
</Paragraph>
|
||
</InfoPanel>
|
||
))}
|
||
<Form method="post" {...getFormProps(form)}>
|
||
<Fieldset>
|
||
<InputGroup>
|
||
<Label htmlFor={emails.id}>Email addresses</Label>
|
||
{emailFields.map((email, index) => (
|
||
<Fragment key={email.key}>
|
||
<Input
|
||
{...getInputProps(email, { type: "email" })}
|
||
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
|
||
icon={EnvelopeIcon}
|
||
autoFocus={index === 0}
|
||
onChange={(e) => {
|
||
fieldValues.current[index] = e.target.value;
|
||
const filledFields = fieldValues.current.filter((v) => v !== "");
|
||
setTotal(limits.used + filledFields.length);
|
||
if (
|
||
emailFields.length === fieldValues.current.length &&
|
||
fieldValues.current.every((v) => v !== "")
|
||
) {
|
||
form.insert({ name: emails.name });
|
||
}
|
||
}}
|
||
/>
|
||
<FormError id={email.errorId}>{email.errors}</FormError>
|
||
</Fragment>
|
||
))}
|
||
</InputGroup>
|
||
{showRolePicker ? (
|
||
<InputGroup>
|
||
<Label htmlFor="rbacRoleId">Role</Label>
|
||
<input type="hidden" name="rbacRoleId" value={selectedRoleId} />
|
||
<Select<string, (typeof offerable)[number]>
|
||
defaultValue={defaultRoleId}
|
||
items={offerable}
|
||
variant="tertiary/medium"
|
||
dropdownIcon
|
||
text={(v) => offerable.find((r) => r.id === v)?.name ?? "Pick a role"}
|
||
setValue={(next) => {
|
||
if (typeof next === "string") setSelectedRoleId(next);
|
||
}}
|
||
>
|
||
{(items) =>
|
||
items.map((role) => (
|
||
<SelectItem key={role.id} value={role.id}>
|
||
{role.name}
|
||
</SelectItem>
|
||
))
|
||
}
|
||
</Select>
|
||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||
Invitees join with this role. They can be promoted later from the Team page.
|
||
</Paragraph>
|
||
</InputGroup>
|
||
) : null}
|
||
<FormButtons
|
||
confirmButton={
|
||
<Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
|
||
Send invitations
|
||
</Button>
|
||
}
|
||
cancelButton={
|
||
<LinkButton to={organizationTeamPath(organization)} variant={"secondary/small"}>
|
||
Cancel
|
||
</LinkButton>
|
||
}
|
||
/>
|
||
</Fieldset>
|
||
</Form>
|
||
</div>
|
||
</MainCenteredContainer>
|
||
);
|
||
}
|