Files
2026-07-13 13:32:57 +08:00

1222 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
import { parseWithZod } from "@conform-to/zod";
import { EnvelopeIcon, NoSymbolIcon, UserPlusIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import {
Form,
type MetaFunction,
useActionData,
useFetcher,
useNavigation,
} from "@remix-run/react";
import { json } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { cloneElement, useEffect, useRef, useState } from "react";
import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Feedback } from "~/components/Feedback";
import { UserAvatar } from "~/components/UserProfilePhoto";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import {
Alert,
AlertCancel,
AlertContent,
AlertDescription,
AlertFooter,
AlertHeader,
AlertTitle,
AlertTrigger,
} from "~/components/primitives/Alert";
import { Button, ButtonContent, LinkButton } from "~/components/primitives/Buttons";
import { PermissionButton } from "~/components/primitives/PermissionButton";
import { DateTime } from "~/components/primitives/DateTime";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { InputGroup } from "~/components/primitives/InputGroup";
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Select";
import { SpinnerWhite } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { $replica, prisma } from "~/db.server";
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
import { useOrganization } from "~/hooks/useOrganizations";
import { useUser } from "~/hooks/useUser";
import { removeTeamMember } from "~/models/removeTeamMember.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { resolveOrgIdFromSlug } from "~/models/organization.server";
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
import { rbac } from "~/services/rbac.server";
import { ssoController } from "~/services/sso.server";
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
import { cn } from "~/utils/cn";
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import {
inviteTeamMemberPath,
organizationRolesPath,
organizationTeamPath,
resendInvitePath,
revokeInvitePath,
v3BillingPath,
} from "~/utils/pathBuilder";
import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
export const meta: MetaFunction = () => {
return [
{
title: `Team | Trigger.dev`,
},
];
};
const Params = z.object({
organizationSlug: z.string(),
});
export const loader = dashboardLoader(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
authorization: { action: "read", resource: { type: "members" } },
},
async ({ user, ability, context }) => {
const orgId = context.organizationId;
if (!orgId) {
throw new Response("Not Found", { status: 404 });
}
const presenter = new TeamPresenter();
const result = await presenter.call({
userId: user.id,
organizationId: orgId,
});
if (!result) {
throw new Response("Not Found", { status: 404 });
}
// Pre-compute manage authority server-side so the UI gating matches
// the action gating (the action enforces it independently). Seat
// purchases are a billing operation, so they gate on manage:billing.
const canManageMembers = ability.can("manage", { type: "members" });
const canManageBilling = ability.can("manage", { type: "billing" });
// When Directory Sync is the authority (allowManualMembership off + a
// directory active), manual invite/remove/leave are disabled. Fail-open to
// "allowed" so a plugin hiccup never strands the team page.
const policy = await ssoController.getMembershipPolicy(orgId);
const manualMembershipAllowed = policy.isOk() ? policy.value.manualMembershipAllowed : true;
return typedjson({ ...result, canManageMembers, canManageBilling, manualMembershipAllowed });
}
);
const schema = z.object({
memberId: z.string(),
});
const PurchaseSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("purchase"),
amount: z.coerce.number().int("Must be a whole number").min(0, "Amount must be 0 or more"),
}),
z.object({
action: z.literal("quota-increase"),
amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"),
}),
]);
const SetRoleSchema = z.object({
userId: z.string(),
roleId: z.string(),
});
export const action = dashboardAction(
{
params: Params,
context: async (params) => {
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
return orgId ? { organizationId: orgId } : {};
},
// No top-level authorization — different intents have different
// requirements. Each branch inside checks the right ability:
// set-role → manage:members
// purchase-seats → manage:billing
// remove-member → manage:members (skipped for self-leave)
// Don't rely on the model-layer (removeTeamMember /
// SetSeatsAddOnService) for enforcement — those are defense in
// depth; the route layer is where the ability gate belongs.
},
async ({ user, ability, request, params, context }) => {
const userId = user.id;
const { organizationSlug } = params;
invariant(organizationSlug, "organizationSlug not found");
const formData = await request.formData();
const formType = formData.get("_formType");
if (formType === "set-role") {
if (!ability.can("manage", { type: "members" })) {
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
}
const orgId = context.organizationId;
if (!orgId) {
return json({ ok: false, error: "Organization not found" } as const, { status: 404 });
}
const submission = parseWithZod(formData, { schema: SetRoleSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
const result = await rbac.setUserRole({
userId: submission.value.userId,
organizationId: orgId,
roleId: submission.value.roleId,
});
if (!result.ok) {
return json({ ok: false, error: result.error } as const, { status: 400 });
}
return json({ ok: true } as const);
}
if (formType === "purchase-seats") {
// Adjusting seat count is a billing operation. Pre-RBAC the team
// page's loader gated the entire route on Owner/Admin, so reaching
// this action implied authority. Post-RBAC the loader requires
// `read:members` (broader audience), so gate the seat purchase
// explicitly here against the right ability rather than relying
// on the SetSeatsAddOnService for enforcement at the model layer.
if (!ability.can("manage", { type: "billing" })) {
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
}
// Reuse the orgId the dashboardBuilder already resolved in the
// context callback (single slug → orgId lookup per request,
// regardless of whether the OSS fallback or cloud plugin
// services the auth — the plugin takes `organizationId` as
// input and doesn't re-resolve from a slug).
const orgId = context.organizationId;
if (!orgId) {
return json({ ok: false, error: "Organization not found" } as const);
}
const currentPlan = await getCurrentPlan(orgId);
const purchaseBlockReason = getSelfServePurchaseBlockReason(currentPlan);
if (purchaseBlockReason === "plan_unavailable") {
return json(
{ ok: false, error: "Unable to verify billing status. Please try again." } as const,
{ status: 503 }
);
}
if (purchaseBlockReason === "managed_billing") {
return json({ ok: false, error: "Contact us to request more seats." } as const, {
status: 403,
});
}
const submission = parseWithZod(formData, { schema: PurchaseSchema });
if (submission.status !== "success") {
return json(submission.reply());
}
const service = new SetSeatsAddOnService();
const [error, result] = await tryCatch(
service.call({
userId,
organizationId: orgId,
action: submission.value.action,
amount: submission.value.amount,
})
);
if (error) {
return json(
submission.reply({
fieldErrors: { amount: [error instanceof Error ? error.message : "Unknown error"] },
})
);
}
if (!result.success) {
return json(submission.reply({ fieldErrors: { amount: [result.error] } }));
}
return json({ ok: true } as const);
}
const submission = parseWithZod(formData, { schema });
if (submission.status !== "success") {
return json(submission.reply());
}
// Default intent: remove a member or leave the org, with the target scoped
// to the actor's organization. Self-leave is always allowed; removing
// someone else requires manage:members.
const orgId = context.organizationId;
if (!orgId) {
return json({ ok: false, error: "Organization not found" } as const, { status: 404 });
}
const targetMember = await $replica.orgMember.findFirst({
where: { id: submission.value.memberId, organizationId: orgId },
select: { userId: true },
});
if (!targetMember) {
return json({ ok: false, error: "Member not found" } as const, { status: 404 });
}
const isSelfLeave = targetMember.userId === userId;
if (!isSelfLeave && !ability.can("manage", { type: "members" })) {
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
}
// Directory-managed membership: manual removal + self-leave are disabled
// (membership is driven by the directory). Enforced here, not just in the
// UI. Fail-open on a plugin error.
const removePolicy = await ssoController.getMembershipPolicy(orgId);
if (removePolicy.isOk() && !removePolicy.value.manualMembershipAllowed) {
return json({ ok: false, error: "Membership is managed by Directory Sync" } as const, {
status: 403,
});
}
try {
const deletedMember = await removeTeamMember(
{
userId,
memberId: submission.value.memberId,
slug: organizationSlug,
},
prisma
);
// Sticky removal: record a tombstone so passive SSO-JIT won't re-add
// them on next login (best-effort; no-op without the SSO plugin).
await ssoController
.recordMembershipRemoval({
organizationId: orgId,
userId: deletedMember.userId,
reason: isSelfLeave ? "self_leave" : "manual_removal",
})
.unwrapOr(undefined);
if (deletedMember.userId === userId) {
return redirectWithSuccessMessage("/", request, `You left the organization`);
}
return redirectWithSuccessMessage(
organizationTeamPath(deletedMember.organization),
request,
`Removed ${deletedMember.user.name ?? "member"} from team`
);
} catch (error: any) {
return json({ errors: { body: error.message } }, { status: 400 });
}
}
);
type Member = UseDataFunctionReturn<typeof loader>["members"][number];
type Invite = UseDataFunctionReturn<typeof loader>["invites"][number];
type Role = UseDataFunctionReturn<typeof loader>["roles"][number];
export default function Page() {
const {
members,
invites,
limits,
canPurchaseSeats,
extraSeats,
seatPricing,
maxSeatQuota,
planSeatLimit,
roles,
assignableRoleIds,
memberRoles,
canManageMembers,
canManageBilling,
manualMembershipAllowed,
} = useTypedLoaderData<typeof loader>();
// Build a userId → roleId map so the dropdown's defaultValue matches
// each member's current assignment without re-querying.
const memberRoleByUserId = new Map<string, string>(
memberRoles.flatMap((m) => (m.role ? [[m.userId, m.role.id]] : []))
);
const user = useUser();
const organization = useOrganization();
const plan = useCurrentPlan();
const showSelfServe = useShowSelfServe();
const requiresUpgrade = limits.used >= limits.limit;
const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0;
const canUpgrade =
plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.teamMembers.canExceed;
return (
<PageContainer>
<NavBar>
<PageTitle title="Team" />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
<Property.Item>
<Property.Label>Org ID</Property.Label>
<Property.Value>{organization.id}</Property.Value>
</Property.Item>
{members.map((member) => (
<Property.Item key={member.id}>
<Property.Label>{member.user.name}</Property.Label>
<Property.Value>
<div className="flex items-center gap-2">
<Paragraph variant="extra-small/bright/mono">
{member.user.email} - {member.user.id}
</Paragraph>
</div>
</Property.Value>
</Property.Item>
))}
</Property.Table>
</AdminDebugTooltip>
{!manualMembershipAllowed ? (
// Directory Sync is the membership authority — manual invites are
// disabled. The invite action + acceptInvite enforce this too.
<SimpleTooltip
button={
<ButtonContent
variant="primary/small"
LeadingIcon={UserPlusIcon}
className="cursor-not-allowed opacity-50"
>
Invite a team member
</ButtonContent>
}
content="Membership is managed by Directory Sync"
disableHoverableContent
/>
) : !canManageMembers ? (
// Gate the invite affordance on manage:members. The action
// route enforces this independently — hiding it here just
// avoids dead UI for non-managers.
<SimpleTooltip
button={
<ButtonContent
variant="primary/small"
LeadingIcon={UserPlusIcon}
className="cursor-not-allowed opacity-50"
>
Invite a team member
</ButtonContent>
}
content="You don't have permission to invite team members"
disableHoverableContent
/>
) : requiresUpgrade ? (
<SimpleTooltip
button={
<ButtonContent
variant="primary/small"
LeadingIcon={UserPlusIcon}
className="cursor-not-allowed opacity-50"
>
Invite a team member
</ButtonContent>
}
content="Purchase more seats to invite more team members"
disableHoverableContent
/>
) : (
<LinkButton
to={inviteTeamMemberPath(organization)}
variant="primary/small"
LeadingIcon={UserPlusIcon}
>
Invite a team member
</LinkButton>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[1fr_auto]">
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="mx-auto max-w-3xl px-4 pb-4 pt-20">
{invites.length > 0 && (
<>
<Header2 className="mb-3 mt-4">Pending invites</Header2>
<ul className="divide-ui-border mb-6 flex w-full flex-col divide-y border-y">
{invites.map((invite) => (
<li key={invite.id} className="flex items-center gap-4 py-4">
<div className="rounded-md border border-grid-dimmed bg-background-bright p-1.5">
<EnvelopeIcon className="size-7 text-text-dimmed" />
</div>
<div className="flex flex-col gap-0.5">
<Header3>{invite.email}</Header3>
<Paragraph variant="small">
Invite sent {<DateTime date={invite.updatedAt} />}
</Paragraph>
</div>
<div className="flex grow items-center justify-end gap-x-2">
<ResendButton invite={invite} canManageMembers={canManageMembers} />
<RevokeButton invite={invite} canManageMembers={canManageMembers} />
</div>
</li>
))}
</ul>
</>
)}
<div className="mt-4 flex items-baseline justify-between">
<Header2>Active team members</Header2>
{roles.length > 0 ? (
<a
className="text-xs text-text-link hover:underline"
href={organizationRolesPath(organization)}
>
View all role permissions
</a>
) : null}
</div>
<div className="mb-8 mt-3 grid w-full grid-cols-[1fr_auto_auto] items-center gap-x-2 border-y border-grid-bright">
{members.map((member) => (
<div
key={member.user.id}
className="col-span-3 grid grid-cols-subgrid items-center gap-x-2 border-b border-grid-bright py-2 last:border-b-0"
>
<div className="flex items-center gap-x-2">
<UserAvatar
avatarUrl={member.user.avatarUrl}
name={member.user.name}
className="size-10"
/>
<div className="flex flex-col gap-0.5">
<Header3>
{member.user.name}{" "}
{member.user.id === user.id && (
<span className="text-text-dimmed">(You)</span>
)}
</Header3>
<Paragraph variant="small">{member.user.email}</Paragraph>
</div>
</div>
<RolePicker
memberUserId={member.user.id}
currentRoleId={memberRoleByUserId.get(member.user.id) ?? null}
roles={roles}
assignableRoleIds={assignableRoleIds}
canManageMembers={canManageMembers}
/>
<div className="justify-self-end">
<LeaveRemoveButton
userId={user.id}
member={member}
memberCount={members.length}
canManageMembers={canManageMembers}
manualMembershipAllowed={manualMembershipAllowed}
/>
</div>
</div>
))}
</div>
</div>
</div>
<div className="flex h-fit w-full items-center gap-3 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${requiresUpgrade ? "stroke-error" : "stroke-success"}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${usageRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round(usageRatio * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{requiresUpgrade ? (
<Header3 className="text-error">
You've used all {limits.limit} of your seats.{" "}
{canPurchaseSeats
? "Purchase more seats to invite more team members."
: "Upgrade your plan to invite more team members."}
</Header3>
) : (
<Header3>
You've used {limits.used}/{limits.limit} of your seats
</Header3>
)}
{canPurchaseSeats && seatPricing ? (
<PurchaseSeatsModal
seatPricing={seatPricing}
extraSeats={extraSeats}
usedSeats={limits.used}
maxQuota={maxSeatQuota}
planSeatLimit={planSeatLimit}
canManageBilling={canManageBilling}
/>
) : canUpgrade ? (
showSelfServe ? (
<LinkButton to={v3BillingPath(organization)} variant="primary/small">
Upgrade
</LinkButton>
) : (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Request more</Button>}
/>
)
) : null}
</div>
</div>
</div>
</PageBody>
</PageContainer>
);
}
function LeaveRemoveButton({
userId,
member,
memberCount,
canManageMembers,
manualMembershipAllowed,
}: {
userId: string;
member: Member;
memberCount: number;
canManageMembers: boolean;
manualMembershipAllowed: boolean;
}) {
const organization = useOrganization();
// Directory-managed membership: neither removing others nor leaving is
// allowed — the directory drives membership. Enforced server-side too.
if (!manualMembershipAllowed) {
const isSelf = userId === member.user.id;
return (
<SimpleTooltip
button={
<ButtonContent
variant={isSelf ? "minimal/small" : "secondary/small"}
className="cursor-not-allowed opacity-50"
>
{isSelf ? "Leave team" : "Remove from team"}
</ButtonContent>
}
disableHoverableContent
content="Membership is managed by Directory Sync"
/>
);
}
if (userId === member.user.id) {
if (memberCount === 1) {
return (
<SimpleTooltip
button={
<ButtonContent variant="minimal/small" className="cursor-not-allowed">
Leave team
</ButtonContent>
}
disableHoverableContent
content="An organization requires at least 1 team member"
/>
);
}
//you leave the team — leaving is always permitted regardless of
//manage:members; non-managers can still leave on their own.
return (
<LeaveTeamModal
member={member}
buttonText="Leave team"
title="Are you sure you want to leave the team?"
description={`You will no longer have access to ${organization.title}. To regain access, you will need to be invited again.`}
actionText="Leave team"
/>
);
}
//you remove another member — requires manage:members
if (!canManageMembers) {
return (
<SimpleTooltip
button={
<ButtonContent variant="secondary/small" className="cursor-not-allowed opacity-50">
Remove from team
</ButtonContent>
}
disableHoverableContent
content="You don't have permission to remove team members"
/>
);
}
return (
<LeaveTeamModal
member={member}
buttonText="Remove from team"
title={`Are you sure you want to remove ${member.user.name ?? "them"} from the team?`}
description={`They will no longer have access to ${organization.title}. To regain access, you will need to invite them again.`}
actionText="Remove from team"
/>
);
}
// Inline role picker — submits a `_formType=set-role` form via fetcher
// so the change persists without a full page reload. Disabled options
// (and the picker itself) reflect plan gating + manage:members; the
// server's setUserRole enforces both checks again as the source of
// truth, so this is a UI-affordance layer only.
function RolePicker({
memberUserId,
currentRoleId,
roles,
assignableRoleIds,
canManageMembers,
}: {
memberUserId: string;
currentRoleId: string | null;
roles: Role[];
assignableRoleIds: string[];
canManageMembers: boolean;
}) {
const organization = useOrganization();
const fetcher = useFetcher<{ ok: boolean; error?: string } | { ok: true }>();
const assignable = new Set(assignableRoleIds);
// With no RBAC plugin installed, the loader returns no roles —
// render nothing rather than an empty dropdown.
if (roles.length === 0) return null;
const isSubmitting = fetcher.state === "submitting";
const error =
fetcher.data && "error" in fetcher.data && fetcher.data.error ? fetcher.data.error : null;
const picker = (
<Select
// Controlled — keeps the dropdown in sync with the persisted
// role even after a failed set-role fetcher submit (the server
// kept the old role; without `value` the UI would show the
// attempted change).
value={currentRoleId ?? ""}
items={roles}
variant="tertiary/small"
disabled={!canManageMembers || isSubmitting}
dropdownIcon
text={(v) => roles.find((r) => r.id === v)?.name ?? "No role"}
setValue={(next) => {
if (typeof next !== "string" || next === (currentRoleId ?? "")) return;
// Upgrade-link rows have a value too (Ariakit needs one to
// make the row interactive — without it the Link inside
// doesn't even register the click), but they shouldn't
// submit the role-change form. The Link navigates the user
// to the plan-selection page; we just bail here.
if (!assignable.has(next)) return;
fetcher.submit(
{ _formType: "set-role", userId: memberUserId, roleId: next },
{ method: "post" }
);
}}
>
{(items) =>
items.map((role) => {
const isAssignable = assignable.has(role.id);
return isAssignable ? (
<SelectItem key={role.id} value={role.id}>
{role.name}
</SelectItem>
) : (
<SelectLinkItem key={role.id} value={role.id} to={v3BillingPath(organization)}>
{role.name} (upgrade)
</SelectLinkItem>
);
})
}
</Select>
);
return (
<div className="flex flex-col items-end gap-1">
{canManageMembers ? (
picker
) : (
// Disabled <Select> swallows hover events on its own, so wrap it
// in a div the tooltip can attach to.
<SimpleTooltip
button={<div className="cursor-not-allowed">{picker}</div>}
content="You don't have permission to change roles"
disableHoverableContent
/>
)}
{error ? (
<span className="text-xs text-error" role="alert">
{error}
</span>
) : null}
</div>
);
}
function LeaveTeamModal({
member,
buttonText,
title,
description,
actionText,
}: {
member: Member;
buttonText: string;
title: string;
description: string;
actionText: string;
}) {
const [open, setOpen] = useState(false);
const lastSubmission = useActionData();
const [form, _fields] = useForm({
id: "remove-member",
// TODO: type this
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
return (
<Alert open={open} onOpenChange={(o) => setOpen(o)}>
<AlertTrigger asChild>
<Button variant="secondary/small">{buttonText}</Button>
</AlertTrigger>
<AlertContent>
<AlertHeader>
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{description}</AlertDescription>
</AlertHeader>
<AlertFooter>
<AlertCancel asChild>
<Button variant="secondary/small">Cancel</Button>
</AlertCancel>
<Form method="post" {...getFormProps(form)} onSubmit={() => setOpen(false)}>
<input type="hidden" value={member.id} name="memberId" />
<Button type="submit" variant="danger/small" form={form.id}>
{actionText}
</Button>
</Form>
</AlertFooter>
</AlertContent>
</Alert>
);
}
const RESEND_COOLDOWN_SECONDS = 30;
function initialCooldown(updatedAt: Date | string): number {
const elapsed = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 1000);
const remaining = RESEND_COOLDOWN_SECONDS - elapsed;
return remaining > 0 ? remaining : 0;
}
function ResendButton({ invite, canManageMembers }: { invite: Invite; canManageMembers: boolean }) {
const navigation = useNavigation();
const isSubmitting =
navigation.state === "submitting" &&
navigation.formAction === resendInvitePath() &&
navigation.formData?.get("inviteId") === invite.id;
const prevSubmitting = useRef(false);
const [cooldown, setCooldown] = useState(() => initialCooldown(invite.updatedAt));
const intervalRef = useRef<ReturnType<typeof setInterval>>();
useEffect(() => {
if (prevSubmitting.current && !isSubmitting) {
setCooldown(RESEND_COOLDOWN_SECONDS);
}
prevSubmitting.current = isSubmitting;
}, [isSubmitting]);
const cooldownActive = cooldown > 0;
useEffect(() => {
if (!cooldownActive) return;
intervalRef.current = setInterval(() => {
setCooldown((c) => {
if (c <= 1) {
clearInterval(intervalRef.current);
return 0;
}
return c - 1;
});
}, 1000);
return () => clearInterval(intervalRef.current);
}, [cooldownActive]);
const isDisabled = isSubmitting || cooldown > 0 || !canManageMembers;
return (
<Form method="post" action={resendInvitePath()} className="flex">
<input type="hidden" value={invite.id} name="inviteId" />
<Button
type="submit"
variant="secondary/small"
disabled={isDisabled}
tooltip={canManageMembers ? undefined : "You don't have permission to manage team members"}
>
{isSubmitting ? (
"Sending…"
) : cooldown > 0 ? (
<span className="tabular-nums">{`Sent resend in ${cooldown}s`}</span>
) : (
"Resend invite"
)}
</Button>
</Form>
);
}
function RevokeButton({ invite, canManageMembers }: { invite: Invite; canManageMembers: boolean }) {
const organization = useOrganization();
return (
<Form method="post" action={revokeInvitePath()} className="flex">
<input type="hidden" value={invite.id} name="inviteId" />
<input type="hidden" value={organization.slug} name="slug" />
<SimpleTooltip
button={
<Button
type="submit"
variant="danger/small"
LeadingIcon={NoSymbolIcon}
leadingIconClassName="text-white"
aria-label="Revoke invite"
disabled={!canManageMembers}
/>
}
content={
canManageMembers ? "Revoke invite" : "You don't have permission to manage team members"
}
disableHoverableContent
asChild
/>
</Form>
);
}
export function PurchaseSeatsModal({
seatPricing,
extraSeats,
usedSeats,
maxQuota,
planSeatLimit,
triggerButton,
canManageBilling = true,
}: {
seatPricing: {
stepSize: number;
centsPerStep: number;
};
extraSeats: number;
usedSeats: number;
maxQuota: number;
planSeatLimit: number;
triggerButton?: React.ReactElement;
canManageBilling?: boolean;
}) {
const showSelfServe = useShowSelfServe();
const fetcher = useFetcher();
const organization = useOrganization();
const lastSubmission =
fetcher.data && typeof fetcher.data === "object" && "status" in fetcher.data
? fetcher.data
: undefined;
const [form, fields] = useForm({
id: "purchase-seats",
lastResult: lastSubmission as any,
onValidate({ formData }) {
return parseWithZod(formData, { schema: PurchaseSchema });
},
shouldRevalidate: "onSubmit",
});
const amount = fields.amount;
const [amountValue, setAmountValue] = useState(extraSeats);
useEffect(() => {
setAmountValue(extraSeats);
}, [extraSeats]);
const isLoading = fetcher.state !== "idle";
const [open, setOpen] = useState(false);
useEffect(() => {
const data = fetcher.data;
if (
fetcher.state === "idle" &&
data !== null &&
typeof data === "object" &&
"ok" in data &&
data.ok
) {
setOpen(false);
}
}, [fetcher.state, fetcher.data]);
const state = updateSeatState({
value: amountValue,
existingValue: extraSeats,
quota: maxQuota,
usedSeats,
planSeatLimit,
});
const changeClassName =
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
const pricePerSeat = seatPricing.centsPerStep / seatPricing.stepSize / 100;
const title = extraSeats === 0 ? "Purchase extra seats…" : "Add/remove extra seats…";
if (!showSelfServe) {
return (
<Feedback
defaultValue="enterprise"
button={<Button variant="secondary/small">Request more</Button>}
/>
);
}
// Buying seats is a billing action — disable the trigger (and explain why)
// when the role can't manage billing. The action enforces it independently.
const noBillingTooltip = "You don't have permission to manage billing";
const trigger = canManageBilling ? (
(triggerButton ?? (
<Button variant="primary/small" onClick={() => setOpen(true)}>
{title}
</Button>
))
) : triggerButton ? (
cloneElement(triggerButton, { disabled: true, tooltip: noBillingTooltip })
) : (
<PermissionButton
variant="primary/small"
hasPermission={false}
noPermissionTooltip={noBillingTooltip}
>
{title}
</PermissionButton>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>{title}</DialogHeader>
<fetcher.Form
method="post"
action={organizationTeamPath(organization)}
{...getFormProps(form)}
>
<input type="hidden" name="_formType" value="purchase-seats" />
<div className="flex flex-col gap-4 pt-2">
<div className="flex flex-col gap-1">
<Paragraph variant="small/bright">
Purchase extra seats at {formatCurrency(pricePerSeat, true)}/month per seat.
Reducing seats will take effect at the start of your next billing cycle (on the 1st
of the month).
</Paragraph>
</div>
<Fieldset>
<InputGroup fullWidth>
<Label htmlFor="amount" className="text-text-dimmed">
Total extra seats
</Label>
<InputNumberStepper
{...getInputProps(amount, { type: "number" })}
step={seatPricing.stepSize}
min={0}
max={undefined}
value={amountValue}
onChange={(e) => setAmountValue(Number(e.target.value))}
disabled={isLoading}
/>
<FormError id={amount.errorId}>{amount.errors}</FormError>
<FormError>{form.errors}</FormError>
</InputGroup>
</Fieldset>
{state === "need_to_remove_members" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
You need to remove {formatNumber(usedSeats - (planSeatLimit + amountValue))}{" "}
{usedSeats - (planSeatLimit + amountValue) === 1
? "team member or pending invite"
: "team members or pending invites"}{" "}
before you can reduce to this level.
</Paragraph>
</div>
) : state === "above_quota" ? (
<div className="flex flex-col pb-3">
<Paragraph variant="small" className="text-warning" spacing>
Currently you can only have up to {maxQuota} extra seats. Send a request below to
lift your current limit. We'll get back to you soon.
</Paragraph>
</div>
) : (
<div className="flex flex-col pb-3 tabular-nums">
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(extraSeats)}</span> current
extra
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(extraSeats * pricePerSeat, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({extraSeats} {extraSeats === 1 ? "seat" : "seats"})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatNumber(amountValue - extraSeats)}
</Header3>
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
{state === "increase" ? "+" : null}
{formatCurrency((amountValue - extraSeats) * pricePerSeat, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({Math.abs(amountValue - extraSeats)}{" "}
{Math.abs(amountValue - extraSeats) === 1 ? "seat" : "seats"} @{" "}
{formatCurrency(pricePerSeat, true)}/mth)
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
<div className="grid grid-cols-2 pt-2">
<Header3 className="pb-0 font-normal text-text-dimmed">
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
</Header3>
<Header3 className="justify-self-end font-normal text-text-bright">
{formatCurrency(amountValue * pricePerSeat, true)}
</Header3>
</div>
<div className="grid grid-cols-2 text-xs">
<span className="text-text-dimmed">
({amountValue} {amountValue === 1 ? "seat" : "seats"})
</span>
<span className="justify-self-end text-text-dimmed">/mth</span>
</div>
</div>
)}
</div>
<FormButtons
confirmButton={
state === "above_quota" ? (
<>
<input type="hidden" name="action" value="quota-increase" />
<Button
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
variant="primary/medium"
type="submit"
disabled={isLoading}
>
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
amountValue
)}`}</span>
</Button>
</>
) : state === "decrease" || state === "need_to_remove_members" ? (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="danger/medium"
type="submit"
disabled={isLoading || state === "need_to_remove_members"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
extraSeats - amountValue
)} ${extraSeats - amountValue === 1 ? "seat" : "seats"}`}</span>
</Button>
</>
) : (
<>
<input type="hidden" name="action" value="purchase" />
<Button
variant="primary/medium"
type="submit"
disabled={isLoading || state === "no_change"}
LeadingIcon={isLoading ? SpinnerWhite : undefined}
>
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
amountValue - extraSeats
)} ${amountValue - extraSeats === 1 ? "seat" : "seats"}`}</span>
</Button>
</>
)
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium" disabled={isLoading}>
Cancel
</Button>
</DialogClose>
}
/>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
function updateSeatState({
value,
existingValue,
quota,
usedSeats,
planSeatLimit,
}: {
value: number;
existingValue: number;
quota: number;
usedSeats: number;
planSeatLimit: number;
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_remove_members" {
if (value === existingValue) return "no_change";
if (value < existingValue) {
const newTotalLimit = planSeatLimit + value;
if (usedSeats > newTotalLimit) {
return "need_to_remove_members";
}
return "decrease";
}
if (value > quota) return "above_quota";
return "increase";
}