diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/credits-setup.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/credits-setup.tsx new file mode 100644 index 0000000000..057c8b35e7 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/credits-setup.tsx @@ -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(); + + 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 ( +
+ } + checked={limitEnabled} + onCheckedChange={handleSwitchChange} + {...register("limit.enabled")} + /> + + + ( +
+
Refill Rate
+ + + +
+ )} + /> + + ( + { + const value = e.target.value === "" ? undefined : Number(e.target.value); + field.onChange(value); + }} + /> + )} + /> + + ( + { + const value = e.target.value === "" ? undefined : Number(e.target.value); + field.onChange(value); + }} + /> + )} + /> +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/expiration-setup.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/expiration-setup.tsx new file mode 100644 index 0000000000..fb79d6ddba --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/expiration-setup.tsx @@ -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(); + + const [selectedTitle, setSelectedTitle] = useState("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 ( +
+ } + checked={expirationEnabled} + onCheckedChange={handleSwitchChange} + {...register("expiration.enabled")} + /> + + ( + } + singleDateMode + minDate={minValidDate} // Set minimum date to 2 minutes from now + > + + + )} + /> +
+ ); +}; + +const ExpirationHeader = () => { + return ( +
+ Choose expiration date +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/general-setup.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/general-setup.tsx new file mode 100644 index 0000000000..11da2a4cb2 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/general-setup.tsx @@ -0,0 +1,72 @@ +"use client"; +import { FormInput } from "@unkey/ui"; +import { useFormContext } from "react-hook-form"; +import type { FormValues } from "../create-key.schema"; + +export const GeneralSetup = () => { + const { + register, + formState: { errors }, + } = useFormContext(); + + return ( +
+ + + + + + {/* INFO: We'll enable that soon */} + {/* */} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx new file mode 100644 index 0000000000..8ccc8f7658 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx @@ -0,0 +1,281 @@ +"use client"; + +import { RatelimitOverviewTooltip } from "@/app/(app)/ratelimits/[namespaceId]/_overview/components/table/components/ratelimit-overview-tooltip"; +import { CopyButton } from "@/components/dashboard/copy-button"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; +import { Popover, PopoverContent } from "@/components/ui/popover"; +import { toast } from "@/components/ui/toaster"; +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { + ArrowRight, + Check, + CircleInfo, + Eye, + EyeSlash, + Key2, + Plus, + TriangleWarning2, +} from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useRef, useState } from "react"; +import { SecretKey } from "./secret-key"; + +const PopoverAnchor = PopoverPrimitive.Anchor; +const PopoverClose = PopoverPrimitive.Close; + +export const KeyCreatedSuccessDialog = ({ + isOpen, + onClose, + keyData, + apiId, + keyspaceId, + onCreateAnother, +}: { + isOpen: boolean; + onClose: () => void; + keyData: { key: string; id: string; name?: string } | null; + apiId: string; + keyspaceId?: string | null; + onCreateAnother?: () => void; +}) => { + const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [isCreateAnother, setIsCreateAnother] = useState(false); + const xButtonRef = useRef(null); + const shouldShowWarning = true; + + if (!keyData) { + return null; + } + + const split = keyData.key.split("_") ?? []; + const maskedKey = + split.length >= 2 + ? `${split.at(0)}_${"*".repeat(split.at(1)?.length ?? 0)}` + : "*".repeat(split.at(0)?.length ?? 0); + + const snippet = `curl -XPOST '${ + process.env.NEXT_PUBLIC_UNKEY_API_URL ?? "https://api.unkey.dev" + }/v1/keys.verifyKey' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "key": "${keyData.key}", + "apiId": "${apiId}" + }'`; + + const handleAttemptClose = (shouldCreateAnother = false) => { + setIsCreateAnother(shouldCreateAnother); + setIsConfirmOpen(true); + }; + + const handleConfirmAndClose = () => { + setIsConfirmOpen(false); + onClose(); + if (isCreateAnother) { + onCreateAnother?.(); + } + }; + + return ( + { + if (!open) { + onClose(); + } + }} + > + + <> +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ Key Created +
+
+ You've successfully generated a new API key. +
Use this key to authenticate requests from your application. +
+
+
+
+
+
+
Key Details
+
+
+
+ +
+
+
{keyData.id}
+ +
+ {keyData.name ?? "Unnamed Key"} +
+
+
+ +
+
+
+
+
Key Secret
+ +
+ + + Copy and save this key secret as it won't be shown again.{" "} + + Learn more + + +
+
+
+
Try It Out
+
+
+
+
+                      {showKeyInSnippet ? snippet : snippet.replace(keyData.key, maskedKey)}
+                    
+
+
+ + +
+
+
+
+
+
+ All set! You can now create another key or explore the docs to learn more +
+
+ + {/* INFO: We'll add this back soon */} + {/* */} +
+
+
+ + + + e.preventDefault()} + > +
+
+
+ +
+
+ You won't see this secret key again! +
+
+
+
+
+
+
+ Make sure to copy your secret key before closing. It cannot be retrieved later. +
+
+ + + + +
+ + + + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup.tsx new file mode 100644 index 0000000000..72ce2f2194 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup.tsx @@ -0,0 +1,113 @@ +"use client"; +import { toast } from "@/components/ui/toaster"; +import { Code } from "@unkey/icons"; +import { FormTextarea } from "@unkey/ui"; +import { Button } from "@unkey/ui"; +import { useFormContext, useWatch } from "react-hook-form"; +import type { MetadataFormValues } from "../create-key.schema"; +import { ProtectionSwitch } from "./protection-switch"; + +const EXAMPLE_JSON = { + user: { + id: "user_123456", + role: "admin", + permissions: ["read", "write", "delete"], + }, +}; + +export const MetadataSetup = () => { + const { + register, + formState: { errors }, + control, + setValue, + trigger, + } = useFormContext(); + + const metadataEnabled = useWatch({ + control, + name: "metadata.enabled", + }); + + const currentMetadata = useWatch({ + control, + name: "metadata.data", + }); + + const handleSwitchChange = (checked: boolean) => { + setValue("metadata.enabled", checked); + // Only set example json if its first time + if (checked && !currentMetadata) { + setValue("metadata.data", JSON.stringify(EXAMPLE_JSON, null, 2)); + } + + trigger("metadata"); + }; + + const formatJSON = () => { + try { + const parsed = JSON.parse(currentMetadata || "{}"); + setValue("metadata.data", JSON.stringify(parsed, null, 2)); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } else { + toast.error("Please check your JSON syntax"); + } + } + }; + + const validateJSON = (jsonString: string): boolean => { + try { + JSON.parse(jsonString); + return true; + } catch { + return false; + } + }; + + return ( +
+ } + checked={metadataEnabled} + onCheckedChange={handleSwitchChange} + {...register("metadata.enabled")} + /> + +
+ +
Format
+ + } + description="Add structured JSON data to this key. Must be valid JSON format." + error={errors.metadata?.data?.message} + disabled={!metadataEnabled} + readOnly={!metadataEnabled} + rows={15} + {...register("metadata.data", { + validate: (value) => { + if (metadataEnabled && (!value || !validateJSON(value))) { + return "Must be valid JSON"; + } + return true; + }, + })} + /> +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/protection-switch.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/protection-switch.tsx new file mode 100644 index 0000000000..90f5d0de6e --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/protection-switch.tsx @@ -0,0 +1,50 @@ +"use client"; +import { Switch } from "@/components/ui/switch"; +import { forwardRef } from "react"; + +type FeatureCardProps = { + icon: React.ReactNode; + title: string; + description: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; + switchProps?: React.ComponentPropsWithoutRef; +}; + +export const ProtectionSwitch = forwardRef( + ({ icon, title, description, checked, onCheckedChange, switchProps, ...rest }, ref) => { + return ( +
+
+
+
{icon}
+
{title}
+
+
{description}
+
+ +
+ ); + }, +); + +ProtectionSwitch.displayName = "ProtectionSwitch"; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup.tsx new file mode 100644 index 0000000000..68a643e6d6 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup.tsx @@ -0,0 +1,151 @@ +"use client"; +import { Gauge, Trash } from "@unkey/icons"; +import { Button, FormInput } from "@unkey/ui"; +import { useEffect } from "react"; +import { useFieldArray, useFormContext, useWatch } from "react-hook-form"; +import type { RatelimitFormValues, RatelimitItem } from "../create-key.schema"; +import { ProtectionSwitch } from "./protection-switch"; + +export const RatelimitSetup = () => { + const { + register, + formState: { errors }, + control, + setValue, + trigger, + } = useFormContext(); + + // Note: We're using the explicitly defined type from the schema file + const { fields, append, remove } = useFieldArray({ + control, + name: "ratelimit.data" as const, // Use as const to make TypeScript recognize this as a literal + }); + + const ratelimitEnabled = useWatch({ + control, + name: "ratelimit.enabled", + }); + + // Ensure there's always at least one ratelimit item + useEffect(() => { + if (fields.length === 0) { + append({ + name: "Default", + limit: 10, + refillInterval: 1000, + }); + } + }, [fields.length, append]); + + const handleSwitchChange = (checked: boolean) => { + setValue("ratelimit.enabled", checked); + trigger("ratelimit"); + }; + + const handleAddRatelimit = () => { + const newItem: RatelimitItem = { + name: "", + limit: 10, + refillInterval: 1000, + }; + append(newItem); + }; + + return ( +
+
+ } + checked={ratelimitEnabled} + onCheckedChange={handleSwitchChange} + {...register("ratelimit.enabled")} + /> +
+ +
+
+ Ratelimits + + {fields.length} + +
+ +
+ +
+ {fields.map((field, index) => ( +
+
+ + {fields.length > 1 ? ( + + ) : ( +
+ )} +
+
+ +
+ + +
+
+
+ ))} +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/secret-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/secret-key.tsx new file mode 100644 index 0000000000..eb5578e854 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/secret-key.tsx @@ -0,0 +1,75 @@ +"use client"; // Keep if needed + +import { CopyButton } from "@/components/dashboard/copy-button"; +import { cn } from "@/lib/utils"; +import { CircleLock, Eye, EyeSlash } from "@unkey/icons"; +import { Button } from "@unkey/ui"; +import { useState } from "react"; + +const maskKey = (key: string): string => { + return "•".repeat(key.length); +}; + +export const SecretKey = ({ + value, + title = "Value", + className, +}: { + value: string; + title: string; + className?: string; +}) => { + const [isVisible, setIsVisible] = useState(false); + + const displayValue = isVisible ? value : maskKey(value); + + const handleToggleVisibility = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsVisible(!isVisible); + }; + + return ( +
+
+
+ +
+
+ {" "} +

+ {displayValue} +

+
+
+ + + +
+
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/section-label.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/section-label.tsx new file mode 100644 index 0000000000..d84954b8c4 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/section-label.tsx @@ -0,0 +1,25 @@ +import { Check, XMark } from "@unkey/icons"; +import type { SectionState } from "../types"; + +export const SectionLabel = ({ + label, + validState, +}: { + label: string; + validState: SectionState; +}) => { + return ( +
+ {label} + {validState !== "initial" && ( +
+ {validState === "valid" ? ( + + ) : ( + + )} +
+ )} +
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.constants.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.constants.tsx new file mode 100644 index 0000000000..66f49b8d17 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.constants.tsx @@ -0,0 +1,48 @@ +import type { StepNamesFrom } from "@/components/dialog-container/navigable-dialog"; +import { CalendarClock, ChartPie, Code, Gauge, Key2 } from "@unkey/icons"; +import type { SectionState } from "./types"; + +import { UsageSetup } from "./components/credits-setup"; +import { ExpirationSetup } from "./components/expiration-setup"; +import { GeneralSetup } from "./components/general-setup"; +import { MetadataSetup } from "./components/metadata-setup"; +import { RatelimitSetup } from "./components/ratelimit-setup"; + +export const SECTIONS = [ + { + id: "general", + label: "General Setup", + icon: Key2, + content: () => , + }, + { + id: "ratelimit", + label: "Ratelimit", + icon: Gauge, + content: () => , + }, + { + id: "credits", + label: "Credits", + icon: ChartPie, + content: () => , + }, + { + id: "expiration", + label: "Expiration", + icon: CalendarClock, + content: () => , + }, + { + id: "metadata", + label: "Metadata", + icon: Code, + content: () => , + }, +] as const; + +export type DialogSectionName = StepNamesFrom; + +export const DEFAULT_STEP_STATES: Record = Object.fromEntries( + SECTIONS.map((section) => [section.id, "initial"]), +) as Record; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.schema.ts b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.schema.ts new file mode 100644 index 0000000000..fae93e4d1e --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.schema.ts @@ -0,0 +1,358 @@ +import { z } from "zod"; + +// Helper function for creating conditional schemas based on the "enabled" flag +export const createConditionalSchema = < + T extends z.ZodObject, + EnabledPath extends string = "enabled", +>( + enabledPath: EnabledPath, + schema: T, +) => { + return z.union([ + // when enabled is false, don't validate other fields + z + .object({ + [enabledPath]: z.literal(false), + }) + .passthrough(), + + // when enabled is true, apply all validations + schema, + ]) as z.ZodUnion< + [z.ZodObject<{ [K in EnabledPath]: z.ZodLiteral }, "passthrough", z.ZodTypeAny>, T] + >; +}; + +// Basic schemas +export const keyPrefixSchema = z + .string() + .max(8, { message: "Prefixes cannot be longer than 8 characters" }) + .trim() + .refine((prefix) => !prefix.includes(" "), { + message: "Prefixes cannot contain spaces.", + }) + .refine((prefix) => !prefix.endsWith("_"), { + message: "Prefixes cannot end with an underscore. We'll add that automatically.", + }) + .optional(); + +export const keyBytesSchema = z.coerce + .number() + .int({ message: "Key length must be a whole number (integer)" }) + .min(8, { message: "Key length is too short (minimum 8 bytes required)" }) + .max(255, { message: "Key length is too long (maximum 255 bytes allowed)" }) + .default(16); + +export const generalSchema = z.object({ + bytes: keyBytesSchema, + prefix: keyPrefixSchema, + externalId: z + .string() + .trim() + .max(256, { message: "External ID cannot exceed 256 characters" }) + .optional() + .nullish(), + name: z.string().trim().max(256, { message: "Name cannot exceed 256 characters" }).optional(), + environment: z + .string() + .max(256, { message: "Environment cannot exceed 256 characters" }) + .trim() + .optional(), + enabled: z.boolean().default(true), +}); + +export const refillSchema = z.discriminatedUnion("interval", [ + z.object({ + interval: z.literal("monthly"), + amount: z.coerce + .number({ + errorMap: () => ({ + message: "Refill amount must be a positive whole number", + }), + }) + .int({ message: "Refill amount must be a whole number" }) + .min(1, { message: "Refill amount must be at least 1" }) + .positive({ message: "Refill amount must be positive" }), + refillDay: z.coerce + .number({ + errorMap: () => ({ + message: "Refill day must be a number between 1 and 31", + }), + }) + .int({ message: "Refill day must be a whole number" }) + .min(1, { message: "Refill day must be at least 1" }) + .max(31, { message: "Refill day cannot be more than 31" }), + }), + z.object({ + interval: z.literal("daily"), + amount: z.coerce + .number({ + errorMap: () => ({ + message: "Refill amount must be a positive whole number", + }), + }) + .int({ message: "Refill amount must be a whole number" }) + .min(1, { message: "Refill amount must be at least 1" }) + .positive({ message: "Refill amount must be positive" }), + refillDay: z.undefined(), + }), + z.object({ + interval: z.literal("none").optional(), + amount: z.undefined().optional(), + refillDay: z.undefined().optional(), + }), +]); +export const ratelimitItemSchema = z.object({ + name: z + .string() + .min(3, { message: "Name is required" }) + .max(256, { message: "Name cannot exceed 256 characters" }), + refillInterval: z.coerce + .number({ + errorMap: (issue, { defaultError }) => ({ + message: issue.code === "invalid_type" ? "Duration must be greater than 0" : defaultError, + }), + }) + .positive({ message: "Refill interval must be greater than 0" }), + limit: z.coerce + .number({ + errorMap: (issue, { defaultError }) => ({ + message: + issue.code === "invalid_type" ? "Refill limit must be greater than 0" : defaultError, + }), + }) + .positive({ message: "Limit must be greater than 0" }), +}); + +export const metadataValidationSchema = z.object({ + enabled: z.literal(true), + data: z + .string({ + required_error: "Metadata is required", + invalid_type_error: "Metadata must be a JSON", + }) + .trim() + .min(2, { message: "Metadata must contain valid JSON" }) + .max(65534, { + message: "Metadata cannot exceed 65535 characters (text field limit)", + }) + .refine( + (s) => { + try { + JSON.parse(s); + return true; + } catch { + return false; + } + }, + { + message: "Must be valid a JSON", + }, + ), +}); + +export const limitDataSchema = z.object({ + remaining: z.coerce + .number({ + errorMap: () => ({ + message: "Number of uses must be a positive whole number", + }), + }) + .int({ message: "Number of uses must be a whole number" }) + .positive({ message: "Number of uses must be positive" }), + refill: refillSchema, +}); + +export const limitValidationSchema = z.object({ + enabled: z.literal(true), + data: limitDataSchema, +}); + +export const ratelimitValidationSchema = z.object({ + enabled: z.literal(true), + data: z.array(ratelimitItemSchema).min(1, { message: "At least one rate limit is required" }), +}); + +export const expirationValidationSchema = z.object({ + enabled: z.literal(true), + data: z.preprocess( + (val) => { + if (val === null || val === undefined || val === "") { + return null; + } + + if (val instanceof Date) { + return val; + } + + try { + const date = new Date(val as any); + return date; + } catch { + return null; + } + }, + z + .date({ + required_error: "Expiry date is required when enabled", + invalid_type_error: "Expiry date must be a valid date", + }) + .refine((date) => !Number.isNaN(date.getTime()), { + message: "Please enter a valid date", + }) + .refine( + (date) => { + const minDate = new Date(new Date().getTime() + 2 * 60000); + return date >= minDate; + }, + { + message: "Expiry date must be at least 2 minutes in the future", + }, + ), + ), +}); + +// Combined schemas for forms +export const metadataSchema = z.object({ + metadata: createConditionalSchema("enabled", metadataValidationSchema).default({ + enabled: false, + }), +}); + +export const creditsSchema = z.object({ + limit: createConditionalSchema("enabled", limitValidationSchema) + .optional() + .default({ + enabled: false, + data: { + remaining: 100, + refill: { + interval: "none", + }, + }, + }), +}); + +export const ratelimitSchema = z.object({ + ratelimit: createConditionalSchema("enabled", ratelimitValidationSchema).default({ + enabled: false, + data: [ + { + name: "default", + limit: 10, + refillInterval: 1000, + }, + ], + }), +}); + +export const expirationSchema = z.object({ + expiration: createConditionalSchema("enabled", expirationValidationSchema).default({ + enabled: false, + }), +}); + +// Combined form schema for UI +export const formSchema = z + .object({ + ...generalSchema.shape, + ...metadataSchema.shape, + ...creditsSchema.shape, + ...ratelimitSchema.shape, + ...expirationSchema.shape, + }) + .superRefine((data, ctx) => { + // For monthly refills, ensure refillDay is provided + if ( + data.limit?.enabled && + data.limit.data?.refill?.interval === "monthly" && + !data.limit.data.refill.refillDay + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Refill day is required for monthly interval", + path: ["limit", "data", "refill", "refillDay"], + }); + } + + // Validate metadata.data field when metadata.enabled is true + if (data.metadata?.enabled && !data.metadata.data) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Metadata is required when metadata is enabled", + path: ["metadata", "data"], + }); + } + + // Validate expiration.data field when expiration.enabled is true + if (data.expiration?.enabled && !data.expiration.data) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Expiry date is required when expiration is enabled", + path: ["expiration", "data"], + }); + } + }); + +// API/TRPC input schema +export const createKeyInputSchema = z.object({ + prefix: keyPrefixSchema, + bytes: keyBytesSchema, + keyAuthId: z.string(), + externalId: z + .string() + .max(256, { message: "External ID cannot exceed 256 characters" }) + .nullish(), + meta: z.record(z.unknown()).optional(), + remaining: z.number().int().positive().optional(), + refill: z + .object({ + amount: z.coerce.number().int().min(1), + refillDay: z.number().int().min(1).max(31).nullable(), + }) + .optional(), + expires: z.number().int().nullish(), // unix timestamp in milliseconds + name: z.string().max(256, { message: "Name cannot exceed 256 characters" }).optional(), + ratelimit: z.array(ratelimitItemSchema).optional(), + enabled: z.boolean().default(true), + environment: z + .string() + .max(256, { message: "Environment cannot exceed 256 characters" }) + .optional(), +}); + +// Type exports +export type RatelimitItem = z.infer; +export type LimitData = z.infer; +export type CreateKeyInput = z.infer; +export type FormValues = z.infer; + +export type FormValueTypes = { + bytes: number; + prefix?: string; + ownerId?: string; + name?: string; + environment?: string; + metadata: { + enabled: boolean; + data?: string; + }; + limit?: { + enabled: boolean; + data?: LimitData; + }; + ratelimit: { + enabled: boolean; + data: RatelimitItem[]; + }; + expiration: { + enabled: boolean; + data?: Date; + }; +}; + +// Helper type exports +export type RatelimitFormValues = Pick; +export type CreditsFormValues = Pick; +export type MetadataFormValues = Pick; +export type ExpirationFormValues = Pick; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.utils.ts b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.utils.ts new file mode 100644 index 0000000000..945cd2e729 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/create-key.utils.ts @@ -0,0 +1,120 @@ +import { + type CreateKeyInput, + type FormValueTypes, + type FormValues, + creditsSchema, + expirationSchema, + generalSchema, + metadataSchema, + ratelimitSchema, +} from "./create-key.schema"; +import type { SectionName } from "./types"; + +/** + * Processes form data to create the final API payload + */ +export const formValuesToApiInput = (formValues: FormValues, keyAuthId: string): CreateKeyInput => { + return { + keyAuthId, + prefix: formValues.prefix === "" ? undefined : formValues.prefix, + bytes: formValues.bytes, + externalId: formValues.externalId || null, + name: formValues.name === "" ? undefined : formValues.name, + enabled: true, + environment: formValues.environment === "" ? undefined : formValues.name, + meta: + formValues.metadata?.enabled && formValues.metadata.data + ? JSON.parse(formValues.metadata.data) + : undefined, + remaining: formValues.limit?.enabled ? formValues.limit.data?.remaining : undefined, + refill: + formValues.limit?.enabled && formValues.limit.data?.refill?.interval !== "none" + ? { + amount: formValues.limit.data?.refill?.amount as number, + refillDay: + formValues.limit.data?.refill?.interval === "monthly" + ? formValues.limit.data?.refill?.refillDay || null + : null, + } + : undefined, + expires: + formValues.expiration?.enabled && formValues.expiration.data + ? formValues.expiration.data.getTime() + : undefined, + ratelimit: formValues.ratelimit?.enabled ? formValues.ratelimit.data : undefined, + }; +}; + +export const isFeatureEnabled = (sectionId: SectionName, values: FormValues): boolean => { + switch (sectionId) { + case "metadata": + return values.metadata?.enabled || false; + case "ratelimit": + return values.ratelimit?.enabled || false; + case "credits": + return values.limit?.enabled || false; + case "expiration": + return values.expiration?.enabled || false; + case "general": + return true; + default: + return false; + } +}; + +export const sectionSchemaMap = { + general: generalSchema, + ratelimit: ratelimitSchema, + credits: creditsSchema, + expiration: expirationSchema, + metadata: metadataSchema, +}; + +export const getFieldsFromSchema = (schema: any, prefix = ""): string[] => { + if (!schema?.shape) { + return []; + } + + return Object.keys(schema.shape).flatMap((key) => { + const fullPath = prefix ? `${prefix}.${key}` : key; + // Handle nested objects recursively + if (schema.shape[key]?._def?.typeName === "ZodObject") { + return getFieldsFromSchema(schema.shape[key], fullPath); + } + return [fullPath]; + }); +}; + +export const getDefaultValues = (): Partial => { + return { + bytes: 16, + prefix: "", + metadata: { + enabled: false, + }, + limit: { + enabled: false, + data: { + remaining: 100, + refill: { + interval: "none", + amount: undefined, + refillDay: undefined, + }, + }, + }, + ratelimit: { + enabled: false, + data: [ + { + name: "Default", + limit: 10, + refillInterval: 1000, + }, + ], + }, + expiration: { + enabled: false, + }, + }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-key.tsx new file mode 100644 index 0000000000..3911043161 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-create-key.tsx @@ -0,0 +1,49 @@ +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; + +export const useCreateKey = ( + onSuccess: (data: { + keyId: `key_${string}`; + key: string; + name?: string; + }) => void, +) => { + const trpcUtils = trpc.useUtils(); + const key = trpc.key.create.useMutation({ + onSuccess(data) { + toast.success("Key Created Successfully", { + description: `Your key ${data.keyId} has been created and is ready to use`, + duration: 5000, + }); + trpcUtils.api.keys.list.invalidate(); + onSuccess(data); + }, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Key Creation Failed", { + description: + "Unable to find the correct API configuration. Please refresh and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while creating your key. Please try again later or contact support at support.unkey.dev", + }); + } else if (err.data?.code === "BAD_REQUEST") { + toast.error("Invalid Configuration", { + description: `Please check your key settings. ${err.message || ""}`, + }); + } else { + toast.error("Failed to Create Key", { + description: err.message || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + } + }, + }); + + return key; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-validate-steps.ts b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-validate-steps.ts new file mode 100644 index 0000000000..5e1374b3e2 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/hooks/use-validate-steps.ts @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import type { UseFormTrigger } from "react-hook-form"; +import { DEFAULT_STEP_STATES, type DialogSectionName, SECTIONS } from "../create-key.constants"; +import type { FormValues } from "../create-key.schema"; +import { getFieldsFromSchema, isFeatureEnabled, sectionSchemaMap } from "../create-key.utils"; +import type { SectionName, SectionState } from "../types"; + +// Custom hook to handle form validation on dialog open +export const useValidateSteps = ( + isSettingsOpen: boolean, + loadSavedValues: () => Promise, + trigger: UseFormTrigger, + getValues: () => FormValues, +) => { + const [validSteps, setValidSteps] = + useState>(DEFAULT_STEP_STATES); + + useEffect(() => { + if (isSettingsOpen) { + const loadAndValidate = async () => { + const loaded = await loadSavedValues(); + if (loaded) { + // Validate all sections after loading + const newValidSteps = { ...DEFAULT_STEP_STATES }; + for (const section of SECTIONS) { + // Skip validating non-existent sections + if (!sectionSchemaMap[section.id as SectionName]) { + continue; + } + // Skip validation if the feature is not enabled + if ( + section.id !== "general" && + !isFeatureEnabled(section.id as SectionName, getValues()) + ) { + newValidSteps[section.id] = "initial"; + continue; + } + // Get fields from the schema and validate + const schema = sectionSchemaMap[section.id as SectionName]; + const fieldsToValidate = getFieldsFromSchema(schema); + if (fieldsToValidate.length > 0) { + const result = await trigger(fieldsToValidate as any); + newValidSteps[section.id] = result ? "valid" : "invalid"; + } + } + setValidSteps(newValidSteps); + } + }; + loadAndValidate(); + } + }, [isSettingsOpen, loadSavedValues, trigger, getValues]); + + // Function to validate a specific section + const validateSection = async (sectionId: DialogSectionName) => { + // Skip validation for non-existent sections + if (!sectionSchemaMap[sectionId as SectionName]) { + return true; + } + + // Skip validation if the feature is not enabled + if (sectionId !== "general" && !isFeatureEnabled(sectionId as SectionName, getValues())) { + setValidSteps((prevState) => ({ + ...prevState, + [sectionId]: "initial", + })); + return true; + } + + // Get the schema for the section + const schema = sectionSchemaMap[sectionId as SectionName]; + // Get fields from the schema + const fieldsToValidate = getFieldsFromSchema(schema); + // Skip validation if no fields to validate + if (fieldsToValidate.length === 0) { + return true; + } + + // Trigger validation for the fields + const result = await trigger(fieldsToValidate as any); + setValidSteps((prevState) => ({ + ...prevState, + [sectionId]: result ? "valid" : "invalid", + })); + + return result; + }; + + // Function to reset validation states to default + const resetValidSteps = () => { + setValidSteps(DEFAULT_STEP_STATES); + }; + + return { validSteps, validateSection, resetValidSteps }; +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx new file mode 100644 index 0000000000..1b6d2bc61c --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/index.tsx @@ -0,0 +1,201 @@ +"use client"; +import { + NavigableDialogBody, + NavigableDialogContent, + NavigableDialogFooter, + NavigableDialogHeader, + NavigableDialogNav, + NavigableDialogRoot, +} from "@/components/dialog-container/navigable-dialog"; +import { usePersistedForm } from "@/hooks/use-persisted-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { IconProps } from "@unkey/icons/src/props"; +import { Button } from "@unkey/ui"; +import { type FC, useState } from "react"; +import { FormProvider } from "react-hook-form"; +import { toast } from "sonner"; +import { KeyCreatedSuccessDialog } from "./components/key-created-success-dialog"; +import { SectionLabel } from "./components/section-label"; +import { type DialogSectionName, SECTIONS } from "./create-key.constants"; +import { type FormValues, formSchema } from "./create-key.schema"; +import { formValuesToApiInput, getDefaultValues } from "./create-key.utils"; +import { useCreateKey } from "./hooks/use-create-key"; +import { useValidateSteps } from "./hooks/use-validate-steps"; + +// Storage key for saving form state +const FORM_STORAGE_KEY = "unkey_create_key_form_state"; + +export const CreateKeyDialog = ({ + keyspaceId, + apiId, +}: { + keyspaceId: string | null; + apiId: string; +}) => { + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [successDialogOpen, setSuccessDialogOpen] = useState(false); + const [createdKeyData, setCreatedKeyData] = useState<{ + key: string; + id: string; + name?: string; + } | null>(null); + + const methods = usePersistedForm( + FORM_STORAGE_KEY, + { + resolver: zodResolver(formSchema), + mode: "onChange", + + shouldFocusError: true, + shouldUnregister: true, + defaultValues: getDefaultValues(), + }, + "memory", + ); + + const { + handleSubmit, + formState, + getValues, + reset, + trigger, + clearPersistedData, + loadSavedValues, + saveCurrentValues, + } = methods; + + const { validSteps, validateSection, resetValidSteps } = useValidateSteps( + isSettingsOpen, + loadSavedValues, + trigger, + getValues, + ); + + const key = useCreateKey((data) => { + if (data?.key && data?.keyId) { + setCreatedKeyData({ + key: data.key, + id: data.keyId, + name: data.name, + }); + setSuccessDialogOpen(true); + } + + // Clean up form state + clearPersistedData(); + reset(getDefaultValues()); + setIsSettingsOpen(false); + resetValidSteps(); + }); + + const handleOpenChange = (open: boolean) => { + if (!open) { + saveCurrentValues(); + } + setIsSettingsOpen(open); + }; + + const onSubmit = async (data: FormValues) => { + if (!keyspaceId) { + toast.error("Failed to Create Key", { + description: "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("https://support.unkey.dev", "_blank"), + }, + }); + return; + } + const finalData = formValuesToApiInput(data, keyspaceId); + + try { + await key.mutateAsync(finalData); + } catch { + // `useCreateKey` already shows a toast, but we still need to + // prevent unhandled‐rejection noise in the console. + } + }; + + const handleSectionNavigation = async (fromId: DialogSectionName) => { + await validateSection(fromId); + return true; + }; + + const handleSuccessDialogClose = () => { + setSuccessDialogOpen(false); + setCreatedKeyData(null); + }; + + const openNewKeyDialog = () => { + setIsSettingsOpen(true); + }; + + return ( + <> + + +
+ + + + ({ + id: section.id, + label: , + icon: section.icon as FC, + }))} + onNavigate={handleSectionNavigation} + /> + ({ + id: section.id, + content: section.content(), + }))} + className="min-h-[600px]" + /> + + +
+
+ +
+ This key will be created immediately and ready-to-use right away +
+
+
+
+
+
+
+ + {/* Success Dialog */} + + + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/types.ts b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/types.ts new file mode 100644 index 0000000000..077ce74e2c --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/types.ts @@ -0,0 +1,3 @@ +export type SectionName = "general" | "ratelimit" | "credits" | "expiration" | "metadata"; + +export type SectionState = "valid" | "invalid" | "initial"; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-datetime/index.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-datetime/index.tsx index e179b82f51..37028a61a8 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-datetime/index.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_overview/components/controls/components/logs-datetime/index.tsx @@ -29,6 +29,7 @@ export const LogsDateTime = () => { return ( { const activeFilters = filters.filter( diff --git a/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx b/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx index e2d3c76cf4..d825adb613 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/api-id-navbar.tsx @@ -1,12 +1,12 @@ "use client"; import { CopyButton } from "@/components/dashboard/copy-button"; -import { CreateKeyButton } from "@/components/dashboard/create-key-button"; import { QuickNavPopover } from "@/components/navbar-popover"; import { Navbar } from "@/components/navigation/navbar"; import { Badge } from "@/components/ui/badge"; import { useIsMobile } from "@/hooks/use-mobile"; import { ChevronExpandY, Gauge } from "@unkey/icons"; +import { CreateKeyDialog } from "./_components/create-key"; export const ApisNavbar = ({ api, @@ -107,7 +107,7 @@ export const ApisNavbar = ({ - +
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx deleted file mode 100644 index 1f94040be1..0000000000 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx +++ /dev/null @@ -1,799 +0,0 @@ -"use client"; -import { revalidate } from "@/app/actions"; -import { CopyButton } from "@/components/dashboard/copy-button"; -import { Loading } from "@/components/dashboard/loading"; -import { VisibleButton } from "@/components/dashboard/visible-button"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; -import { Code } from "@/components/ui/code"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Button } from "@unkey/ui"; - -import { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; -import { toast } from "@/components/ui/toaster"; -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { addMinutes, format } from "date-fns"; -import { AlertCircle } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import type { z } from "zod"; -import { formSchema } from "./validation"; - -export const dynamic = "force-dynamic"; - -type Props = { - apiId: string; - keyAuthId: string; - defaultBytes: number | null; - defaultPrefix: string | null; -}; - -export const CreateKey = ({ apiId, keyAuthId, defaultBytes, defaultPrefix }: Props) => { - const router = useRouter(); - const form = useForm>({ - resolver: async (data, context, options) => { - return zodResolver(formSchema)(data, context, options); - }, - mode: "all", - shouldFocusError: true, - delayError: 100, - // Should required to unregister form elements when they are not rendered. - shouldUnregister: true, - defaultValues: { - prefix: defaultPrefix || undefined, - bytes: defaultBytes || 16, - expireEnabled: false, - limitEnabled: false, - metaEnabled: false, - ratelimitEnabled: false, - limit: { - remaining: undefined, - refill: { - interval: "none", - amount: undefined, - refillDay: undefined, - }, - }, - }, - }); - - const key = trpc.key.create.useMutation({ - onSuccess() { - toast.success("Key Created", { - description: "Your Key has been created", - }); - revalidate(`/keys/${keyAuthId}`); - }, - onError(err) { - console.error(err); - toast.error(err.message || "An unknown error occurred"); - }, - }); - - async function onSubmit(values: z.infer) { - if ( - values.limitEnabled && - values.limit?.refill?.interval !== "none" && - !values.limit?.refill?.amount - ) { - form.setError("limit.refill.amount", { - type: "manual", - message: "Please enter a value if interval is selected", - }); - return; - } - - if (!values.expireEnabled) { - delete values.expires; - } - if (!values.metaEnabled) { - delete values.meta; - } - if (!values.limitEnabled) { - delete values.limit; - } - if (!values.ratelimitEnabled) { - delete values.ratelimit; - } - const refill = values.limit?.refill; - if (refill?.interval === "daily") { - refill.refillDay = undefined; - } - if (refill?.interval === "monthly" && !refill.refillDay) { - refill.refillDay = 1; - } - await key.mutateAsync({ - keyAuthId, - ...values, - meta: values.meta ? JSON.parse(values.meta) : undefined, - expires: values.expires?.getTime() ?? undefined, - ownerId: values.ownerId ?? undefined, - remaining: values.limit?.remaining ?? undefined, - refill: - refill?.amount && refill.interval !== "none" - ? { - amount: refill.amount, - refillDay: refill.interval === "daily" ? null : (refill.refillDay ?? 1), - } - : undefined, - enabled: true, - }); - - router.refresh(); - } - - const snippet = `curl -XPOST '${process.env.NEXT_PUBLIC_UNKEY_API_URL ?? "https://api.unkey.dev"}/v1/keys.verifyKey' \\ - -H 'Content-Type: application/json' \\ - -d '{ - "key": "${key.data?.key}", - "apiId": "${apiId}" - }'`; - - const split = key.data?.key.split("_") ?? []; - const maskedKey = - split.length >= 2 - ? `${split.at(0)}_${"*".repeat(split.at(1)?.length ?? 0)}` - : "*".repeat(split.at(0)?.length ?? 0); - const [showKey, setShowKey] = useState(false); - const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); - - const resetRateLimit = () => { - // set them to undefined so the form resets properly. - form.resetField("ratelimit.duration", undefined); - form.resetField("ratelimit.limit", undefined); - form.resetField("ratelimit", undefined); - }; - - const resetLimited = () => { - form.resetField("limit.refill.amount", undefined); - form.resetField("limit.refill.interval", undefined); - form.resetField("limit.refill", undefined); - form.resetField("limit.remaining", undefined); - form.resetField("limit", undefined); - }; - - // biome-ignore lint/correctness/useExhaustiveDependencies: reset is only required on mount - useEffect(() => { - // React hook form + zod doesn't play nice with nested objects, so we need to reset them on load. - resetRateLimit(); - resetLimited(); - }, []); - - return ( - <> - {key.data ? ( -
-
-
-

Your API Key

- -
{key.data.keyId}
- -
-
- - - This key is only shown once and can not be recovered - - Please pass it on to your user or store it somewhere safe. - - - -
{showKey ? key.data.key : maskedKey}
-
- - -
-
-
- -

Try verifying it:

- -
-
-                {showKeyInSnippet ? snippet : snippet.replace(key.data.key, maskedKey)}
-              
-
-
- - -
-
-
- - - - - - - -
-
- ) : ( - <> -
-
-

Create a new key

-
- -
- ( - - - Prefix{" "} - - Optional - - - - { - if (e.target.value === "") { - return; - } - }} - /> - - - Using a prefix can make it easier for your users to distinguish between - apis. Don't add a trailing underscore, we'll do that automatically:{" "} - {"_randombytes"} - - - - )} - /> - ( - - - Bytes{" "} - - Optional - - - - - - - How long the key will be. Longer keys are harder to guess and more - secure. - - - - )} - /> - ( - - - Owner{" "} - - Optional - - - - - - - This is the id of the user or workspace in your system, so you can - identify users from an API key. - - - - )} - /> - ( - - - Name{" "} - - Optional - - - - - - - To make it easier to identify a particular key, you can provide a name. - - - - )} - /> - ( - - - Environment{" "} - - Optional - - - - - - - Separate keys into different environments, for example{" "} - test and live. - - - - )} - /> -
- -
- - -
- Ratelimit - - ( - - Ratelimit - - { - field.onChange(e); - if (field.value === false) { - resetRateLimit(); - } - }} - /> - - - )} - /> -
- - {form.watch("ratelimitEnabled") ? ( - <> -
- ( - - Limit - - - - - The maximum number of requests in the given fixed window. - - - - )} - /> - - ( - - Refill Interval (milliseconds) - - - - - The time window in milliseconds for the rate limit to reset. - - - - )} - /> -
- {form.formState.errors.ratelimit && ( -

- {form.formState.errors.ratelimit.message} -

- )} - - ) : null} -
-
- - -
- Limited Use - ( - - Limited Use - - { - field.onChange(e); - if (field.value === false) { - resetLimited(); - } - }} - /> - - - )} - /> -
- - {form.watch("limitEnabled") ? ( - <> -

- How many times this key can be used before it gets disabled - automatically. -

-
- ( - - Number of uses - - - - - Enter the remaining amount of uses for this key. - - - - )} - /> - ( - - Refill Rate - - - Interval key will be refilled. - - - )} - /> - ( - - Number of uses per interval - - - - - Enter the number of uses to refill per interval. - - - - )} - /> - ( - - - On which day of the month should we refill the key? - - -
- -
-
- - Enter the day to refill monthly. - - -
- )} - /> - - How many requests may be performed in a given interval - -
- {form.formState.errors.ratelimit && ( -

- {form.formState.errors.ratelimit.message} -

- )} - - ) : null} -
-
- - -
- Expiration - - ( - - Expiration - - { - field.onChange(e); - if (field.value === false) { - resetLimited(); - } - }} - /> - - - )} - /> -
- - {form.watch("expireEnabled") ? ( - <> -

- {" "} - Automatically revoke this key after a certain date. -

-
- ( - - Expiry Date - - - - - This api key will automatically be revoked after the given - date. - - - - )} - /> -
- {form.formState.errors.ratelimit && ( -

- {form.formState.errors.ratelimit.message} -

- )} - - ) : null} -
-
- - -
- Metadata - - ( - - Metadata - - { - field.onChange(e); - if (field.value === false) { - resetLimited(); - } - }} - /> - - - )} - /> -
- - {form.watch("metaEnabled") ? ( - <> -

- Store json, or any other data you want to associate with this key. - Whenever you verify this key, we'll return the metadata to you. Enter - custom metadata as a JSON object.Format Json -

- -
- ( - - -