fix(ui): support all duration suffixes in regenerate expiry preview

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.
This commit is contained in:
Yuneng Jiang 2026-04-09 20:31:35 -07:00
parent f95ef935ef
commit 1d50f774e2
No known key found for this signature in database
2 changed files with 66 additions and 6 deletions

View file

@ -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(<RegenerateKeyModal {...defaultProps} />);
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(
<RegenerateKeyModal {...defaultProps} selectedToken={makeToken({ expires: previousExpires })} />,
);
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

View file

@ -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