fix(ui): require new expiration when regenerating an expired key (#29838)

This commit is contained in:
milan-berri 2026-06-06 19:18:19 +03:00 committed by GitHub
parent 22186f457a
commit 1f171ee018
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 235 additions and 73 deletions

View File

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

View File

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

View 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();
});
});

View 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;
}
}