-
Notifications
You must be signed in to change notification settings - Fork 612
feat: keys create wizard #3156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: keys create wizard #3156
Changes from all commits
678202c
1d75f87
896560d
3e1155b
1a353da
20cde15
096b3ee
622aa58
c4ef986
4c0ef46
be44c3d
011589c
b9192f3
4936df0
0d1edb3
746d500
ce08839
1e1b617
f488658
454b216
a7c77e5
1dc2f70
30ea84b
94b409a
5b5fe59
c219dae
b591fc3
4ba872f
496c12e
f5bbb2a
4bf7030
a37ccfb
ceda977
241b58d
284a7e7
9346cc4
8142cb0
d3ff0ec
5f5e91c
ae7d42c
c02ae4d
2032a5a
a91897d
14c511a
594e8a5
f91fe79
c3690b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| "use client"; | ||
| import { | ||
| Select, | ||
| SelectContent, | ||
| SelectItem, | ||
| SelectTrigger, | ||
| SelectValue, | ||
| } from "@/components/ui/select"; | ||
| import { ChartPie, CircleInfo } from "@unkey/icons"; | ||
| import { FormInput } from "@unkey/ui"; | ||
| import { Controller, useFormContext, useWatch } from "react-hook-form"; | ||
| import type { CreditsFormValues } from "../create-key.schema"; | ||
| import { ProtectionSwitch } from "./protection-switch"; | ||
|
|
||
| export const UsageSetup = () => { | ||
| const { | ||
| register, | ||
| formState: { errors }, | ||
| control, | ||
| setValue, | ||
| getValues, | ||
| trigger, | ||
| } = useFormContext<CreditsFormValues>(); | ||
|
|
||
| const limitEnabled = useWatch({ | ||
| control, | ||
| name: "limit.enabled", | ||
| }); | ||
|
|
||
| const currentRefillInterval = useWatch({ | ||
| control, | ||
| name: "limit.data.refill.interval", | ||
| }); | ||
|
|
||
| const handleSwitchChange = (checked: boolean) => { | ||
| setValue("limit.enabled", checked); | ||
|
|
||
| // When enabling, ensure refill has the correct structure | ||
| if (checked && !getValues("limit.data.refill.interval")) { | ||
| setValue("limit.data.refill.interval", "none", { shouldValidate: true }); | ||
| setValue("limit.data.refill.amount", undefined, { shouldValidate: true }); | ||
| setValue("limit.data.refill.refillDay", undefined, { | ||
| shouldValidate: true, | ||
| }); | ||
| } | ||
|
|
||
| trigger("limit"); | ||
| }; | ||
|
|
||
| const handleRefillIntervalChange = (value: "none" | "daily" | "monthly") => { | ||
| setValue("limit.data.refill.interval", value, { shouldValidate: true }); | ||
|
|
||
| // Clean up related fields based on the selected interval | ||
| if (value === "none") { | ||
| setValue("limit.data.refill.amount", undefined, { shouldValidate: true }); | ||
| setValue("limit.data.refill.refillDay", undefined, { | ||
| shouldValidate: true, | ||
| }); | ||
| } else if (value === "daily") { | ||
| // For daily, ensure refillDay is undefined but keep amount if present | ||
| setValue("limit.data.refill.refillDay", undefined, { | ||
| shouldValidate: true, | ||
| }); | ||
| // If amount is not set, set a default | ||
| if (!getValues("limit.data.refill.amount")) { | ||
| setValue("limit.data.refill.amount", 100, { shouldValidate: true }); | ||
| } | ||
| } else if (value === "monthly") { | ||
| // For monthly, ensure both amount and refillDay have values | ||
| if (!getValues("limit.data.refill.amount")) { | ||
| setValue("limit.data.refill.amount", 100, { shouldValidate: true }); | ||
| } | ||
| if (!getValues("limit.data.refill.refillDay")) { | ||
| setValue("limit.data.refill.refillDay", 1, { shouldValidate: true }); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-5 px-2 py-1"> | ||
| <ProtectionSwitch | ||
| description="Turn on to limit how many times this key can be used. Once the limit | ||
| is reached, the key will be disabled." | ||
| title="Credits" | ||
| icon={<ChartPie className="text-gray-12" size="sm-regular" />} | ||
| checked={limitEnabled} | ||
| onCheckedChange={handleSwitchChange} | ||
| {...register("limit.enabled")} | ||
| /> | ||
| <FormInput | ||
| className="[&_input:first-of-type]:h-[36px]" | ||
| placeholder="100" | ||
| inputMode="numeric" | ||
| type="number" | ||
| label="Number of uses" | ||
| description="Enter the remaining amount of uses for this key." | ||
| error={errors.limit?.data?.remaining?.message} | ||
| disabled={!limitEnabled} | ||
| readOnly={!limitEnabled} | ||
| {...register("limit.data.remaining")} | ||
| /> | ||
|
|
||
| <Controller | ||
| control={control} | ||
| name="limit.data.refill.interval" | ||
| render={({ field }) => ( | ||
| <div className="space-y-1.5"> | ||
| <div className="text-gray-11 text-[13px] flex items-center">Refill Rate</div> | ||
| <Select | ||
| onValueChange={(value) => { | ||
| handleRefillIntervalChange(value as "none" | "daily" | "monthly"); | ||
| }} | ||
| value={field.value || "none"} | ||
| disabled={!limitEnabled} | ||
| > | ||
| <SelectTrigger className="h-9"> | ||
| <SelectValue placeholder="Select refill interval" /> | ||
| </SelectTrigger> | ||
| <SelectContent className="border-none rounded-md"> | ||
| <SelectItem value="none">None</SelectItem> | ||
| <SelectItem value="daily">Daily</SelectItem> | ||
| <SelectItem value="monthly">Monthly</SelectItem> | ||
| </SelectContent> | ||
| </Select> | ||
| <output className="text-gray-9 flex gap-2 items-center text-[13px]"> | ||
| <CircleInfo size="md-regular" aria-hidden="true" /> | ||
| <span>Interval key will be refilled.</span> | ||
| </output> | ||
| </div> | ||
| )} | ||
| /> | ||
|
|
||
| <Controller | ||
| control={control} | ||
| name="limit.data.refill.amount" | ||
| render={({ field }) => ( | ||
| <FormInput | ||
| className="[&_input:first-of-type]:h-[36px]" | ||
| placeholder="100" | ||
| inputMode="numeric" | ||
| type="number" | ||
| label="Number of uses per interval" | ||
| description="Enter the number of uses to refill per interval." | ||
| error={errors.limit?.data?.refill?.amount?.message} | ||
| disabled={!limitEnabled || currentRefillInterval === "none"} | ||
| readOnly={!limitEnabled || currentRefillInterval === "none"} | ||
| value={field.value === undefined ? "" : field.value} | ||
| onChange={(e) => { | ||
| const value = e.target.value === "" ? undefined : Number(e.target.value); | ||
| field.onChange(value); | ||
| }} | ||
| /> | ||
| )} | ||
| /> | ||
|
|
||
| <Controller | ||
| control={control} | ||
| name="limit.data.refill.refillDay" | ||
| render={({ field }) => ( | ||
| <FormInput | ||
| className="[&_input:first-of-type]:h-[36px]" | ||
| placeholder="1" | ||
| inputMode="numeric" | ||
| type="number" | ||
| label="On which day of the month should we refill the key?" | ||
| description="Enter the day to refill monthly (1-31)." | ||
| error={errors.limit?.data?.refill?.refillDay?.message} | ||
| disabled={!limitEnabled || currentRefillInterval !== "monthly"} | ||
| readOnly={!limitEnabled || currentRefillInterval !== "monthly"} | ||
| value={field.value === undefined ? "" : field.value} | ||
| onChange={(e) => { | ||
| const value = e.target.value === "" ? undefined : Number(e.target.value); | ||
| field.onChange(value); | ||
| }} | ||
| /> | ||
| )} | ||
| /> | ||
| </div> | ||
| ); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,184 @@ | ||
| "use client"; | ||
| import { DatetimePopover } from "@/components/logs/datetime/datetime-popover"; | ||
| import { Clock } from "@unkey/icons"; | ||
| import { FormInput } from "@unkey/ui"; | ||
| import { addDays, addMinutes, format } from "date-fns"; | ||
| import { useState } from "react"; | ||
| import { Controller, useFormContext, useWatch } from "react-hook-form"; | ||
| import type { ExpirationFormValues } from "../create-key.schema"; | ||
| import { ProtectionSwitch } from "./protection-switch"; | ||
|
|
||
| const EXPIRATION_OPTIONS = [ | ||
| { | ||
| id: 1, | ||
| display: "1 day", | ||
| value: "1d", | ||
| description: "Key expires in 1 day", | ||
| checked: false, | ||
| }, | ||
| { | ||
| id: 2, | ||
| display: "1 week", | ||
| value: "7d", | ||
| description: "Key expires in 1 week", | ||
| checked: false, | ||
| }, | ||
| { | ||
| id: 3, | ||
| display: "1 month", | ||
| value: "30d", | ||
| description: "Key expires in 30 days", | ||
| checked: false, | ||
| }, | ||
| { | ||
| id: 4, | ||
| display: "Custom", | ||
| value: undefined, | ||
| description: "Set custom expiration date and time", | ||
| checked: false, | ||
| }, | ||
| ]; | ||
|
|
||
| export const ExpirationSetup = () => { | ||
| const { | ||
| register, | ||
| formState: { errors }, | ||
| control, | ||
| setValue, | ||
| } = useFormContext<ExpirationFormValues>(); | ||
|
|
||
| const [selectedTitle, setSelectedTitle] = useState<string>("1 day"); | ||
|
|
||
| const expirationEnabled = useWatch({ | ||
| control, | ||
| name: "expiration.enabled", | ||
| }); | ||
|
|
||
| const currentExpiryDate = useWatch({ | ||
| control, | ||
| name: "expiration.data", | ||
| }); | ||
|
|
||
| const handleSwitchChange = (checked: boolean) => { | ||
| setValue("expiration.enabled", checked); | ||
|
|
||
| // Set default expiry date (1 day) when enabling if not already set | ||
| if (checked && !currentExpiryDate) { | ||
| setValue("expiration.data", addDays(new Date(), 1)); | ||
| } | ||
| }; | ||
|
|
||
| // Calculate minimum valid date (10 minutes from now) | ||
| const minValidDate = addMinutes(new Date(), 10); | ||
|
|
||
| // Handle date and time selection from DatetimePopover | ||
| const handleDateTimeChange = (startTime?: number, _?: number, since?: string) => { | ||
| if (since) { | ||
| // Handle predefined time ranges | ||
| let newDate = new Date(); | ||
| switch (since) { | ||
| case "1d": | ||
| newDate = addDays(newDate, 1); | ||
| break; | ||
| case "7d": | ||
| newDate = addDays(newDate, 7); | ||
| break; | ||
| case "30d": | ||
| newDate = addDays(newDate, 30); | ||
| break; | ||
| } | ||
| setValue("expiration.data", newDate); | ||
| } else if (startTime) { | ||
| // Handle custom date selection | ||
| const newDate = new Date(startTime); | ||
|
|
||
| // Check if the date is valid (at least 2 minutes in the future) | ||
| if (newDate < minValidDate) { | ||
| // If date is too soon, set it to minimum valid date | ||
| setValue("expiration.data", minValidDate); | ||
| } else { | ||
| setValue("expiration.data", newDate); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| // Format date for display | ||
| const formatExpiryDate = (date?: Date) => { | ||
| if (!date) { | ||
| return "Select expiration date"; | ||
| } | ||
| return format(date, "MMM d, yyyy 'at' h:mm a"); | ||
| }; | ||
|
|
||
| const getInitialTimeValues = () => { | ||
| // If we have a current expiry date, use it, otherwise use minimum valid date | ||
| const initialDate = currentExpiryDate || minValidDate; | ||
|
|
||
| return { | ||
| startTime: initialDate.getTime(), | ||
| endTime: undefined, // Not needed for single date mode | ||
| since: undefined, | ||
| }; | ||
| }; | ||
|
|
||
| // Calculate date for showing warning about close expiry (less than 1 hour) | ||
| const isExpiringVerySoon = | ||
| currentExpiryDate && | ||
| new Date(currentExpiryDate).getTime() - new Date().getTime() < 60 * 60 * 1000; | ||
|
|
||
| const getExpiryDescription = () => { | ||
| if (isExpiringVerySoon) { | ||
| return "This key will expire very soon (less than 1 hour). Consider setting a longer expiration time."; | ||
| } | ||
| return "The key will be automatically disabled at the specified date and time (UTC)."; | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-5 px-2 py-1"> | ||
| <ProtectionSwitch | ||
| description="Turn on to set an expiration date. When reached, the key will be automatically disabled." | ||
| title="Expiration" | ||
| icon={<Clock className="text-gray-12" size="sm-regular" />} | ||
| checked={expirationEnabled} | ||
| onCheckedChange={handleSwitchChange} | ||
| {...register("expiration.enabled")} | ||
| /> | ||
|
|
||
| <Controller | ||
| control={control} | ||
| name="expiration.data" | ||
| render={({ field }) => ( | ||
| <DatetimePopover | ||
| initialTitle={selectedTitle} | ||
| initialTimeValues={getInitialTimeValues()} | ||
| onSuggestionChange={setSelectedTitle} | ||
| onDateTimeChange={handleDateTimeChange} | ||
| customOptions={EXPIRATION_OPTIONS} | ||
| customHeader={<ExpirationHeader />} | ||
| singleDateMode | ||
| minDate={minValidDate} // Set minimum date to 2 minutes from now | ||
| > | ||
| <FormInput | ||
| label="Expiry Date" | ||
| description={getExpiryDescription()} | ||
| readOnly | ||
| disabled={!expirationEnabled} | ||
| value={formatExpiryDate(field.value)} | ||
| className="cursor-pointer w-full" | ||
| variant={expirationEnabled && isExpiringVerySoon ? "warning" : undefined} | ||
| error={errors.expiration?.data?.message} | ||
| /> | ||
| </DatetimePopover> | ||
| )} | ||
| /> | ||
|
Comment on lines
+147
to
+173
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainValidate and clear form errors correctly Per PR comments, the date field is automatically marked as invalid and selecting a valid date doesn't clear the validation error. This may be related to how form validation is handled. Check if the form validation is properly updated when dates change. Consider logging the validation in the 🏁 Script executed: #!/bin/bash
# Search for form validation schema for expiration
rg -A 10 -B 10 "expiration.*schema|schema.*expiration" --glob="*.{ts,tsx}"Length of output: 76 🏁 Script executed: #!/bin/bash
# Locate where handleDateTimeChange is defined/used
rg -n "handleDateTimeChange" -A 5 -B 5 --glob="*.{ts,tsx}"
# Check if clearErrors is ever called in relation to expiration
rg -n "clearErrors" -A 5 -B 5 --glob="*.{ts,tsx}"Length of output: 6363 🏁 Script executed: #!/bin/bash
# Inspect handleDateTimeChange implementation in expiration-setup.tsx and related form methods
file="apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/expiration-setup.tsx"
# Show the function body
rg -n -A30 -B0 "const handleDateTimeChange" -- "$file"
# Check how the form state is updated
rg -n "setValue" -- "$file"
# Verify if errors are cleared or set
rg -n "clearErrors" -- "$file"
rg -n "setError" -- "$file"Length of output: 1890 Clear or re-validate the expiry field when the date changes The • Destructure and call Locations to update:
|
||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| const ExpirationHeader = () => { | ||
| return ( | ||
| <div className="flex justify-between w-full h-8 px-2"> | ||
| <span className="text-gray-9 text-[13px] w-full">Choose expiration date</span> | ||
| </div> | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix expiration warning when expiration is disabled
Per PR comments, an expiration warning appears even when the date field is disabled. This is because
isExpiringVerySooncan be true even whenexpirationEnabledis false.📝 Committable suggestion