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(); 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>({ id: "invite-members", // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { return parseWithZod(formData, { schema }); }, defaultValue: { emails: [""], }, }); const { emails } = fields; const fieldValues = useRef([""]); const emailFields = emails.getFieldList(); return (
} title="Invite team members" description={`Invite new team members to ${organization.title}.`} /> {total > limits.limit && (canPurchaseSeats && seatPricing ? ( Purchase more seats…} /> } panelClassName="mb-4" > You've used all {limits.limit} of your available team members. Purchase extra seats to add more. ) : ( Upgrade } panelClassName="mb-4" > You've used all {limits.limit} of your available team members. Upgrade your plan to add more. ))}
{emailFields.map((email, index) => ( { 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 }); } }} /> {email.errors} ))} {showRolePicker ? ( 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) => ( {role.name} )) } Invitees join with this role. They can be promoted later from the Team page. ) : null} limits.limit}> Send invitations } cancelButton={ Cancel } />
); }