chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user