From 1d50f774e253909fdda1847fa27871e3e6cd5b59 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Apr 2026 20:31:35 -0700 Subject: [PATCH] fix(ui): support all duration suffixes in regenerate expiry preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calculateNewExpiryTime only handled s/h/d, but the grace-period validation and backend accept m, w, and mo as well. Entering any of those in the Expire Key field caused the function to return null, which then propagated as expires: null in the onKeyUpdate payload — the parent UI would then render the expiry as "Never" even though the backend had correctly applied the new expiry. Extend the suffix check to cover s/m/h/d/w/mo, matching "mo" before "m" so "1mo" isn't misread as minutes. Also nullish-coalesce the call site so an unparseable duration falls back to the previous expiry instead of null. Add parametric tests for each supported suffix plus a regression test for the null fallback. --- .../organisms/RegenerateKeyModal.test.tsx | 48 +++++++++++++++++++ .../organisms/RegenerateKeyModal.tsx | 24 +++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx index f77cf787a3e..a69ae779249 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.test.tsx @@ -219,6 +219,54 @@ describe("RegenerateKeyModal", () => { expect(updateCall.key_name).toBe("sk-new-regenerated-key"); }); + it.each([ + ["30s", /New expiry:/], + ["15m", /New expiry:/], + ["2h", /New expiry:/], + ["7d", /New expiry:/], + ["2w", /New expiry:/], + ["1mo", /New expiry:/], + ])("should compute a new expiry preview for duration '%s'", async (durationInput, expected) => { + const user = userEvent.setup(); + renderWithProviders(); + + const durationField = screen.getByPlaceholderText("e.g. 30s, 30h, 30d"); + await user.clear(durationField); + await user.type(durationField, durationInput); + + await waitFor(() => { + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + }); + + it("should fall back to the previous expiry when duration is unparseable", async () => { + // Regression: if calculateNewExpiryTime returns null (unrecognised suffix), + // the payload should fall back to the previous expires rather than null. + const user = userEvent.setup(); + const previousExpires = "2026-12-31T00:00:00Z"; + mockRegenerateKeyCall.mockResolvedValue({ + key: "sk-new-regenerated-key", + token: "new-token-hash", + }); + + renderWithProviders( + , + ); + + const durationField = screen.getByPlaceholderText("e.g. 30s, 30h, 30d"); + await user.clear(durationField); + await user.type(durationField, "bogus"); + + await user.click(screen.getByRole("button", { name: /Regenerate/ })); + + await waitFor(() => { + expect(mockOnKeyUpdate).toHaveBeenCalledOnce(); + }); + + const updateCall = mockOnKeyUpdate.mock.calls[0][0]; + expect(updateCall.expires).toBe(previousExpires); + }); + it("should pass form values to onKeyUpdate even when the API echoes back different limits", async () => { // Regression: when the regenerate endpoint returns GenerateKeyResponse, it echoes // back the existing max_budget / tpm_limit / rpm_limit. The modal must prefer the diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx index c714a15eb98..babbf9989e6 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.tsx @@ -68,15 +68,25 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat if (!duration) return null; try { + const amount = parseInt(duration); + if (Number.isNaN(amount)) { + throw new Error("Invalid duration format"); + } const now = new Date(); + // Check "mo" before "m" to avoid a false prefix match (e.g. "1mo" → minutes). let newExpiry: Date; - - if (duration.endsWith("s")) { - newExpiry = add(now, { seconds: parseInt(duration) }); + if (duration.endsWith("mo")) { + newExpiry = add(now, { months: amount }); + } else if (duration.endsWith("s")) { + newExpiry = add(now, { seconds: amount }); + } else if (duration.endsWith("m")) { + newExpiry = add(now, { minutes: amount }); } else if (duration.endsWith("h")) { - newExpiry = add(now, { hours: parseInt(duration) }); + newExpiry = add(now, { hours: amount }); } else if (duration.endsWith("d")) { - newExpiry = add(now, { days: parseInt(duration) }); + newExpiry = add(now, { days: amount }); + } else if (duration.endsWith("w")) { + newExpiry = add(now, { weeks: amount }); } else { throw new Error("Invalid duration format"); } @@ -122,7 +132,9 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat max_budget: formValues.max_budget, tpm_limit: formValues.tpm_limit, rpm_limit: formValues.rpm_limit, - expires: formValues.duration ? calculateNewExpiryTime(formValues.duration) : selectedToken.expires, + expires: formValues.duration + ? (calculateNewExpiryTime(formValues.duration) ?? selectedToken.expires) + : selectedToken.expires, }; // Update the parent component with new key data