From 16ffbaaf37e109589d536cc70ff4d81f95d41cc5 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 9 Nov 2026 07:32:05 -0800 Subject: [PATCH] fix(team): revoke Undo after send re-invites, re-invite cancels a pending revoke; design P1/P2 polish --- .../components/billing/PlanBillingSection.tsx | 8 +++-- .../components/billing/PlanBillingUsage.tsx | 26 +++++++------- .../billing/checkout/CheckoutPanel.tsx | 4 +-- .../billing/team/TeamMemberList.tsx | 4 ++- .../billing/team/use-team-actions.ts | 34 ++++++++++++++----- 5 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/renderer/src/components/billing/PlanBillingSection.tsx b/src/renderer/src/components/billing/PlanBillingSection.tsx index c93e9c051ae..62f67b1370e 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.tsx @@ -186,7 +186,11 @@ export function PlanBillingView({ ) : 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) ? (

{denyReasonCopy(entitlement.reason, t, entitlement.canManageBilling)}

@@ -220,7 +224,7 @@ export function PlanBillingView({ ) : null} {hasPlan ? ( - ) : null} diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index b135e90bca1..829e8d4eba5 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -88,18 +88,12 @@ export function UsageSummary({ ) : null} {entitlement.overageSeconds > 0 ? (

- {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) + })}

) : null} - {entitlement.usageWarningPercent !== null ? ( + {entitlement.usageWarningPercent !== null && entitlement.usageWarningPercent < 100 ? (

{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({ }} /> + {capValid && entitlement.reason === 'overage_cap_reached' ? ( +

+ {t( + 'auto.components.billing.capReachedHint', + 'Raise the cap to keep starting sandboxes. Your files are safe.' + )} +

+ ) : null} {capValid ? null : (

{t('auto.components.billing.capInvalid', 'Enter a whole dollar amount.')} diff --git a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx index 6d86f97a19c..5f14099e9ce 100644 --- a/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx +++ b/src/renderer/src/components/billing/checkout/CheckoutPanel.tsx @@ -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 diff --git a/src/renderer/src/components/billing/team/TeamMemberList.tsx b/src/renderer/src/components/billing/team/TeamMemberList.tsx index b3b9dd66a03..5cc4aa888ca 100644 --- a/src/renderer/src/components/billing/team/TeamMemberList.tsx +++ b/src/renderer/src/components/billing/team/TeamMemberList.tsx @@ -242,7 +242,9 @@ export function TeamMemberList(props: TeamMemberListProps): React.JSX.Element { ) : null} {row.status === 'active' ? null : ( - {memberStatusLabel(row.status, t)} + + {memberStatusLabel(row.status, t)} + )}

diff --git a/src/renderer/src/components/billing/team/use-team-actions.ts b/src/renderer/src/components/billing/team/use-team-actions.ts index cb40aecf34e..91ce8988ab0 100644 --- a/src/renderer/src/components/billing/team/use-team-actions.ts +++ b/src/renderer/src/components/billing/team/use-team-actions.ts @@ -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 void>()) const [rowStates, setRowStates] = useState>({}) const setRow = useCallback((key: string, state: TeamRowState | null) => { @@ -131,6 +133,9 @@ export function useTeamActions({ const invite = async (emails: string[], role: OrcaOrgRole): Promise => { 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() + } } } }