1- import { CalendarDaysIcon , ChevronUpDownIcon } from "@heroicons/react/20/solid" ;
21import { DialogClose } from "@radix-ui/react-dialog" ;
3- import { endOfDay , format , startOfDay } from "date-fns" ;
42import { useEffect , useState } from "react" ;
53import { useTypedFetcher } from "remix-typedjson" ;
64import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel" ;
75import { InlineCode } from "~/components/code/InlineCode" ;
86import { Feedback } from "~/components/Feedback" ;
97import { Button , LinkButton } from "~/components/primitives/Buttons" ;
10- import { Calendar } from "~/components/primitives/Calendar" ;
118import { Callout } from "~/components/primitives/Callout" ;
129import { ClipboardField } from "~/components/primitives/ClipboardField" ;
1310import { Dialog , DialogContent , DialogHeader } from "~/components/primitives/Dialog" ;
@@ -17,7 +14,6 @@ import { Input } from "~/components/primitives/Input";
1714import { InputGroup } from "~/components/primitives/InputGroup" ;
1815import { Label } from "~/components/primitives/Label" ;
1916import { Paragraph } from "~/components/primitives/Paragraph" ;
20- import { Popover , PopoverContent , PopoverTrigger } from "~/components/primitives/Popover" ;
2117import { RadioGroup , RadioGroupItem } from "~/components/primitives/RadioButton" ;
2218import { Select , SelectItem } from "~/components/primitives/Select" ;
2319import SegmentedControl from "~/components/primitives/SegmentedControl" ;
@@ -27,9 +23,16 @@ import { useOrganization } from "~/hooks/useOrganizations";
2723import { useShowSelfServe } from "~/hooks/useShowSelfServe" ;
2824import type { ApiKeyPreset } from "~/services/apiKeyPresetValidation.server" ;
2925import { SCOPE_CAPABILITIES , scopeDetailForPreset } from "~/utils/apiKeyScopeDetail" ;
26+ import {
27+ apiKeyExpiryDay ,
28+ apiKeyExpiryTimestamp ,
29+ DEFAULT_API_KEY_EXPIRY_DAYS ,
30+ isApiKeyExpiryDayExpired ,
31+ } from "~/utils/apiKeyExpiry" ;
3032import { cn } from "~/utils/cn" ;
3133import { v3BillingPath } from "~/utils/pathBuilder" ;
3234import type { action , ApiKeyActionData } from "./route" ;
35+ import { ApiKeyExpiryPicker } from "./ApiKeyExpiryPicker" ;
3336
3437type 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
362352const SCOPE_LEVEL_WORDS = [ "No access" , "Read" , "Read & write" , "Allowed" , "Full access" ] as const ;
363353const 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-
442370function PresetGroup ( {
443371 title,
444372 presets,
0 commit comments