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["members"][number]; type Invite = UseDataFunctionReturn["invites"][number]; type Role = UseDataFunctionReturn["roles"][number]; export default function Page() { const { members, invites, limits, canPurchaseSeats, extraSeats, seatPricing, maxSeatQuota, planSeatLimit, roles, assignableRoleIds, memberRoles, canManageMembers, canManageBilling, manualMembershipAllowed, } = useTypedLoaderData(); // Build a userId → roleId map so the dropdown's defaultValue matches // each member's current assignment without re-querying. const memberRoleByUserId = new Map( 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 ( Org ID {organization.id} {members.map((member) => ( {member.user.name}
{member.user.email} - {member.user.id}
))}
{!manualMembershipAllowed ? ( // Directory Sync is the membership authority — manual invites are // disabled. The invite action + acceptInvite enforce this too. Invite a team member } 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. Invite a team member } content="You don't have permission to invite team members" disableHoverableContent /> ) : requiresUpgrade ? ( Invite a team member } content="Purchase more seats to invite more team members" disableHoverableContent /> ) : ( Invite a team member )}
{invites.length > 0 && ( <> Pending invites
    {invites.map((invite) => (
  • {invite.email} Invite sent {}
  • ))}
)}
Active team members {roles.length > 0 ? ( View all role permissions → ) : null}
{members.map((member) => (
{member.user.name}{" "} {member.user.id === user.id && ( (You) )} {member.user.email}
))}
} content={`${Math.round(usageRatio * 100)}%`} />
{requiresUpgrade ? ( 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."} ) : ( You've used {limits.used}/{limits.limit} of your seats )} {canPurchaseSeats && seatPricing ? ( ) : canUpgrade ? ( showSelfServe ? ( Upgrade ) : ( Request more} /> ) ) : null}
); } 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 ( {isSelf ? "Leave team" : "Remove from team"} } disableHoverableContent content="Membership is managed by Directory Sync" /> ); } if (userId === member.user.id) { if (memberCount === 1) { return ( Leave team } 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 ( ); } //you remove another member — requires manage:members if (!canManageMembers) { return ( Remove from team } disableHoverableContent content="You don't have permission to remove team members" /> ); } return ( ); } // 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 = ( ); return (
{canManageMembers ? ( picker ) : ( // Disabled ); } 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>(); 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 (
); } function RevokeButton({ invite, canManageMembers }: { invite: Invite; canManageMembers: boolean }) { const organization = useOrganization(); return (
} content={ canManageMembers ? "Revoke invite" : "You don't have permission to manage team members" } disableHoverableContent asChild /> ); } 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 ( Request more} /> ); } // 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 ?? ( )) ) : triggerButton ? ( cloneElement(triggerButton, { disabled: true, tooltip: noBillingTooltip }) ) : ( {title} ); return ( {trigger} {title}
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).
setAmountValue(Number(e.target.value))} disabled={isLoading} /> {amount.errors} {form.errors}
{state === "need_to_remove_members" ? (
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.
) : state === "above_quota" ? (
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.
) : (
Summary Total
{formatNumber(extraSeats)} current extra {formatCurrency(extraSeats * pricePerSeat, true)}
({extraSeats} {extraSeats === 1 ? "seat" : "seats"}) /mth
{state === "increase" ? "+" : null} {formatNumber(amountValue - extraSeats)} {state === "increase" ? "+" : null} {formatCurrency((amountValue - extraSeats) * pricePerSeat, true)}
({Math.abs(amountValue - extraSeats)}{" "} {Math.abs(amountValue - extraSeats) === 1 ? "seat" : "seats"} @{" "} {formatCurrency(pricePerSeat, true)}/mth) /mth
{formatNumber(amountValue)} new total {formatCurrency(amountValue * pricePerSeat, true)}
({amountValue} {amountValue === 1 ? "seat" : "seats"}) /mth
)}
) : state === "decrease" || state === "need_to_remove_members" ? ( <> ) : ( <> ) } cancelButton={ } />
); } 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"; }