fix(ui): require new expiration when regenerating an expired key (#29838)
This commit is contained in:
parent
22186f457a
commit
1f171ee018
@ -10,6 +10,15 @@ vi.mock("../networking", () => ({
|
||||
regenerateKeyCall: (...args: unknown[]) => mockRegenerateKeyCall(...args),
|
||||
}));
|
||||
|
||||
const mockNotificationFromBackend = vi.fn();
|
||||
const mockNotificationSuccess = vi.fn();
|
||||
vi.mock("../molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
fromBackend: (...args: unknown[]) => mockNotificationFromBackend(...args),
|
||||
success: (...args: unknown[]) => mockNotificationSuccess(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
const makeToken = (overrides: Partial<KeyResponse> = {}): KeyResponse =>
|
||||
({
|
||||
token: "token-hash-123",
|
||||
@ -239,9 +248,29 @@ describe("RegenerateKeyModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
it("should use the API response's ISO expires for the optimistic update", async () => {
|
||||
const user = userEvent.setup();
|
||||
const apiExpires = "2026-06-13T11:08:16.783000Z";
|
||||
mockRegenerateKeyCall.mockResolvedValue({
|
||||
key: "sk-new-regenerated-key",
|
||||
token: "new-token-hash",
|
||||
expires: apiExpires,
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<RegenerateKeyModal {...defaultProps} selectedToken={makeToken({ expires: "2026-12-31T00:00:00Z" })} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Regenerate/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnKeyUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
expect(mockOnKeyUpdate.mock.calls[0][0].expires).toBe(apiExpires);
|
||||
});
|
||||
|
||||
it("should fall back to the previous expiry when the API response omits expires", async () => {
|
||||
const user = userEvent.setup();
|
||||
const previousExpires = "2026-12-31T00:00:00Z";
|
||||
mockRegenerateKeyCall.mockResolvedValue({
|
||||
@ -253,6 +282,20 @@ describe("RegenerateKeyModal", () => {
|
||||
<RegenerateKeyModal {...defaultProps} selectedToken={makeToken({ expires: previousExpires })} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Regenerate/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnKeyUpdate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
expect(mockOnKeyUpdate.mock.calls[0][0].expires).toBe(previousExpires);
|
||||
});
|
||||
|
||||
it("should reject unparseable duration values before calling regenerate", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<RegenerateKeyModal {...defaultProps} />);
|
||||
|
||||
const durationField = screen.getByPlaceholderText("e.g. 30s, 30h, 30d");
|
||||
await user.clear(durationField);
|
||||
await user.type(durationField, "bogus");
|
||||
@ -260,11 +303,10 @@ describe("RegenerateKeyModal", () => {
|
||||
await user.click(screen.getByRole("button", { name: /Regenerate/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnKeyUpdate).toHaveBeenCalledOnce();
|
||||
expect(screen.getByText("Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const updateCall = mockOnKeyUpdate.mock.calls[0][0];
|
||||
expect(updateCall.expires).toBe(previousExpires);
|
||||
expect(mockRegenerateKeyCall).not.toHaveBeenCalled();
|
||||
expect(mockNotificationFromBackend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should pass form values to onKeyUpdate even when the API echoes back different limits", async () => {
|
||||
@ -338,6 +380,41 @@ describe("RegenerateKeyModal", () => {
|
||||
expect(mockRegenerateKeyCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should mark expiry as expired without pre-filling a duration", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-06-06T12:00:00Z"));
|
||||
|
||||
renderWithProviders(
|
||||
<RegenerateKeyModal
|
||||
{...defaultProps}
|
||||
selectedToken={makeToken({ expires: "2026-06-01T12:00:00Z", duration: "" })}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/\(expired\)/)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("e.g. 30s, 30h, 30d")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should require a new expiration before regenerating an expired key", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-06-06T12:00:00Z"));
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<RegenerateKeyModal
|
||||
{...defaultProps}
|
||||
selectedToken={makeToken({ expires: "2026-06-01T12:00:00Z", duration: "" })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Regenerate/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Expiration is required for expired keys")).toBeInTheDocument();
|
||||
});
|
||||
expect(mockRegenerateKeyCall).not.toHaveBeenCalled();
|
||||
// Form validation rejections must not surface a backend-style toast.
|
||||
expect(mockNotificationFromBackend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should pass the correct token identifier to regenerateKeyCall", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockRegenerateKeyCall.mockResolvedValue({
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { CheckOutlined, CopyOutlined, SyncOutlined } from "@ant-design/icons";
|
||||
import { Alert, Button, Col, Flex, Form, Input, InputNumber, Modal, Row, Space, Typography } from "antd";
|
||||
import { add } from "date-fns";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { KeyResponse } from "../key_team_helpers/key_list";
|
||||
import NotificationManager from "../molecules/notifications_manager";
|
||||
import { regenerateKeyCall } from "../networking";
|
||||
import { calculateExpiryPreviewFromDuration, formatExpiresUtc, isKeyExpired } from "@/utils/keyExpiryUtils";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const DURATION_RULE = {
|
||||
pattern: /^(\d+(s|m|h|d|w|mo))?$/,
|
||||
message: "Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo",
|
||||
};
|
||||
|
||||
interface RegenerateKeyModalProps {
|
||||
selectedToken: KeyResponse | null;
|
||||
visible: boolean;
|
||||
@ -21,11 +26,18 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
|
||||
const { accessToken } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const [regeneratedKey, setRegeneratedKey] = useState<string | null>(null);
|
||||
const [regenerateFormData, setRegenerateFormData] = useState<any>(null);
|
||||
const [newExpiryTime, setNewExpiryTime] = useState<string | null>(null);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const keyIsExpired = isKeyExpired(selectedToken?.expires);
|
||||
const durationValue = Form.useWatch("duration", form);
|
||||
|
||||
// Expired keys must get a new duration, otherwise regeneration produces a key
|
||||
// that inherits the old (past) expiry and is immediately unusable.
|
||||
const durationRules = keyIsExpired
|
||||
? [{ required: true, message: "Expiration is required for expired keys" }, DURATION_RULE]
|
||||
: [DURATION_RULE];
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && selectedToken && accessToken) {
|
||||
form.setFieldsValue({
|
||||
@ -39,46 +51,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
|
||||
}
|
||||
}, [visible, selectedToken, form, accessToken]);
|
||||
|
||||
const calculateNewExpiryTime = (duration: string | undefined): string | null => {
|
||||
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("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: amount });
|
||||
} else if (duration.endsWith("d")) {
|
||||
newExpiry = add(now, { days: amount });
|
||||
} else if (duration.endsWith("w")) {
|
||||
newExpiry = add(now, { weeks: amount });
|
||||
} else {
|
||||
throw new Error("Invalid duration format");
|
||||
}
|
||||
|
||||
return newExpiry.toLocaleString();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (regenerateFormData?.duration) {
|
||||
setNewExpiryTime(calculateNewExpiryTime(regenerateFormData.duration));
|
||||
} else {
|
||||
setNewExpiryTime(null);
|
||||
}
|
||||
}, [regenerateFormData?.duration]);
|
||||
const newExpiryTime = durationValue ? calculateExpiryPreviewFromDuration(durationValue) : null;
|
||||
|
||||
const handleRegenerateKey = async () => {
|
||||
if (!selectedToken || !accessToken) return;
|
||||
@ -95,6 +68,8 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
|
||||
// fields it returns (new token, timestamps, etc.) are captured, then
|
||||
// override with the explicit form values — the user's just-submitted
|
||||
// edits must win over whatever the API echoes back.
|
||||
// expires must come from the API (an ISO string), never the locale-
|
||||
// formatted preview, otherwise downstream expiry parsing breaks.
|
||||
const updatedKeyData: Partial<KeyResponse> = {
|
||||
...response,
|
||||
token: response.token || response.key_id || selectedToken.token,
|
||||
@ -102,9 +77,7 @@ 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
|
||||
: selectedToken.expires,
|
||||
expires: response.expires ?? selectedToken.expires,
|
||||
};
|
||||
|
||||
// Update the parent component with new key data
|
||||
@ -114,9 +87,14 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
|
||||
|
||||
setIsRegenerating(false);
|
||||
} catch (error) {
|
||||
setIsRegenerating(false); // Reset regenerating state on error
|
||||
// Ant Design form validation rejections surface inline under the field;
|
||||
// don't also raise a backend-style toast for them.
|
||||
if (error && typeof error === "object" && "errorFields" in error) {
|
||||
return;
|
||||
}
|
||||
console.error("Error regenerating key:", error);
|
||||
NotificationManager.fromBackend(error);
|
||||
setIsRegenerating(false); // Reset regenerating state on error
|
||||
}
|
||||
};
|
||||
|
||||
@ -193,16 +171,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 4 }}
|
||||
onValuesChange={(changedValues) => {
|
||||
if ("duration" in changedValues) {
|
||||
setRegenerateFormData((prev: { duration?: string }) => ({ ...prev, duration: changedValues.duration }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 4 }}>
|
||||
<Form.Item name="key_alias" label="Key Alias">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
@ -230,11 +199,12 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
|
||||
<Form.Item
|
||||
name="duration"
|
||||
label="Expire Key"
|
||||
rules={durationRules}
|
||||
extra={
|
||||
<Flex vertical gap={2}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Current expiry:{" "}
|
||||
{selectedToken?.expires ? new Date(selectedToken.expires).toLocaleString() : "Never"}
|
||||
<Text type={keyIsExpired ? "danger" : "secondary"} style={{ fontSize: 12 }}>
|
||||
Current expiry: {selectedToken?.expires ? formatExpiresUtc(selectedToken.expires) : "Never"}
|
||||
{keyIsExpired && " (expired)"}
|
||||
</Text>
|
||||
{newExpiryTime && (
|
||||
<Text type="success" style={{ fontSize: 12 }}>
|
||||
@ -257,12 +227,7 @@ export function RegenerateKeyModal({ selectedToken, visible, onClose, onKeyUpdat
|
||||
Recommended: 24h to 72h for production keys
|
||||
</Text>
|
||||
}
|
||||
rules={[
|
||||
{
|
||||
pattern: /^(\d+(s|m|h|d|w|mo))?$/,
|
||||
message: "Must be a duration like 30s, 30m, 24h, 2d, 1w, or 1mo",
|
||||
},
|
||||
]}
|
||||
rules={[DURATION_RULE]}
|
||||
>
|
||||
<Input placeholder="e.g. 24h, 2d" />
|
||||
</Form.Item>
|
||||
|
||||
45
ui/litellm-dashboard/src/utils/keyExpiryUtils.test.ts
Normal file
45
ui/litellm-dashboard/src/utils/keyExpiryUtils.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { calculateExpiryPreviewFromDuration, formatExpiresUtc, isKeyExpired, parseExpiresUtc } from "./keyExpiryUtils";
|
||||
|
||||
describe("keyExpiryUtils", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("treats naive ISO strings as UTC", () => {
|
||||
const ms = parseExpiresUtc("2020-01-01T12:00:00");
|
||||
expect(ms).toBe(Date.parse("2020-01-01T12:00:00Z"));
|
||||
});
|
||||
|
||||
it("parses Z-suffixed ISO strings unchanged", () => {
|
||||
const iso = "2020-01-01T12:00:00.000Z";
|
||||
expect(parseExpiresUtc(iso)).toBe(Date.parse(iso));
|
||||
});
|
||||
|
||||
it("formats naive ISO strings as UTC before localizing", () => {
|
||||
const naive = "2026-06-05T10:00:00";
|
||||
expect(formatExpiresUtc(naive)).toBe(new Date(Date.parse(`${naive}Z`)).toLocaleString());
|
||||
});
|
||||
|
||||
it("returns false when expires is missing", () => {
|
||||
expect(isKeyExpired(undefined)).toBe(false);
|
||||
expect(isKeyExpired(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects expired keys using UTC comparison", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-06-06T12:00:00Z"));
|
||||
expect(isKeyExpired("2026-06-05T12:00:00Z")).toBe(true);
|
||||
expect(isKeyExpired("2026-06-06T12:00:00")).toBe(false);
|
||||
expect(isKeyExpired("2026-06-07T12:00:00Z")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a preview for supported duration strings", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-06-06T12:00:00Z"));
|
||||
expect(calculateExpiryPreviewFromDuration("30d")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns null for unparseable duration strings", () => {
|
||||
expect(calculateExpiryPreviewFromDuration("bogus")).toBeNull();
|
||||
expect(calculateExpiryPreviewFromDuration(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
75
ui/litellm-dashboard/src/utils/keyExpiryUtils.ts
Normal file
75
ui/litellm-dashboard/src/utils/keyExpiryUtils.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { add } from "date-fns";
|
||||
|
||||
const HAS_TIMEZONE_SUFFIX = /[zZ]$|[+-]\d{2}:?\d{2}$/;
|
||||
|
||||
/**
|
||||
* Parse a key expires timestamp as UTC, matching proxy auth behavior for naive DB values.
|
||||
*/
|
||||
export function parseExpiresUtc(expires: string): number {
|
||||
const normalized = HAS_TIMEZONE_SUFFIX.test(expires) ? expires : `${expires}Z`;
|
||||
return Date.parse(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an expires timestamp for display, treating naive DB values as UTC.
|
||||
*/
|
||||
export function formatExpiresUtc(expires: string): string {
|
||||
const expiryMs = parseExpiresUtc(expires);
|
||||
if (Number.isNaN(expiryMs)) {
|
||||
return expires;
|
||||
}
|
||||
|
||||
return new Date(expiryMs).toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the key has an expires value in the past (UTC).
|
||||
*/
|
||||
export function isKeyExpired(expires?: string | null): boolean {
|
||||
if (!expires) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiryMs = parseExpiresUtc(expires);
|
||||
return !Number.isNaN(expiryMs) && expiryMs < Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a human-readable preview of the new expiry from a duration string.
|
||||
* Returns null when the duration cannot be parsed.
|
||||
*/
|
||||
export function calculateExpiryPreviewFromDuration(duration: string | undefined): string | null {
|
||||
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("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: amount });
|
||||
} else if (duration.endsWith("d")) {
|
||||
newExpiry = add(now, { days: amount });
|
||||
} else if (duration.endsWith("w")) {
|
||||
newExpiry = add(now, { weeks: amount });
|
||||
} else {
|
||||
throw new Error("Invalid duration format");
|
||||
}
|
||||
|
||||
return newExpiry.toLocaleString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user