fix(team): revoke Undo after send re-invites, re-invite cancels a pending revoke; design P1/P2 polish

This commit is contained in:
Neil
2026-11-09 07:33:29 -08:00
parent 4e94e36ef0
commit 16ffbaaf37
5 changed files with 50 additions and 26 deletions
@@ -186,7 +186,11 @@ export function PlanBillingView({
</div>
) : null}
{hasPlan && entitlement.reason && entitlement.payment.pastDueSince === null ? (
{/* The overage card carries the cap-reached hint for admins; don't repeat it here. */}
{hasPlan &&
entitlement.reason &&
entitlement.payment.pastDueSince === null &&
!(entitlement.reason === 'overage_cap_reached' && entitlement.canManageBilling) ? (
<p className="text-xs text-muted-foreground">
{denyReasonCopy(entitlement.reason, t, entitlement.canManageBilling)}
</p>
@@ -220,7 +224,7 @@ export function PlanBillingView({
</Button>
) : null}
{hasPlan ? (
<Button type="button" variant="link" size="inline" onClick={onSeePlans}>
<Button type="button" variant="ghost" size="sm" onClick={onSeePlans}>
{t('auto.components.billing.comparePlans', 'Compare plans')}
</Button>
) : null}
@@ -88,18 +88,12 @@ export function UsageSummary({
) : null}
{entitlement.overageSeconds > 0 ? (
<p className="text-xs text-muted-foreground tabular-nums">
{t(
'auto.components.billing.overageHours',
'{{value0}} overage hours · {{value1}} of {{value2}} cap',
{
value0: formatHours(entitlement.overageSeconds),
value1: formatCents(entitlement.overage.spentCents, locale),
value2: formatCents(entitlement.overage.spendCapCents, locale)
}
)}
{t('auto.components.billing.overageHoursOnly', '{{value0}} overage hours this month', {
value0: formatHours(entitlement.overageSeconds)
})}
</p>
) : null}
{entitlement.usageWarningPercent !== null ? (
{entitlement.usageWarningPercent !== null && entitlement.usageWarningPercent < 100 ? (
<p className="text-xs font-medium">
{entitlement.usageWarningPercent >= 100
? t('auto.components.billing.warn100b', 'All of this month’s sandbox hours are used.')
@@ -153,8 +147,8 @@ export function OverageControls({
const largeWeight = entitlement.offers?.team.largeMachineWeight ?? 2
const paymentFailed = entitlement.payment.pastDueSince !== null
const description = t(
'auto.components.billing.overageDescription3',
'Optional overage at {{value0}}/hour ({{value1}}/hour on Large), capped at a monthly spend cap you set ({{value2}} by default).',
'auto.components.billing.overageDescription4',
'Optional overage at {{value0}}/hour ({{value1}}/hour on Large), up to a monthly spend cap you set ({{value2}} by default).',
{
value0: formatCents(overage.centsPerHour, locale),
value1: formatCents(Math.round(overage.centsPerHour * largeWeight), locale),
@@ -277,6 +271,14 @@ export function OverageControls({
}}
/>
</div>
{capValid && entitlement.reason === 'overage_cap_reached' ? (
<p className="text-xs text-muted-foreground">
{t(
'auto.components.billing.capReachedHint',
'Raise the cap to keep starting sandboxes. Your files are safe.'
)}
</p>
) : null}
{capValid ? null : (
<p id={capErrorId} className="text-xs text-destructive">
{t('auto.components.billing.capInvalid', 'Enter a whole dollar amount.')}
@@ -162,8 +162,8 @@ export function CheckoutPanel(props: CheckoutPanelProps): React.JSX.Element {
case 'paid':
title = t('auto.components.billing.checkout.paid', 'Payment confirmed')
body = t(
'auto.components.billing.checkout.paidBody',
'You’re on {{value0}}. Opening your team…',
'auto.components.billing.checkout.paidBody2',
'{{value0}} plan is active. Opening your team…',
{ value0: props.team.label }
)
break
@@ -242,7 +242,9 @@ export function TeamMemberList(props: TeamMemberListProps): React.JSX.Element {
) : null}
{row.status === 'active' ? null : (
<Badge variant="outline" className="shrink-0">
{memberStatusLabel(row.status, t)}
<span className="text-muted-foreground">
{memberStatusLabel(row.status, t)}
</span>
</Badge>
)}
</p>
@@ -53,6 +53,8 @@ export function useTeamActions({
{ id: number; apply: Overlay; settledAfterSeq: number | null }[]
>([])
const nextOverlayId = useRef(0)
/** Revokes waiting out their Undo window, by lowercased email → cancel. */
const pendingRevokes = useRef(new Map<string, () => void>())
const [rowStates, setRowStates] = useState<Record<string, TeamRowState>>({})
const setRow = useCallback((key: string, state: TeamRowState | null) => {
@@ -131,6 +133,9 @@ export function useTeamActions({
const invite = async (emails: string[], role: OrcaOrgRole): Promise<InviteBatchOutcome> => {
const now = Date.now()
for (const email of emails) {
pendingRevokes.current.get(email.toLowerCase())?.()
}
const failed: InviteBatchOutcome['failed'] = []
// Sequential: auth rate-limits member changes per actor, and order keeps errors readable.
for (const email of emails) {
@@ -177,7 +182,7 @@ export function useTeamActions({
return { failed }
}
const revoke = (email: string, _role: OrcaOrgRole): void => {
const revoke = (email: string, role: OrcaOrgRole): void => {
focusMembersHeading()
const key = inviteKey(email)
const id = ++nextOverlayId.current
@@ -188,11 +193,18 @@ export function useTeamActions({
)
})
setOverlays((list) => [...list, { id, apply: hide, settledAfterSeq: null }])
let undone = false
// Sonner pauses its timer on hover and in background windows; ours doesn't. Once the revoke
// is sent the toast is dismissed, and a late Undo re-invites instead of pretending.
let sent = false
const cancel = (): void => {
window.clearTimeout(timer)
pendingRevokes.current.delete(email.toLowerCase())
setOverlays((list) => list.filter((entry) => entry.id !== id))
}
const timer = window.setTimeout(() => {
if (undone) {
return
}
sent = true
pendingRevokes.current.delete(email.toLowerCase())
toast.dismiss(toastId)
void settle(() => api.orgInviteRevoke({ orgId, email })).then((outcome) => {
if (outcome.status === 'ok') {
const settledAfterSeq = currentRosterReadSeq()
@@ -206,7 +218,9 @@ export function useTeamActions({
}
})
}, REVOKE_UNDO_MS)
toast.success(
// Inviting the same address again before the revoke goes out cancels it (see invite()).
pendingRevokes.current.set(email.toLowerCase(), cancel)
const toastId = toast.success(
t('auto.components.billing.team.revokedToast', 'Invite to {{value0}} revoked', {
value0: email
}),
@@ -215,9 +229,11 @@ export function useTeamActions({
action: {
label: t('auto.components.billing.team.undo', 'Undo'),
onClick: () => {
undone = true
window.clearTimeout(timer)
setOverlays((list) => list.filter((entry) => entry.id !== id))
if (sent) {
void invite([email], role)
} else {
cancel()
}
}
}
}