mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(team): revoke Undo after send re-invites, re-invite cancels a pending revoke; design P1/P2 polish
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user