Skip to content

Commit f999236

Browse files
authored
feat(user): let users pick a custom expiration date for an API key (#4315)
* feat(user): let users pick a custom expiration date for an API key The create dialog adds a "Custom date" option next to the 30 day, 90 day and 1 year presets. It defaults to tomorrow and opens a calendar limited to tomorrow through one year out. The key expires on the picked day at the current time of day, so tomorrow means exactly 24 hours. Refs CON-1207 * fix(user): keep a custom API key expiry valid when the dialog stays open past midnight A custom expiry day picked before midnight became today after it, so the key expired at the moment of creation and the API rejected every retry. The lifetime is now at least one day. Refs CON-1207
1 parent 24a28a8 commit f999236

6 files changed

Lines changed: 206 additions & 18 deletions

File tree

‎apps/deploy-web/src/components/api-keys/ApiKeyList/ApiKeyList.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@ import type { LucideIcon } from "lucide-react";
77
import { KeyRoundIcon, MoreHorizontalIcon, ShieldAlertIcon, Trash2Icon, TriangleAlertIcon } from "lucide-react";
88

99
import type { ApiKeyExpiryStatus } from "@src/components/api-keys/apiKeyExpiry/apiKeyExpiry";
10-
import { getApiKeyExpiryStatus } from "@src/components/api-keys/apiKeyExpiry/apiKeyExpiry";
10+
import { API_KEY_DATE_FORMAT, getApiKeyExpiryStatus } from "@src/components/api-keys/apiKeyExpiry/apiKeyExpiry";
1111
import { SettingsSection } from "@src/components/layout/SettingsSection/SettingsSection";
1212

13-
const API_KEY_DATE_FORMAT = "MMM d, yyyy";
14-
1513
const EXPIRY_BADGE_LABELS: Partial<Record<ApiKeyExpiryStatus, string>> = {
1614
expiringSoon: "Expiring soon",
1715
expired: "Expired"

‎apps/deploy-web/src/components/api-keys/CreateApiKeyDialog/CreateApiKeyDialog.spec.tsx‎

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ApiKeyResponse } from "@akashnetwork/http-sdk";
22
import { differenceInCalendarDays } from "date-fns";
3-
import { describe, expect, it, vi } from "vitest";
3+
import { describe, expect, it, onTestFinished, vi } from "vitest";
44
import { mock } from "vitest-mock-extended";
55

66
import type { NewApiKey } from "@src/queries/useApiKeysQuery";
@@ -9,6 +9,7 @@ import type { DEPENDENCIES } from "./CreateApiKeyDialog";
99
import { CreateApiKeyDialog } from "./CreateApiKeyDialog";
1010

1111
import { render, screen } from "@testing-library/react";
12+
import type { UserEvent } from "@testing-library/user-event";
1213
import userEvent from "@testing-library/user-event";
1314
import { buildApiKey } from "@tests/seeders";
1415
import { TestContainerProvider } from "@tests/unit/TestContainerProvider";
@@ -37,13 +38,83 @@ describe("CreateApiKeyDialog", () => {
3738
expect(differenceInCalendarDays(newApiKey.expiresAt, new Date())).toBe(30);
3839
});
3940

40-
it("offers expiries of at most one year", async () => {
41+
it("offers preset expiries of at most one year and a custom date", async () => {
4142
const { user } = setup();
4243

4344
await user.click(screen.getByRole("combobox", { name: /Expiration/ }));
4445

4546
const options = await screen.findAllByRole("option");
46-
expect(options.map(option => option.textContent)).toEqual(["30 days", "90 days", "1 year"]);
47+
expect(options.map(option => option.textContent)).toEqual(["30 days", "90 days", "1 year", "Custom date"]);
48+
});
49+
50+
it("shows the date picker only for a custom expiry", async () => {
51+
const { user } = setup();
52+
53+
expect(screen.queryByLabelText("Expiration date")).not.toBeInTheDocument();
54+
55+
await pickCustomExpiry(user);
56+
57+
expect(screen.getByLabelText("Expiration date")).toBeInTheDocument();
58+
});
59+
60+
it("creates a key that expires tomorrow when the custom date is left as is", async () => {
61+
const { user, createApiKey } = setup({ now: new Date(2026, 9, 9, 12) });
62+
63+
await user.type(screen.getByLabelText("Name"), "One day");
64+
await pickCustomExpiry(user);
65+
66+
expect(screen.getByLabelText("Expiration date")).toHaveTextContent("Oct 10, 2026");
67+
68+
await user.click(screen.getByRole("button", { name: "Create key" }));
69+
70+
const [newApiKey] = createApiKey.mock.lastCall as [NewApiKey];
71+
expect(newApiKey.expiresAt).toEqual(new Date(2026, 9, 10, 12));
72+
});
73+
74+
it("still creates a key one day out when the dialog stays open past midnight", async () => {
75+
const { user, createApiKey } = setup({ now: new Date(2026, 9, 9, 23, 50) });
76+
77+
await user.type(screen.getByLabelText("Name"), "Overnight");
78+
await pickCustomExpiry(user);
79+
vi.setSystemTime(new Date(2026, 9, 10, 0, 5));
80+
await user.click(screen.getByRole("button", { name: "Create key" }));
81+
82+
const [newApiKey] = createApiKey.mock.lastCall as [NewApiKey];
83+
expect(newApiKey.expiresAt).toEqual(new Date(2026, 9, 11, 0, 5));
84+
});
85+
86+
it("creates a key that expires on the custom date the user picks", async () => {
87+
const { user, createApiKey } = setup({ now: new Date(2026, 9, 9, 12) });
88+
89+
await user.type(screen.getByLabelText("Name"), "One week");
90+
await pickCustomExpiry(user);
91+
await user.click(screen.getByLabelText("Expiration date"));
92+
await user.click(await screen.findByRole("button", { name: /October 16th, 2026/ }));
93+
94+
expect(screen.queryByRole("grid")).not.toBeInTheDocument();
95+
expect(screen.getByLabelText("Expiration date")).toHaveTextContent("Oct 16, 2026");
96+
97+
await user.click(screen.getByRole("button", { name: "Create key" }));
98+
99+
const [newApiKey] = createApiKey.mock.lastCall as [NewApiKey];
100+
expect(newApiKey.expiresAt).toEqual(new Date(2026, 9, 16, 12));
101+
});
102+
103+
it("limits the custom date to between tomorrow and one year out", async () => {
104+
const { user } = setup({ now: new Date(2026, 9, 9, 12) });
105+
106+
await pickCustomExpiry(user);
107+
await user.click(screen.getByLabelText("Expiration date"));
108+
109+
expect(await screen.findByRole("button", { name: /October 9th, 2026/ })).toBeDisabled();
110+
expect(screen.getByRole("button", { name: /October 10th, 2026/ })).toBeEnabled();
111+
112+
for (let month = 0; month < 12; month++) {
113+
await user.click(screen.getByRole("button", { name: "Go to the Next Month" }));
114+
}
115+
116+
expect(screen.getByRole("button", { name: /October 9th, 2027/ })).toBeEnabled();
117+
expect(screen.getByRole("button", { name: /October 10th, 2027/ })).toBeDisabled();
47118
});
48119

49120
it("tracks the key creation", async () => {
@@ -206,7 +277,19 @@ describe("CreateApiKeyDialog", () => {
206277
});
207278
});
208279

209-
function setup(input: { createdApiKey?: ApiKeyResponse; isPending?: boolean } = {}) {
280+
async function pickCustomExpiry(user: UserEvent) {
281+
await user.click(screen.getByRole("combobox", { name: /Expiration/ }));
282+
await user.click(await screen.findByRole("option", { name: "Custom date" }));
283+
}
284+
285+
function setup(input: { createdApiKey?: ApiKeyResponse; isPending?: boolean; now?: Date } = {}) {
286+
if (input.now) {
287+
vi.useFakeTimers({ toFake: ["Date"] });
288+
vi.setSystemTime(input.now);
289+
onTestFinished(() => {
290+
vi.useRealTimers();
291+
});
292+
}
210293
const user = userEvent.setup();
211294
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
212295
writeText.mockClear();

‎apps/deploy-web/src/components/api-keys/CreateApiKeyDialog/CreateApiKeyDialog.tsx‎

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"use client";
22
import type { FC } from "react";
3-
import { useId } from "react";
3+
import { useId, useState } from "react";
44
import { useForm } from "react-hook-form";
55
import {
66
Button,
7+
Calendar,
78
DialogV2,
89
DialogV2Body,
910
DialogV2Content,
@@ -18,6 +19,9 @@ import {
1819
FormItem,
1920
FormLabel,
2021
LoadingButton,
22+
Popover,
23+
PopoverContent,
24+
PopoverTrigger,
2125
Select,
2226
SelectContent,
2327
SelectItem,
@@ -27,12 +31,20 @@ import {
2731
} from "@akashnetwork/ui/components";
2832
import { copyTextToClipboard } from "@akashnetwork/ui/utils";
2933
import { zodResolver } from "@hookform/resolvers/zod";
34+
import { format } from "date-fns";
3035
import { CalendarIcon, CopyIcon, KeyRoundIcon, TriangleAlertIcon } from "lucide-react";
3136
import { useSnackbar } from "notistack";
3237
import { z } from "zod";
3338

3439
import type { ApiKeyLifetimeDays } from "@src/components/api-keys/apiKeyExpiry/apiKeyExpiry";
35-
import { API_KEY_LIFETIMES, DEFAULT_API_KEY_LIFETIME_DAYS, getApiKeyExpiryDate } from "@src/components/api-keys/apiKeyExpiry/apiKeyExpiry";
40+
import {
41+
API_KEY_DATE_FORMAT,
42+
API_KEY_LIFETIMES,
43+
DEFAULT_API_KEY_LIFETIME_DAYS,
44+
getApiKeyExpiryDate,
45+
getCustomApiKeyExpiryDate,
46+
getCustomApiKeyExpiryDayRange
47+
} from "@src/components/api-keys/apiKeyExpiry/apiKeyExpiry";
3648
import { useServices } from "@src/context/ServicesProvider";
3749
import { useCreateApiKey } from "@src/queries/useApiKeysQuery";
3850

@@ -43,13 +55,16 @@ export const DEPENDENCIES = {
4355

4456
export const MAX_API_KEY_NAME_LENGTH = 40;
4557

58+
const CUSTOM_LIFETIME = "custom";
59+
4660
const newApiKeySchema = z.object({
4761
name: z
4862
.string()
4963
.trim()
5064
.min(1, { message: "Name is required." })
5165
.max(MAX_API_KEY_NAME_LENGTH, { message: `Name must be ${MAX_API_KEY_NAME_LENGTH} characters or fewer.` }),
52-
lifetimeDays: z.custom<ApiKeyLifetimeDays>()
66+
lifetime: z.custom<ApiKeyLifetimeDays | typeof CUSTOM_LIFETIME>(),
67+
customExpiryDay: z.date()
5368
});
5469

5570
type NewApiKeyValues = z.infer<typeof newApiKeySchema>;
@@ -65,14 +80,17 @@ export const CreateApiKeyDialog: FC<Props> = ({ onClose, dependencies: d = DEPEN
6580
const { mutate: createApiKey, data: createdApiKey, isPending } = d.useCreateApiKey();
6681
const isDismissible = !isPending && !createdApiKey?.apiKey;
6782

68-
const createKey = ({ name, lifetimeDays }: NewApiKeyValues) => {
83+
const createKey = ({ name, lifetime, customExpiryDay }: NewApiKeyValues) => {
6984
analyticsService.track("create_api_key", {
7085
category: "settings",
7186
label: "Create API key"
7287
});
7388

89+
const now = new Date();
90+
const expiresAt = lifetime === CUSTOM_LIFETIME ? getCustomApiKeyExpiryDate(customExpiryDay, now) : getApiKeyExpiryDate(lifetime, now);
91+
7492
createApiKey(
75-
{ name, expiresAt: getApiKeyExpiryDate(lifetimeDays, new Date()) },
93+
{ name, expiresAt },
7694
{
7795
onError: () => {
7896
enqueueSnackbar(<Snackbar title="Couldn't create the API key" subTitle="Try again in a moment." iconVariant="error" />, { variant: "error" });
@@ -113,9 +131,14 @@ type NewApiKeyStepProps = {
113131
const NewApiKeyStep: FC<NewApiKeyStepProps> = ({ isCreating, onCreate, onCancel }) => {
114132
const formId = useId();
115133
const form = useForm<NewApiKeyValues>({
116-
defaultValues: { name: "", lifetimeDays: DEFAULT_API_KEY_LIFETIME_DAYS },
134+
defaultValues: {
135+
name: "",
136+
lifetime: DEFAULT_API_KEY_LIFETIME_DAYS,
137+
customExpiryDay: getCustomApiKeyExpiryDayRange(new Date()).earliest
138+
},
117139
resolver: zodResolver(newApiKeySchema)
118140
});
141+
const isCustomLifetime = form.watch("lifetime") === CUSTOM_LIFETIME;
119142

120143
return (
121144
<>
@@ -135,11 +158,11 @@ const NewApiKeyStep: FC<NewApiKeyStepProps> = ({ isCreating, onCreate, onCancel
135158

136159
<FormField
137160
control={form.control}
138-
name="lifetimeDays"
161+
name="lifetime"
139162
render={({ field }) => (
140163
<FormItem>
141164
<FormLabel>Expiration</FormLabel>
142-
<Select value={String(field.value)} onValueChange={value => field.onChange(Number(value))}>
165+
<Select value={String(field.value)} onValueChange={value => field.onChange(value === CUSTOM_LIFETIME ? value : Number(value))}>
143166
<FormControl>
144167
<SelectTrigger>
145168
<div className="flex items-center gap-2">
@@ -154,12 +177,26 @@ const NewApiKeyStep: FC<NewApiKeyStepProps> = ({ isCreating, onCreate, onCancel
154177
{lifetime.label}
155178
</SelectItem>
156179
))}
180+
<SelectItem value={CUSTOM_LIFETIME}>Custom date</SelectItem>
157181
</SelectContent>
158182
</Select>
159183
</FormItem>
160184
)}
161185
/>
162186

187+
{isCustomLifetime && (
188+
<FormField
189+
control={form.control}
190+
name="customExpiryDay"
191+
render={({ field }) => (
192+
<FormItem>
193+
<FormLabel className="sr-only">Expiration date</FormLabel>
194+
<CustomExpiryDayPicker value={field.value} onChange={field.onChange} />
195+
</FormItem>
196+
)}
197+
/>
198+
)}
199+
163200
<div className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/5 px-3 py-2.5 text-[12.5px] leading-[18px] text-muted-foreground">
164201
<TriangleAlertIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" aria-hidden />
165202
<span>
@@ -184,6 +221,37 @@ const NewApiKeyStep: FC<NewApiKeyStepProps> = ({ isCreating, onCreate, onCancel
184221
);
185222
};
186223

224+
type CustomExpiryDayPickerProps = {
225+
value: Date;
226+
onChange: (day: Date) => void;
227+
};
228+
229+
const CustomExpiryDayPicker: FC<CustomExpiryDayPickerProps> = ({ value, onChange }) => {
230+
const [isOpen, setIsOpen] = useState(false);
231+
const { earliest, latest } = getCustomApiKeyExpiryDayRange(new Date());
232+
233+
const selectDay = (day: Date) => {
234+
onChange(day);
235+
setIsOpen(false);
236+
};
237+
238+
return (
239+
<Popover open={isOpen} onOpenChange={setIsOpen}>
240+
<PopoverTrigger asChild>
241+
<FormControl>
242+
<Button type="button" variant="outline" className="w-full justify-start gap-2 bg-popover px-3 font-normal dark:bg-popover">
243+
<CalendarIcon className="h-4 w-4 text-muted-foreground" aria-hidden />
244+
{format(value, API_KEY_DATE_FORMAT)}
245+
</Button>
246+
</FormControl>
247+
</PopoverTrigger>
248+
<PopoverContent align="start" className="w-auto p-0">
249+
<Calendar mode="single" required selected={value} onSelect={selectDay} defaultMonth={value} disabled={[{ before: earliest }, { after: latest }]} />
250+
</PopoverContent>
251+
</Popover>
252+
);
253+
};
254+
187255
type ApiKeySecretStepProps = {
188256
name: string;
189257
secret: string;

‎apps/deploy-web/src/components/api-keys/apiKeyExpiry/apiKeyExpiry.spec.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { getApiKeyExpiryDate, getApiKeyExpiryStatus } from "./apiKeyExpiry";
3+
import { getApiKeyExpiryDate, getApiKeyExpiryStatus, getCustomApiKeyExpiryDate, getCustomApiKeyExpiryDayRange } from "./apiKeyExpiry";
44

55
const NOW = new Date("2026-10-02T12:00:00.000Z");
66

@@ -14,6 +14,29 @@ describe("apiKeyExpiry", () => {
1414
});
1515
});
1616

17+
describe(getCustomApiKeyExpiryDate.name, () => {
18+
it("expires on the picked day at the current time of day", () => {
19+
const now = new Date(2026, 9, 9, 15, 30);
20+
21+
expect(getCustomApiKeyExpiryDate(new Date(2026, 9, 10), now)).toEqual(new Date(2026, 9, 10, 15, 30));
22+
expect(getCustomApiKeyExpiryDate(new Date(2026, 11, 25), now)).toEqual(new Date(2026, 11, 25, 15, 30));
23+
});
24+
25+
it("expires one day out when the picked day is no longer in the future", () => {
26+
const justAfterMidnight = new Date(2026, 9, 10, 0, 5);
27+
28+
expect(getCustomApiKeyExpiryDate(new Date(2026, 9, 10), justAfterMidnight)).toEqual(new Date(2026, 9, 11, 0, 5));
29+
});
30+
});
31+
32+
describe(getCustomApiKeyExpiryDayRange.name, () => {
33+
it("ranges from tomorrow to one year out", () => {
34+
const now = new Date(2026, 9, 9, 15, 30);
35+
36+
expect(getCustomApiKeyExpiryDayRange(now)).toEqual({ earliest: new Date(2026, 9, 10, 15, 30), latest: new Date(2027, 9, 9, 15, 30) });
37+
});
38+
});
39+
1740
describe(getApiKeyExpiryStatus.name, () => {
1841
it("returns never when the key has no expiry", () => {
1942
expect(getApiKeyExpiryStatus(null, NOW)).toBe("never");

‎apps/deploy-web/src/components/api-keys/apiKeyExpiry/apiKeyExpiry.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { addDays, differenceInCalendarDays, isAfter } from "date-fns";
22

3+
export const MIN_CUSTOM_API_KEY_LIFETIME_DAYS = 1;
4+
5+
export const MAX_API_KEY_LIFETIME_DAYS = 365;
6+
37
export const API_KEY_LIFETIMES = [
48
{ days: 30, label: "30 days" },
59
{ days: 90, label: "90 days" },
6-
{ days: 365, label: "1 year" }
10+
{ days: MAX_API_KEY_LIFETIME_DAYS, label: "1 year" }
711
] as const;
812

913
export type ApiKeyLifetimeDays = (typeof API_KEY_LIFETIMES)[number]["days"];
@@ -12,12 +16,23 @@ export const DEFAULT_API_KEY_LIFETIME_DAYS: ApiKeyLifetimeDays = 365;
1216

1317
export const EXPIRING_SOON_WITHIN_DAYS = 7;
1418

19+
export const API_KEY_DATE_FORMAT = "MMM d, yyyy";
20+
1521
export type ApiKeyExpiryStatus = "never" | "active" | "expiringSoon" | "expired";
1622

17-
export function getApiKeyExpiryDate(lifetimeDays: ApiKeyLifetimeDays, now: Date) {
23+
export function getApiKeyExpiryDate(lifetimeDays: number, now: Date) {
1824
return addDays(now, lifetimeDays);
1925
}
2026

27+
export function getCustomApiKeyExpiryDate(expiryDay: Date, now: Date) {
28+
const lifetimeDays = Math.max(differenceInCalendarDays(expiryDay, now), MIN_CUSTOM_API_KEY_LIFETIME_DAYS);
29+
return getApiKeyExpiryDate(lifetimeDays, now);
30+
}
31+
32+
export function getCustomApiKeyExpiryDayRange(now: Date) {
33+
return { earliest: addDays(now, MIN_CUSTOM_API_KEY_LIFETIME_DAYS), latest: addDays(now, MAX_API_KEY_LIFETIME_DAYS) };
34+
}
35+
2136
export function getApiKeyExpiryStatus(expiresAt: string | null, now: Date): ApiKeyExpiryStatus {
2237
if (!expiresAt) return "never";
2338

‎packages/ui/components/index.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export * from "./badge";
66
export * from "./banner/banner";
77
export * from "./brand-icons";
88
export * from "./breadcrumb";
9+
export * from "./calendar";
910
export * from "./card";
1011
export * from "./checkbox";
1112
export * from "./collapsible";

0 commit comments

Comments
 (0)