Skip to content

Commit ef6ad5b

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(webapp): add custom date api key expiry
Improve the API key expiry picker with presets, a calendar and date previews in one control. Keys expire at the end of the selected day in UTC. Mono-RevId: f1c2ca855c6d441b3f320cf96f59ea79c9d4cb08
1 parent 63b58f3 commit ef6ad5b

5 files changed

Lines changed: 237 additions & 111 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Choose an API key's expiry from presets or a calendar in one picker, with date previews and expiration at the end of the chosen day in UTC.
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
2+
import { addDays, format } from "date-fns";
3+
import { useState } from "react";
4+
import { ButtonContent } from "~/components/primitives/Buttons";
5+
import { Calendar } from "~/components/primitives/Calendar";
6+
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
7+
import { apiKeyExpiryDay, isApiKeyExpiryDayExpired } from "~/utils/apiKeyExpiry";
8+
import { cn } from "~/utils/cn";
9+
10+
const EXPIRATION_PRESETS = [
11+
{ value: "30-days", label: "In 30 days", days: 30 },
12+
{ value: "90-days", label: "In 90 days", days: 90 },
13+
{ value: "1-year", label: "In 1 year", days: 365 },
14+
];
15+
16+
export function ApiKeyExpiryPicker({
17+
expiration,
18+
date,
19+
onChange,
20+
}: {
21+
expiration: string;
22+
date: Date | undefined;
23+
onChange: (expiration: string, date: Date | undefined) => void;
24+
}) {
25+
const [open, setOpen] = useState(false);
26+
const [today, setToday] = useState(() => apiKeyExpiryDay());
27+
const [month, setMonth] = useState(date ?? today);
28+
const label =
29+
expiration === "never"
30+
? "Never"
31+
: (EXPIRATION_PRESETS.find((preset) => preset.value === expiration)?.label ?? "Custom date");
32+
33+
return (
34+
<Popover
35+
open={open}
36+
onOpenChange={(nextOpen) => {
37+
if (nextOpen) {
38+
const currentDay = apiKeyExpiryDay();
39+
setToday(currentDay);
40+
setMonth(date && date >= currentDay ? date : currentDay);
41+
}
42+
setOpen(nextOpen);
43+
}}
44+
>
45+
<PopoverTrigger asChild>
46+
<button
47+
id="api-key-expiry"
48+
type="button"
49+
className="group/button w-full cursor-pointer outline-hidden focus-custom"
50+
>
51+
<ButtonContent
52+
variant="secondary/medium"
53+
TrailingIcon={ChevronUpDownIcon}
54+
fullWidth
55+
textAlignLeft
56+
>
57+
<span className="flex w-full items-center justify-between gap-3">
58+
<span>{label}</span>
59+
{date ? (
60+
<span className="text-xs text-text-faint">{format(date, "d MMM yyyy")}</span>
61+
) : null}
62+
</span>
63+
</ButtonContent>
64+
</button>
65+
</PopoverTrigger>
66+
<PopoverContent className="w-auto min-w-0 overflow-y-auto p-0" align="end">
67+
<div className="flex flex-col sm:flex-row">
68+
<div className="space-y-1 border-b border-grid-bright p-2 sm:min-w-56 sm:border-b-0 sm:border-r">
69+
{EXPIRATION_PRESETS.map((preset) => {
70+
const presetDate = addDays(today, preset.days);
71+
return (
72+
<button
73+
key={preset.value}
74+
type="button"
75+
aria-pressed={expiration === preset.value}
76+
className={cn(
77+
"flex w-full cursor-pointer items-center justify-between gap-4 rounded-sm px-3 py-2 text-left text-sm text-text-bright hover:bg-tertiary focus-custom",
78+
expiration === preset.value && "bg-tertiary"
79+
)}
80+
onClick={() => {
81+
const currentDay = apiKeyExpiryDay();
82+
const selectedDate = addDays(currentDay, preset.days);
83+
setToday(currentDay);
84+
onChange(preset.value, selectedDate);
85+
setMonth(selectedDate);
86+
}}
87+
>
88+
<span>{preset.label}</span>
89+
<span className="text-xs text-text-faint">
90+
{format(presetDate, "d MMM yyyy")}
91+
</span>
92+
</button>
93+
);
94+
})}
95+
<div className="mt-2 border-t border-grid-bright pt-2">
96+
<button
97+
type="button"
98+
aria-pressed={expiration === "never"}
99+
className={cn(
100+
"w-full cursor-pointer rounded-sm px-3 py-2 text-left text-sm text-text-bright hover:bg-tertiary focus-custom",
101+
expiration === "never" && "bg-tertiary"
102+
)}
103+
onClick={() => {
104+
onChange("never", undefined);
105+
setOpen(false);
106+
}}
107+
>
108+
Never
109+
</button>
110+
</div>
111+
</div>
112+
<Calendar
113+
mode="single"
114+
required
115+
selected={date}
116+
today={today}
117+
month={month}
118+
onMonthChange={setMonth}
119+
disabled={(day) => isApiKeyExpiryDayExpired(day)}
120+
onSelect={(selectedDate) => {
121+
if (isApiKeyExpiryDayExpired(selectedDate)) {
122+
setToday(apiKeyExpiryDay());
123+
return;
124+
}
125+
onChange("custom", selectedDate);
126+
setOpen(false);
127+
}}
128+
/>
129+
</div>
130+
</PopoverContent>
131+
</Popover>
132+
);
133+
}

