chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -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>
);
}