From 4ad0d93229e9d87d8d1797e2f1e6f9936598d670 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 11:15:19 +0900 Subject: [PATCH 1/5] feat(gui): read and redeem Grok reset coupons from the xAI account rows The coupon routes shipped in #4306 with a CLI verb and no dashboard surface. Each xAI OAuth row now carries a ticket badge with its remaining coupon count, and the badge opens a dialog that lists validity windows and redeems the coupon closest to expiry. Three behaviours are deliberate rather than incidental: - Redemption truth is the settled ledger code, not HTTP 200. The route replays a settled failure as 200 with replayed: true and the original code, so reading only that flag would announce a failed redemption as a completed reset. - The roster epoch and the per-account request token are separate, so one row's retry cannot discard a sibling row's in-flight read. - An aborted redemption stops posting. The route re-executes a redemption whose journal record is still open, so a retry after a timeout can spend a second coupon; the dialog holds its operation id, reports the outcome as unknown, and offers only a re-read. Reads are bounded to three in flight and cover only the accounts of the open provider. --- .../provider-workspace/GrokResetCoupons.tsx | 310 +++++++++++++++++ .../provider-workspace/ProviderAuthPanel.tsx | 51 ++- gui/src/hooks/useGrokResetCoupons.ts | 207 ++++++++++++ gui/src/i18n/en.ts | 39 +++ gui/tests/grok-reset-coupons.test.tsx | 318 ++++++++++++++++++ 5 files changed, 919 insertions(+), 6 deletions(-) create mode 100644 gui/src/components/provider-workspace/GrokResetCoupons.tsx create mode 100644 gui/src/hooks/useGrokResetCoupons.ts create mode 100644 gui/tests/grok-reset-coupons.test.tsx diff --git a/gui/src/components/provider-workspace/GrokResetCoupons.tsx b/gui/src/components/provider-workspace/GrokResetCoupons.tsx new file mode 100644 index 0000000000..d0deca4fd0 --- /dev/null +++ b/gui/src/components/provider-workspace/GrokResetCoupons.tsx @@ -0,0 +1,310 @@ +/** + * Grok reset-coupon badge and redemption dialog for xAI OAuth account rows. + * + * The dialog is deliberately conservative about the one irreversible thing it + * does. It always names the coupon it is spending, it holds one client-minted + * operation id per confirmation, and when a redemption aborts it stops posting + * entirely: the route re-executes a redemption whose journal record is still + * open, so a retry after a timeout can spend a second coupon. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useI18n, type Locale, type TFn, type TKey } from "../../i18n/shared"; +import { IconAlert, IconTicket } from "../../icons"; +import { daysUntil, formatCreditDate, formatCreditDateTime } from "../codex-account-pool-utils"; +import type { GrokCouponEntry, GrokResetCoupon, GrokResetCouponController } from "../../hooks/useGrokResetCoupons"; + +function couponsOf(entry: GrokCouponEntry | undefined): GrokResetCoupon[] { + return entry?.status === "ready" ? entry.coupons : []; +} + +function newOperationId(): string | undefined { + const api = globalThis.crypto; + if (api && typeof api.randomUUID === "function") return api.randomUUID(); + if (api && typeof api.getRandomValues === "function") { + const bytes = api.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map(byte => byte.toString(16).padStart(2, "0")).join(""); + return [hex.slice(0, 8), hex.slice(8, 12), hex.slice(12, 16), hex.slice(16, 20), hex.slice(20)].join("-"); + } + // Without an id the journal cannot recognise a repeat, so the dialog refuses + // rather than letting the route mint a fresh id per attempt. + return undefined; +} + +const FAILURE_KEYS: Record = { + auth_failed: "grokCoupon.authFailed", + no_account: "grokCoupon.noAccount", + no_coupons_available: "grokCoupon.noneAvailable", + operation_id_owned_by_another_account: "grokCoupon.identityMismatch", + capacity: "grokCoupon.capacity", + unavailable: "grokCoupon.capacity", + network: "grokCoupon.networkError", + redeem_failed: "grokCoupon.redeemFailed", +}; + +/** Ticket badge on an xAI account row. Muted at zero, amber when redeemable. */ +export function GrokCouponBadge({ entry, onClick, t }: { + entry: GrokCouponEntry | undefined; + onClick: () => void; + t: TFn; +}) { + if (entry === undefined || entry.status === "loading") { + // Reserve the width so the row does not shift when the count lands. Same + // aria-hidden placeholder the Codex ticket badge uses. + return ( + + ); + } + const count = entry.status === "ready" ? entry.coupons.length : null; + const label = count === null + ? t("grokCoupon.badgeErrorAria") + : t("grokCoupon.badgeAria", { count: String(count) }); + return ( + + ); +} + +function GrokCouponItem({ coupon, index, isNext, locale, t }: { + coupon: GrokResetCoupon; + index: number; + isNext: boolean; + locale: Locale; + t: TFn; +}) { + const days = coupon.validityEnd ? daysUntil(coupon.validityEnd) : null; + return ( +
+
+ + + {isNext ? t("grokCoupon.couponNext") : t("grokCoupon.couponLabel", { n: String(index + 1) })} + + {isNext && ( + + {t("grokCoupon.couponNextBadge")} + + )} +
+
+ {coupon.validityStart && {t("grokCoupon.validFrom", { date: formatCreditDate(coupon.validityStart, locale) })}} + {days !== null && ( + + {t("grokCoupon.expires", { date: formatCreditDateTime(coupon.validityEnd, locale), days: String(days) })} + + )} +
+
+ ); +} + +type Outcome = { tone: "ok" | "warn"; key: TKey }; + +export function GrokResetCouponModal({ accountId, accountLabel, entry, controller, onClose }: { + accountId: string; + accountLabel: string; + entry: GrokCouponEntry | undefined; + controller: GrokResetCouponController; + onClose: () => void; +}) { + const { locale, t } = useI18n(); + const dialogRef = useRef(null); + const redeemRef = useRef(null); + const [confirming, setConfirming] = useState(false); + const [redeeming, setRedeeming] = useState(false); + const [checking, setChecking] = useState(false); + /** Set by an aborted redemption; while it holds, the dialog posts nothing. */ + const [unknown, setUnknown] = useState<{ tokenId: string } | null>(null); + const [outcome, setOutcome] = useState(null); + const operationIdRef = useRef(undefined); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + useEffect(() => { + if (confirming) redeemRef.current?.focus(); + }, [confirming]); + + const handleCancel = useCallback((event: React.SyntheticEvent) => { + event.preventDefault(); + onClose(); + }, [onClose]); + + const coupons = couponsOf(entry); + const next = coupons[0]; + + const startConfirm = () => { + if (unknown) return; + const id = newOperationId(); + if (!id) { + setOutcome({ tone: "warn", key: "grokCoupon.noOperationId" }); + return; + } + operationIdRef.current = id; + setOutcome(null); + setConfirming(true); + }; + + const redeem = async () => { + if (redeeming || unknown) return; + const operationId = operationIdRef.current; + if (!next?.tokenId) { + setOutcome({ tone: "warn", key: "grokCoupon.noneAvailable" }); + return; + } + if (!operationId) { + setOutcome({ tone: "warn", key: "grokCoupon.noOperationId" }); + return; + } + setRedeeming(true); + const result = await controller.redeem(accountId, { tokenId: next.tokenId, operationId }); + setRedeeming(false); + if (result.ok) { + operationIdRef.current = undefined; + setConfirming(false); + setOutcome({ tone: "ok", key: result.replayed ? "grokCoupon.redeemReplayed" : "grokCoupon.redeemSuccess" }); + return; + } + if (result.code === "aborted") { + // Outcome unknown: hold the id, stop posting, and let the user re-read. + setUnknown({ tokenId: next.tokenId }); + setOutcome(null); + void controller.refresh(accountId); + return; + } + if (result.code === "operation_id_owned_by_another_account") operationIdRef.current = undefined; + setOutcome({ tone: "warn", key: FAILURE_KEYS[result.code] ?? "grokCoupon.redeemFailed" }); + }; + + const recheck = async () => { + if (!unknown || checking) return; + setChecking(true); + await controller.refresh(accountId); + setChecking(false); + }; + + const unresolvedToken = unknown + ? couponsOf(entry).some(coupon => coupon.tokenId === unknown.tokenId) + : false; + const remaining = String(coupons.length); + + const message = (result: Outcome) => ( +

+ {t(result.key, { count: remaining })} +

+ ); + + return ( + + + + + + ) : !confirming ? ( + <> +

{t("grokCoupon.title")}

+
{accountLabel}
+
+ {entry === undefined || entry.status === "loading" ? ( +

{t("common.loading")}

+ ) : entry.status === "error" ? ( + <> +

+ {t(entry.reason === "auth" ? "grokCoupon.loadFailedAuth" : "grokCoupon.loadFailed")} +

+ + + ) : coupons.length > 0 ? ( + <> +

{t("grokCoupon.available", { count: remaining })}

+
+ {coupons.map((coupon, index) => ( + + ))} +
+ +

{t("grokCoupon.fifoNote")}

+ + ) : ( + <> +

{t("grokCoupon.none")}

+

{t("grokCoupon.desc")}

+ + )} + {outcome && message(outcome)} +
+ + ) : ( + <> +
+
+

{t("grokCoupon.confirmTitle")}

+

{t("grokCoupon.confirmDesc", { count: remaining })}

+ {next?.validityEnd && ( +

+ {t("grokCoupon.confirmWhich", { date: formatCreditDate(next.validityEnd, locale) })} +

+ )} +

{t("grokCoupon.irreversible")}

+ {outcome && message(outcome)} +
+
+ + +
+ + )} + +
+ ); +} diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index fecf40ac2b..6ad65475f4 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -3,7 +3,7 @@ * embedding for the workspace Settings tab (WP091). Consumes WP040+WP060 * handlers via props-down; no internal auth machinery. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import { IconLock, IconRefresh, IconTrash } from "../../icons"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; @@ -21,7 +21,9 @@ import AnthropicAccountPoolSettings from "./AnthropicAccountPoolSettings"; import { LoginHint as LoginHintView } from "../login-url-block"; import { OpenBrowserPrefToggle } from "../open-browser-pref-toggle"; import ProviderAccountQuota from "./ProviderAccountQuota"; +import { GrokCouponBadge, GrokResetCouponModal } from "./GrokResetCoupons"; import type { CodexAccountPoolController } from "../../hooks/useCodexAccountPool"; +import { useGrokResetCoupons } from "../../hooks/useGrokResetCoupons"; import { Switch } from "../../ui"; import type { AccountLoadState, @@ -37,6 +39,15 @@ const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024; const EMPTY_OAUTH_ACCOUNTS: OAuthAccountRow[] = []; const EMPTY_API_KEYS: ApiKeyRow[] = []; +/** + * One predicate for "this row cannot spend a coupon right now". The read set and + * the badge must agree: a row fetched here and hidden there is a billing RPC + * spent on a 401. + */ +function accountShowsReauth(account: OAuthAccountRow): boolean { + return Boolean(account.needsReauth) || oauthHealthShowsReauth(account.health?.status); +} + function XaiChatOptInControl({ initialState, onUpdateProvider, @@ -207,6 +218,22 @@ export default function ProviderAuthPanel({ }, [connectionIdentity]); const onRefreshQuota = authHandlers?.onRefreshQuota; + const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); + const isOauth = surface === "oauth-accounts"; + const isKeyAuth = surface === "api-keys"; + // Grok reset coupons live behind a billing RPC rather than the quota payload, + // so the xAI rows read them once per roster instead of riding the quota probe. + // The gate names the OAuth surface here rather than relying on the roster + // loader three files away to leave `accounts` empty for key-auth xAI. + const grokCouponsEnabled = isOauth && item.name === "xai" && accounts.length > 0; + const grokAccountIds = useMemo( + () => (grokCouponsEnabled + ? accounts.filter(account => !accountShowsReauth(account)).map(account => account.id) + : []), + [grokCouponsEnabled, accounts], + ); + const grokCoupons = useGrokResetCoupons({ apiBase, accountIds: grokAccountIds, enabled: grokCouponsEnabled }); + const [couponAccount, setCouponAccount] = useState(null); const refreshQuota = async () => { if (!onRefreshQuota || refreshingQuota) return; const generation = ++quotaRefreshGeneration.current; @@ -222,10 +249,6 @@ export default function ProviderAuthPanel({ } }; - const surface = providerAuthSurface({ ...item, hasApiKey: item.hasApiKey || keys.length > 0 }); - const isOauth = surface === "oauth-accounts"; - const isKeyAuth = surface === "api-keys"; - if (surface === "codex-accounts") { return (
@@ -495,7 +518,7 @@ export default function ProviderAuthPanel({ const label = oauthAccountDisplayLabel(accounts, account, t); const switching = switchingAccountId === account.id; const healthStatus = account.health?.status; - const showReauth = Boolean(account.needsReauth) || oauthHealthShowsReauth(healthStatus); + const showReauth = accountShowsReauth(account); const inCooldown = oauthHealthIsCooldown(healthStatus); const maskedId = displayAccountId(account.id); const healthLabel = formatOAuthHealthLabel(t, account.health); @@ -536,6 +559,13 @@ export default function ProviderAuthPanel({ {t("pws.reauthenticate")} )} + {grokCouponsEnabled && !showReauth && ( + setCouponAccount(account)} + /> + )}