‎apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/NewApiKeyDialog.tsx‎

Lines changed: 39 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import { CalendarDaysIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
21
import { DialogClose } from "@radix-ui/react-dialog";
3-
import { endOfDay, format, startOfDay } from "date-fns";
42
import { useEffect, useState } from "react";
53
import { useTypedFetcher } from "remix-typedjson";
64
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
75
import { InlineCode } from "~/components/code/InlineCode";
86
import { Feedback } from "~/components/Feedback";
97
import { Button, LinkButton } from "~/components/primitives/Buttons";
10-
import { Calendar } from "~/components/primitives/Calendar";
118
import { Callout } from "~/components/primitives/Callout";
129
import { ClipboardField } from "~/components/primitives/ClipboardField";
1310
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
@@ -17,7 +14,6 @@ import { Input } from "~/components/primitives/Input";
1714
import { InputGroup } from "~/components/primitives/InputGroup";
1815
import { Label } from "~/components/primitives/Label";
1916
import { Paragraph } from "~/components/primitives/Paragraph";
20-
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
2117
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
2218
import { Select, SelectItem } from "~/components/primitives/Select";
2319
import SegmentedControl from "~/components/primitives/SegmentedControl";
@@ -27,9 +23,16 @@ import { useOrganization } from "~/hooks/useOrganizations";
2723
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
2824
import type { ApiKeyPreset } from "~/services/apiKeyPresetValidation.server";
2925
import { SCOPE_CAPABILITIES, scopeDetailForPreset } from "~/utils/apiKeyScopeDetail";
26+
import {
27+
apiKeyExpiryDay,
28+
apiKeyExpiryTimestamp,
29+
DEFAULT_API_KEY_EXPIRY_DAYS,
30+
isApiKeyExpiryDayExpired,
31+
} from "~/utils/apiKeyExpiry";
3032
import { cn } from "~/utils/cn";
3133
import { v3BillingPath } from "~/utils/pathBuilder";
3234
import type { action, ApiKeyActionData } from "./route";
35+
import { ApiKeyExpiryPicker } from "./ApiKeyExpiryPicker";
3336

3437
type NewApiKeyDialogProps = {
3538
availableTasks: string[];
@@ -55,15 +58,17 @@ export function NewApiKeyDialog({
5558
const [showError, setShowError] = useState(false);
5659
const [name, setName] = useState("");
5760
const [expiration, setExpiration] = useState("90-days");
58-
const [customExpiryDate, setCustomExpiryDate] = useState<Date>();
61+
const [expiryDate, setExpiryDate] = useState<Date | undefined>(() =>
62+
apiKeyExpiryDay(new Date(), DEFAULT_API_KEY_EXPIRY_DAYS)
63+
);
64+
const [expiryError, setExpiryError] = useState(false);
5965
const defaultPresetId =
6066
presets?.find((preset) => preset.id === "FULL_ACCESS" && preset.available)?.id ??
6167
presets?.find((preset) => preset.available)?.id ??
6268
"FULL_ACCESS";
6369
const additionalPresetIds =
6470
presets?.filter((preset) => !KNOWN_PRESET_IDS.has(preset.id)).map((preset) => preset.id) ?? [];
65-
const expiresAt = expirationDate(expiration, customExpiryDate);
66-
const needsCustomExpiryDate = expiration === "custom" && !customExpiryDate;
71+
const expiresAt = expiryDate ? apiKeyExpiryTimestamp(expiryDate) : undefined;
6772
const [presetId, setPresetId] = useState(defaultPresetId);
6873
const [taskScope, setTaskScope] = useState<"all" | "selected">("all");
6974
const [selectedTasks, setSelectedTasks] = useState<string[]>([]);
@@ -87,7 +92,8 @@ export function NewApiKeyDialog({
8792
if (nextOpen) {
8893
setName("");
8994
setExpiration("90-days");
90-
setCustomExpiryDate(undefined);
95+
setExpiryDate(apiKeyExpiryDay(new Date(), DEFAULT_API_KEY_EXPIRY_DAYS));
96+
setExpiryError(false);
9197
setPresetId(defaultPresetId);
9298
setTaskScope("all");
9399
setSelectedTasks([]);
@@ -145,7 +151,12 @@ export function NewApiKeyDialog({
145151
) : (
146152
<fetcher.Form
147153
method="post"
148-
onSubmit={() => setShowError(false)}
154+
onSubmit={(event) => {
155+
setShowError(false);
156+
const expired = expiryDate !== undefined && isApiKeyExpiryDayExpired(expiryDate);
157+
setExpiryError(expired);
158+
if (expired) event.preventDefault();
159+
}}
149160
className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]"
150161
>
151162
<input type="hidden" name="action" value="create" />
@@ -173,7 +184,7 @@ export function NewApiKeyDialog({
173184
<div className={cn("min-w-0 px-5 pb-6 pt-4", showAccessControls && "space-y-5")}>
174185
<div
175186
className={cn(
176-
"grid gap-4 pb-5 sm:grid-cols-[minmax(0,1fr)_12rem]",
187+
"grid items-start gap-4 pb-5 sm:grid-cols-[minmax(0,1fr)_16rem]",
177188
showAccessControls && "border-b border-grid-dimmed"
178189
)}
179190
>
@@ -193,36 +204,20 @@ export function NewApiKeyDialog({
193204
</InputGroup>
194205

195206
<InputGroup fullWidth>
196-
<Label>Expires</Label>
197-
<Select<string, { value: string; label: string }>
198-
value={expiration}
199-
setValue={setExpiration}
200-
items={API_KEY_EXPIRATIONS}
201-
variant="secondary/medium"
202-
dropdownIcon
203-
className="w-full justify-between"
204-
text={(value) =>
205-
API_KEY_EXPIRATIONS.find((option) => option.value === value)?.label
206-
}
207-
>
208-
{(options) =>
209-
options.map((option) => (
210-
<SelectItem key={option.value} value={option.value}>
211-
{option.label}
212-
</SelectItem>
213-
))
214-
}
215-
</Select>
216-
{expiration === "custom" ? (
217-
<CustomExpiryDatePicker
218-
value={customExpiryDate}
219-
onChange={setCustomExpiryDate}
220-
/>
221-
) : null}
207+
<Label htmlFor="api-key-expiry">Expires</Label>
208+
<ApiKeyExpiryPicker
209+
expiration={expiration}
210+
date={expiryDate}
211+
onChange={(nextExpiration, date) => {
212+
setExpiration(nextExpiration);
213+
setExpiryDate(date);
214+
setExpiryError(false);
215+
}}
216+
/>
222217
<Hint>
223-
{needsCustomExpiryDate
224-
? "Pick the last day this key works"
225-
: formatExpiryHint(expiresAt, expiration === "custom")}
218+
{expiration === "never"
219+
? "Works until you revoke it."
220+
: "Expires at the end of the chosen day, UTC."}
226221
</Hint>
227222
</InputGroup>
228223
</div>
@@ -293,7 +288,11 @@ export function NewApiKeyDialog({
293288

294289
<div className="flex items-center gap-3 border-t border-grid-bright px-5 py-3">
295290
{(() => {
296-
const error = showError && actionData && !actionData.ok ? actionData.error : null;
291+
const error = expiryError
292+
? "This expiry date has passed. Choose another date."
293+
: showError && actionData && !actionData.ok
294+
? actionData.error
295+
: null;
297296
const hint =
298297
needsSelectedTask && selectedTasks.length === 0
299298
? "Pick at least one task, or switch to all tasks."
@@ -318,7 +317,6 @@ export function NewApiKeyDialog({
318317
variant="primary/small"
319318
disabled={
320319
!name.trim() ||
321-
needsCustomExpiryDate ||
322320
!selectedPresetIsAvailable ||
323321
(needsSelectedTask &&
324322
(selectedTasks.length === 0 ||
@@ -350,14 +348,6 @@ const KNOWN_PRESET_IDS = new Set([
350348
"WAITPOINTS_ONLY",
351349
]);
352350

353-
const API_KEY_EXPIRATIONS = [
354-
{ value: "30-days", label: "In 30 days" },
355-
{ value: "90-days", label: "In 90 days" },
356-
{ value: "1-year", label: "In 1 year" },
357-
{ value: "custom", label: "Custom date" },
358-
{ value: "never", label: "Never" },
359-
];
360-
361351
// 0 none · 1 read · 2 read & write · 3 allowed (an action) · 4 full
362352
const SCOPE_LEVEL_WORDS = ["No access", "Read", "Read & write", "Allowed", "Full access"] as const;
363353
const SCOPE_LEVEL_TONES = ["none", "read", "write", "write", "write"] as const;
@@ -377,68 +367,6 @@ function expandScopeString(raw: string, scoped: boolean, tasks: string[]): strin
377367
return shown;
378368
}
379369

380-
function formatExpiryHint(expiresAt: Date | undefined, endOfDay: boolean): string {
381-
if (!expiresAt) {
382-
return "Works until you revoke it";
383-
}
384-
const date = new Intl.DateTimeFormat("en-GB", {
385-
day: "numeric",
386-
month: "short",
387-
year: "numeric",
388-
}).format(expiresAt);
389-
return endOfDay ? `Until the end of ${date}` : date;
390-
}
391-
392-
function expirationDate(expiration: string, customDate: Date | undefined): Date | undefined {
393-
if (expiration === "custom") {
394-
return customDate ? endOfDay(customDate) : undefined;
395-
}
396-
const days = { "30-days": 30, "90-days": 90, "1-year": 365 }[expiration];
397-
return days ? new Date(Date.now() + days * 24 * 60 * 60 * 1_000) : undefined;
398-
}
399-
400-
function CustomExpiryDatePicker({
401-
value,
402-
onChange,
403-
}: {
404-
value: Date | undefined;
405-
onChange: (date: Date | undefined) => void;
406-
}) {
407-
const [open, setOpen] = useState(false);
408-
409-
return (
410-
<Popover open={open} onOpenChange={setOpen}>
411-
<PopoverTrigger asChild>
412-
<Button
413-
type="button"
414-
variant="secondary/medium"
415-
LeadingIcon={CalendarDaysIcon}
416-
TrailingIcon={ChevronUpDownIcon}
417-
fullWidth
418-
textAlignLeft
419-
aria-label="Expiration date"
420-
className={cn(!value && "text-text-dimmed")}
421-
>
422-
{value ? format(value, "d MMM yyyy") : "Select date"}
423-
</Button>
424-
</PopoverTrigger>
425-
<PopoverContent className="w-auto p-0" align="end">
426-
<Calendar
427-
mode="single"
428-
required
429-
selected={value}
430-
defaultMonth={value}
431-
disabled={{ before: startOfDay(new Date()) }}
432-
onSelect={(date) => {
433-
onChange(date);
434-
setOpen(false);
435-
}}
436-
/>
437-
</PopoverContent>
438-
</Popover>
439-
);
440-
}
441-
442370
function PresetGroup({
443371
title,
444372
presets,

0 commit comments

Comments
 (0)