diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.constants.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.constants.tsx index 19a31cde62..fdcd7113ca 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.constants.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.constants.tsx @@ -3,10 +3,10 @@ import type { StepNamesFrom } from "@unkey/ui"; import type { SectionState } from "./types"; import { MetadataSetup } from "@/components/dashboard/metadata/metadata-setup"; +import { RatelimitSetup } from "@/components/dashboard/ratelimits/ratelimit-setup"; import { UsageSetup } from "./components/credits-setup"; import { ExpirationSetup } from "./components/expiration-setup"; import { GeneralSetup } from "./components/general-setup"; -import { RatelimitSetup } from "./components/ratelimit-setup"; export const UNNAMED_KEY = "Unnamed Key" as const; export const SECTIONS = [ diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-ratelimits/index.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-ratelimits/index.tsx index b058a8c060..c46e3d7b97 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-ratelimits/index.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/edit-ratelimits/index.tsx @@ -1,16 +1,14 @@ -import { RatelimitSetup } from "@/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/components/ratelimit-setup"; -import { - type RatelimitFormValues, - ratelimitSchema, -} from "@/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema"; +import { RatelimitSetup } from "@/components/dashboard/ratelimits/ratelimit-setup"; import type { ActionComponentProps } from "@/components/logs/table-action.popover"; +import { useEditRatelimits } from "@/hooks/use-edit-ratelimits"; import { usePersistedForm } from "@/hooks/use-persisted-form"; +import type { RatelimitFormValues } from "@/lib/schemas/ratelimit"; +import { ratelimitSchema } from "@/lib/schemas/ratelimit"; import type { KeyDetails } from "@/lib/trpc/routers/api/keys/query-api-keys/schema"; import { zodResolver } from "@hookform/resolvers/zod"; import { Button, DialogContainer } from "@unkey/ui"; import { useEffect } from "react"; import { FormProvider } from "react-hook-form"; -import { useEditRatelimits } from "../hooks/use-edit-ratelimits"; import { KeyInfo } from "../key-info"; import { getKeyRatelimitsDefaults } from "./utils"; @@ -49,7 +47,7 @@ export const EditRatelimits = ({ keyDetails, isOpen, onClose }: EditRatelimitsPr } }, [isOpen, loadSavedValues]); - const key = useEditRatelimits(() => { + const key = useEditRatelimits("key", () => { reset(getKeyRatelimitsDefaults(keyDetails)); clearPersistedData(); onClose(); @@ -59,10 +57,7 @@ export const EditRatelimits = ({ keyDetails, isOpen, onClose }: EditRatelimitsPr try { await key.mutateAsync({ keyId: keyDetails.id, - ratelimit: { - enabled: data.ratelimit.enabled, - data: data.ratelimit.data, - }, + ratelimit: data.ratelimit, }); } catch { // `useEditRatelimits` already shows a toast, but we still need to diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/create-identity-dialog.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/create-identity-dialog.tsx deleted file mode 100644 index 25738f0d20..0000000000 --- a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/create-identity-dialog.tsx +++ /dev/null @@ -1,130 +0,0 @@ -"use client"; - -import { MetadataSetup } from "@/components/dashboard/metadata/metadata-setup"; -import { NavbarActionButton } from "@/components/navigation/action-button"; -import { metadataSchema } from "@/lib/schemas/metadata"; -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Plus } from "@unkey/icons"; -import { Button, DialogContainer, FormInput, toast } from "@unkey/ui"; -import { useState } from "react"; -import { FormProvider, useForm } from "react-hook-form"; -import { z } from "zod"; - -const formSchema = z - .object({ - externalId: z - .string() - .transform((s) => s.trim()) - .refine((trimmed) => trimmed.length >= 3, "External ID must be at least 3 characters") - .refine((trimmed) => trimmed.length <= 255, "External ID must be 255 characters or fewer") - .refine((trimmed) => trimmed !== "", "External ID cannot be only whitespace"), - }) - .merge(metadataSchema); - -type FormValues = z.infer; - -export function CreateIdentityDialog() { - const [open, setOpen] = useState(false); - const utils = trpc.useUtils(); - - const methods = useForm({ - resolver: zodResolver(formSchema), - mode: "onChange", - defaultValues: { - externalId: "", - metadata: { - enabled: false, - }, - }, - }); - - const { - register, - handleSubmit, - setError, - formState: { errors, isValid }, - reset, - } = methods; - - const createIdentity = trpc.identity.create.useMutation({ - onSuccess: (data) => { - toast.success("Identity created successfully", { - description: `Identity "${data.externalId}" has been created.`, - }); - // Invalidate queries to refetch the list - utils.identity.query.invalidate(); - setOpen(false); - reset(); - }, - onError: (error) => { - if (error.data?.code === "CONFLICT") { - setError("externalId", { - message: "An identity with this external ID already exists", - }); - } else { - toast.error("Failed to create identity", { - description: error.message || "An unexpected error occurred", - }); - } - }, - }); - - const onSubmit = (data: FormValues) => { - const meta = - data.metadata?.enabled && data.metadata.data ? JSON.parse(data.metadata.data) : null; - createIdentity.mutate({ - externalId: data.externalId, - meta, - }); - }; - - return ( - <> - setOpen(true)}> - - Create Identity - - - - -
- Create a new identity to associate with keys and rate limits -
- - } - > - -
- - - - -
-
- - ); -} diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity-dialog.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity-dialog.tsx new file mode 100644 index 0000000000..06a2a380f1 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity-dialog.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { NavbarActionButton } from "@/components/navigation/action-button"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus } from "@unkey/icons"; +import { + Button, + NavigableDialogBody, + NavigableDialogContent, + NavigableDialogFooter, + NavigableDialogHeader, + NavigableDialogNav, + NavigableDialogRoot, + toast, +} from "@unkey/ui"; +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { SECTIONS } from "./create-identity.constants"; +import { type FormValues, formSchema, getDefaultValues } from "./create-identity.schema"; + +export function CreateIdentityDialog() { + const [open, setOpen] = useState(false); + const utils = trpc.useUtils(); + + const methods = useForm({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: getDefaultValues(), + }); + + const { + handleSubmit, + setError, + formState: { isValid }, + reset, + } = methods; + + const createIdentity = trpc.identity.create.useMutation({ + onSuccess: (data) => { + toast.success("Identity created successfully", { + description: `Identity "${data.externalId}" has been created.`, + }); + // Invalidate queries to refetch the list + utils.identity.query.invalidate(); + setOpen(false); + reset(getDefaultValues()); + }, + onError: (error) => { + if (error.data?.code === "CONFLICT") { + setError("externalId", { + message: "An identity with this external ID already exists", + }); + } else { + toast.error("Failed to create identity", { + description: error.message || "An unexpected error occurred", + }); + } + }, + }); + + const onSubmit = (data: FormValues) => { + const meta = + data.metadata?.enabled && data.metadata.data ? JSON.parse(data.metadata.data) : null; + const ratelimits = + data.ratelimit?.enabled && data.ratelimit.data ? data.ratelimit.data : undefined; + createIdentity.mutate({ + externalId: data.externalId, + meta, + ratelimits, + }); + }; + + return ( + <> + setOpen(true)}> + + Create Identity + + + +
+ + + + ({ + id: section.id, + label: section.label, + icon: section.icon, + }))} + initialSelectedId="general" + /> + ({ + id: section.id, + content: section.content(), + }))} + /> + + +
+
+ +
+ Create an identity to group keys and manage permissions +
+
+
+
+
+
+
+ + ); +} diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity.constants.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity.constants.tsx new file mode 100644 index 0000000000..929d40946e --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity.constants.tsx @@ -0,0 +1,28 @@ +import { MetadataSetup } from "@/components/dashboard/metadata/metadata-setup"; +import { RatelimitSetup } from "@/components/dashboard/ratelimits/ratelimit-setup"; +import { Code, Fingerprint, Gauge } from "@unkey/icons"; +import type { StepNamesFrom } from "@unkey/ui"; +import { GeneralSetup } from "./general-setup"; + +export const SECTIONS = [ + { + id: "general", + label: "General Setup", + icon: Fingerprint, + content: () => , + }, + { + id: "ratelimit", + label: "Ratelimit", + icon: Gauge, + content: () => , + }, + { + id: "metadata", + label: "Metadata", + icon: Code, + content: () => , + }, +] as const; + +export type DialogSectionName = StepNamesFrom; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity.schema.ts b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity.schema.ts new file mode 100644 index 0000000000..f511a7e08d --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/create-identity.schema.ts @@ -0,0 +1,35 @@ +import { metadataSchema } from "@/lib/schemas/metadata"; +import { ratelimitSchema } from "@/lib/schemas/ratelimit"; +import { z } from "zod"; + +export const formSchema = z + .object({ + externalId: z + .string() + .transform((s) => s.trim()) + .refine((trimmed) => trimmed.length >= 3, "External ID must be at least 3 characters") + .refine((trimmed) => trimmed.length <= 255, "External ID must be 255 characters or fewer") + .refine((trimmed) => trimmed !== "", "External ID cannot be only whitespace"), + }) + .merge(metadataSchema) + .merge(ratelimitSchema); + +export type FormValues = z.infer; + +export const getDefaultValues = (): FormValues => ({ + externalId: "", + metadata: { + enabled: false, + }, + ratelimit: { + enabled: false, + data: [ + { + name: "default", + limit: 10, + refillInterval: 1000, + autoApply: true, + }, + ], + }, +}); diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/edit-ratelimit-dialog.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/edit-ratelimit-dialog.tsx new file mode 100644 index 0000000000..56aa656821 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/edit-ratelimit-dialog.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { RatelimitSetup } from "@/components/dashboard/ratelimits/ratelimit-setup"; +import { useEditRatelimits } from "@/hooks/use-edit-ratelimits"; +import type { RatelimitFormValues } from "@/lib/schemas/ratelimit"; +import { ratelimitSchema } from "@/lib/schemas/ratelimit"; +import type { IdentityResponseSchema } from "@/lib/trpc/routers/identity/query"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Fingerprint } from "@unkey/icons"; +import { Button, DialogContainer, InfoTooltip } from "@unkey/ui"; +import { type FC, useCallback, useEffect, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import type { z } from "zod"; + +type Identity = z.infer; + +interface EditRatelimitDialogProps { + identity: Identity; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const getIdentityRatelimitsDefaults = (identity: Identity): RatelimitFormValues => { + const hasRatelimits = identity.ratelimits && identity.ratelimits.length > 0; + const defaultRatelimits = hasRatelimits + ? identity.ratelimits.map((rl) => ({ + id: rl.id, + name: rl.name, + limit: rl.limit, + refillInterval: rl.duration, + autoApply: rl.autoApply, + })) + : [ + { + name: "Default", + limit: 10, + refillInterval: 1000, + autoApply: false, + }, + ]; + + return { + ratelimit: { + enabled: hasRatelimits ? (true as const) : (false as const), + data: defaultRatelimits, + }, + } as RatelimitFormValues; +}; + +export const EditRatelimitDialog: FC = ({ + identity, + open, + onOpenChange, +}) => { + const [isSubmitting, setIsSubmitting] = useState(false); + + const getDefaultValues = useCallback(() => { + return getIdentityRatelimitsDefaults(identity); + }, [identity]); + + const methods = useForm({ + resolver: zodResolver(ratelimitSchema), + defaultValues: getDefaultValues(), + }); + + // Reset form when dialog opens + useEffect(() => { + if (open) { + methods.reset(getDefaultValues()); + } + }, [open, getDefaultValues, methods]); + + const updateRatelimit = useEditRatelimits("identity", () => { + onOpenChange(false); + }); + + const onSubmit = methods.handleSubmit(async (data) => { + setIsSubmitting(true); + try { + await updateRatelimit.mutateAsync({ + identityId: identity.id, + ratelimit: data.ratelimit, + }); + } catch { + // `useEditRatelimits` already shows a toast, but we still need to + // prevent unhandled rejection noise in the console. + } finally { + setIsSubmitting(false); + } + }); + + return ( + +
+ + +
Changes will be applied immediately
+ + } + > + {/* Scrollable body container */} +
+
+
+ +
+
+
{identity.id}
+ +
+ {identity.externalId} +
+
+
+
+ +
+
+
+
+ +
+
+ + + + ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/general-setup.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/general-setup.tsx new file mode 100644 index 0000000000..c6d7a3a552 --- /dev/null +++ b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/dialogs/general-setup.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { FormInput } from "@unkey/ui"; +import { useFormContext } from "react-hook-form"; +import type { FormValues } from "./create-identity.schema"; + +export const GeneralSetup = () => { + const { + register, + formState: { errors }, + } = useFormContext(); + + return ( +
+ +
+ ); +}; diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/table/identity-table-actions.tsx b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/table/identity-table-actions.tsx index d0e7a4517c..41cf94311f 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/table/identity-table-actions.tsx +++ b/apps/dashboard/app/(app)/[workspaceSlug]/identities/_components/table/identity-table-actions.tsx @@ -2,19 +2,29 @@ import { type MenuItem, TableActionPopover } from "@/components/logs/table-action.popover"; import type { IdentityResponseSchema } from "@/lib/trpc/routers/identity/query"; -import { Clone, Code } from "@unkey/icons"; +import { Clone, Code, Gauge } from "@unkey/icons"; import { toast } from "@unkey/ui"; import { useMemo, useState } from "react"; import type { z } from "zod"; +import { EditRatelimitDialog } from "../dialogs/edit-ratelimit-dialog"; import { EditMetadataDialog } from "./edit-metadata-dialog"; type Identity = z.infer; export const IdentityTableActions = ({ identity }: { identity: Identity }) => { const [isEditMetadataOpen, setIsEditMetadataOpen] = useState(false); + const [isEditRatelimitOpen, setIsEditRatelimitOpen] = useState(false); const menuItems: MenuItem[] = useMemo( () => [ + { + id: "edit-ratelimit", + label: "Edit ratelimit...", + icon: , + onClick: () => { + setIsEditRatelimitOpen(true); + }, + }, { id: "edit-metadata", label: "Edit metadata...", @@ -63,6 +73,11 @@ export const IdentityTableActions = ({ identity }: { identity: Identity }) => { return ( <> + { const { register, @@ -18,12 +20,28 @@ export const RatelimitSetup = ({ control, setValue, trigger, - } = useFormContext(); + } = useFormContext(); + + // Helper to safely access error messages from conditional schema + const getFieldError = (index: number, field: keyof RatelimitItem): string | undefined => { + const data = errors.ratelimit?.data; + if (!data || !Array.isArray(data)) { + return undefined; + } + const fieldError = data[index]; + if (!fieldError || typeof fieldError !== "object") { + return undefined; + } + const error = fieldError[field]; + if (!error || typeof error !== "object") { + return undefined; + } + return "message" in error ? String(error.message) : undefined; + }; - // Note: We're using the explicitly defined type from the schema file const { fields, append, remove } = useFieldArray({ control, - name: "ratelimit.data" as const, + name: "ratelimit.data", }); const ratelimitEnabled = useWatch({ @@ -49,21 +67,24 @@ export const RatelimitSetup = ({ }; const handleAddRatelimit = () => { - const newItem: RatelimitItem = { + append({ name: "", limit: 10, refillInterval: 1000, autoApply: false, - }; - append(newItem); + }); }; + const description = + entityType === "key" + ? "Turn on to restrict how frequently this key can be used. Requests beyond the limit will be blocked." + : "Turn on to restrict how frequently this identity can be used. Requests beyond the limit will be blocked."; + return (
{!overrideEnabled && ( } checked={ratelimitEnabled} @@ -103,7 +124,7 @@ export const RatelimitSetup = ({ type="text" label="Name" description="A name to identify this rate limit rule" - error={errors.ratelimit?.data?.[index]?.name?.message} + error={getFieldError(index, "name")} disabled={!ratelimitEnabled} readOnly={!ratelimitEnabled} {...register(`ratelimit.data.${index}.name`)} @@ -137,7 +158,7 @@ export const RatelimitSetup = ({ type="number" label="Limit" description="Maximum requests in the given time window" - error={errors.ratelimit?.data?.[index]?.limit?.message} + error={getFieldError(index, "limit")} disabled={!ratelimitEnabled} readOnly={!ratelimitEnabled} {...register(`ratelimit.data.${index}.limit`)} @@ -150,7 +171,7 @@ export const RatelimitSetup = ({ inputMode="numeric" type="number" description="Time window in milliseconds" - error={errors.ratelimit?.data?.[index]?.refillInterval?.message} + error={getFieldError(index, "refillInterval")} disabled={!ratelimitEnabled} readOnly={!ratelimitEnabled} {...register(`ratelimit.data.${index}.refillInterval`)} @@ -179,7 +200,7 @@ export const RatelimitSetup = ({ .

} - error={errors.ratelimit?.data?.[index]?.autoApply?.message} + error={getFieldError(index, "autoApply")} disabled={!ratelimitEnabled} checked={field.value} onCheckedChange={field.onChange} diff --git a/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts b/apps/dashboard/hooks/use-edit-ratelimits.ts similarity index 52% rename from apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts rename to apps/dashboard/hooks/use-edit-ratelimits.ts index c68f31858b..6472a984b0 100644 --- a/apps/dashboard/app/(app)/[workspaceSlug]/apis/[apiId]/keys/[keyAuthId]/_components/components/table/components/actions/components/hooks/use-edit-ratelimits.ts +++ b/apps/dashboard/hooks/use-edit-ratelimits.ts @@ -2,10 +2,20 @@ import { trpc } from "@/lib/trpc/client"; import { toast } from "@unkey/ui"; import { formatDuration, intervalToDuration } from "date-fns"; -export const useEditRatelimits = (onSuccess?: () => void) => { +type EntityType = "key" | "identity"; + +export function useEditRatelimits( + entityType: "key", + onSuccess?: () => void, +): ReturnType; +export function useEditRatelimits( + entityType: "identity", + onSuccess?: () => void, +): ReturnType; +export function useEditRatelimits(entityType: EntityType, onSuccess?: () => void) { const trpcUtils = trpc.useUtils(); - const updateKeyRemaining = trpc.key.update.ratelimit.useMutation({ + const updateKeyRatelimit = trpc.key.update.ratelimit.useMutation({ onSuccess(data, variables) { let description = ""; @@ -13,13 +23,11 @@ export const useEditRatelimits = (onSuccess?: () => void) => { const rulesCount = variables.ratelimit.data.length; if (rulesCount === 1) { - // If there's just one rule, show its limit directly const rule = variables.ratelimit.data[0]; description = `Your key ${data.keyId} has been updated with a limit of ${ rule.limit } requests per ${formatInterval(rule.refillInterval)}`; } else { - // If there are multiple rules, show the count description = `Your key ${data.keyId} has been updated with ${rulesCount} rate limit rules`; } } else { @@ -58,8 +66,59 @@ export const useEditRatelimits = (onSuccess?: () => void) => { }, }); - return updateKeyRemaining; -}; + const updateIdentityRatelimit = trpc.identity.update.ratelimit.useMutation({ + onSuccess(data, variables) { + let description = ""; + + if (variables.ratelimit?.enabled) { + const rulesCount = variables.ratelimit.data.length; + + if (rulesCount === 1) { + const rule = variables.ratelimit.data[0]; + description = `Identity ${data.identityId} has been updated with a limit of ${ + rule.limit + } requests per ${formatInterval(rule.refillInterval)}`; + } else { + description = `Identity ${data.identityId} has been updated with ${rulesCount} rate limit rules`; + } + } else { + description = `Identity ${data.identityId} has been updated with rate limits disabled`; + } + + toast.success("Identity Ratelimits Updated", { + description, + duration: 5000, + }); + + trpcUtils.identity.query.invalidate(); + if (onSuccess) { + onSuccess(); + } + }, + onError(err) { + if (err.data?.code === "NOT_FOUND") { + toast.error("Identity Update Failed", { + description: "Unable to find the identity. Please refresh and try again.", + }); + } else if (err.data?.code === "INTERNAL_SERVER_ERROR") { + toast.error("Server Error", { + description: + "We encountered an issue while updating your identity. Please try again later or contact support at support.unkey.dev", + }); + } else { + toast.error("Failed to Update Identity Limits", { + description: err.message || "An unexpected error occurred. Please try again later.", + action: { + label: "Contact Support", + onClick: () => window.open("mailto:support@unkey.dev", "_blank"), + }, + }); + } + }, + }); + + return entityType === "key" ? updateKeyRatelimit : updateIdentityRatelimit; +} const formatInterval = (milliseconds: number): string => { if (milliseconds < 1000) { diff --git a/apps/dashboard/lib/schemas/ratelimit.ts b/apps/dashboard/lib/schemas/ratelimit.ts new file mode 100644 index 0000000000..ed7fe95982 --- /dev/null +++ b/apps/dashboard/lib/schemas/ratelimit.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { createConditionalSchema } from "./metadata"; + +export const ratelimitItemSchema = z.object({ + id: z.string().nullish(), // Will be used only for updating case + name: z + .string() + .min(3, { message: "Name is required and should have at least 3 characters" }) + .max(256, { message: "Name cannot exceed 256 characters" }), + refillInterval: z.coerce + .number({ + errorMap: (issue, { defaultError }) => ({ + message: issue.code === "invalid_type" ? "Duration must be a valid number" : defaultError, + }), + }) + .min(1000, { message: "Refill interval must be at least 1 second (1000ms)" }), + limit: z.coerce + .number({ + errorMap: (issue, { defaultError }) => ({ + message: issue.code === "invalid_type" ? "Limit must be a valid number" : defaultError, + }), + }) + .positive({ message: "Limit must be greater than 0" }), + autoApply: z.boolean(), +}); + +export const ratelimitValidationSchema = z.object({ + enabled: z.literal(true), + data: z + .array(ratelimitItemSchema) + .min(1, { message: "At least one rate limit is required" }) + .superRefine((items, ctx) => { + const seenNames = new Set(); + for (let i = 0; i < items.length; i++) { + const name = items[i].name; + if (seenNames.has(name)) { + ctx.addIssue({ + code: "custom", + message: "Ratelimit name must be unique", + path: ["data", i, "name"], + }); + } + seenNames.add(name); + } + }), +}); + +export const ratelimitSchema = z.object({ + ratelimit: createConditionalSchema("enabled", ratelimitValidationSchema).default({ + enabled: false, + data: [ + { + name: "default", + limit: 10, + refillInterval: 1000, + autoApply: true, + }, + ], + }), +}); + +// Type exports +export type RatelimitItem = z.infer; +export type RatelimitFormValues = z.infer; + +// Manual type for form context to avoid conditional schema union issues +export type RatelimitFormContextValues = { + ratelimit: { + enabled: boolean; + data: RatelimitItem[]; + }; +}; diff --git a/apps/dashboard/lib/trpc/routers/identity/create.ts b/apps/dashboard/lib/trpc/routers/identity/create.ts index e298837b67..f2ba74260b 100644 --- a/apps/dashboard/lib/trpc/routers/identity/create.ts +++ b/apps/dashboard/lib/trpc/routers/identity/create.ts @@ -1,5 +1,6 @@ import { insertAuditLogs } from "@/lib/audit"; import { type Identity, db, schema } from "@/lib/db"; +import { ratelimitItemSchema } from "@/lib/schemas/ratelimit"; import { TRPCError } from "@trpc/server"; import { newId } from "@unkey/id"; import { z } from "zod"; @@ -13,6 +14,7 @@ export const createIdentityInputSchema = z.object({ .trim() .refine((id) => !/^\s+$/.test(id), "External ID cannot be only whitespace"), meta: z.record(z.unknown()).nullable(), + ratelimits: z.array(ratelimitItemSchema).optional(), }); export const createIdentity = t.procedure @@ -68,6 +70,24 @@ export const createIdentity = t.procedure }; await tx.insert(schema.identities).values(payload); + + // Insert ratelimits if provided + if (input.ratelimits && input.ratelimits.length > 0) { + const ratelimitValues = input.ratelimits.map((ratelimit) => ({ + id: newId("ratelimit"), + identityId: identityId, + duration: ratelimit.refillInterval, + limit: ratelimit.limit, + name: ratelimit.name, + autoApply: ratelimit.autoApply, + workspaceId: ctx.workspace.id, + createdAt: Date.now(), + updatedAt: null, + })); + + await tx.insert(schema.ratelimits).values(ratelimitValues); + } + await insertAuditLogs(tx, { workspaceId: ctx.workspace.id, actor: { type: "user", id: ctx.user.id }, @@ -80,6 +100,8 @@ export const createIdentity = t.procedure name: input.externalId, meta: { hasMeta: Boolean(input.meta), + hasRatelimits: Boolean(input.ratelimits && input.ratelimits.length > 0), + ratelimitCount: input.ratelimits?.length || 0, }, }, { diff --git a/apps/dashboard/lib/trpc/routers/identity/query.ts b/apps/dashboard/lib/trpc/routers/identity/query.ts index fc9dc2243f..31b7c0592f 100644 --- a/apps/dashboard/lib/trpc/routers/identity/query.ts +++ b/apps/dashboard/lib/trpc/routers/identity/query.ts @@ -19,7 +19,15 @@ export const IdentityResponseSchema = z.object({ createdAt: z.number(), updatedAt: z.number().nullable(), keys: z.array(z.object({ id: z.string() })), - ratelimits: z.array(z.object({ id: z.string() })), + ratelimits: z.array( + z.object({ + id: z.string(), + name: z.string(), + limit: z.number(), + duration: z.number(), + autoApply: z.boolean(), + }), + ), }); const IdentitiesResponse = z.object({ @@ -105,6 +113,10 @@ export const queryIdentities = t.procedure ratelimits: { columns: { id: true, + name: true, + limit: true, + duration: true, + autoApply: true, }, }, }, diff --git a/apps/dashboard/lib/trpc/routers/identity/search.ts b/apps/dashboard/lib/trpc/routers/identity/search.ts index ac2fcd668e..2c60c71cbf 100644 --- a/apps/dashboard/lib/trpc/routers/identity/search.ts +++ b/apps/dashboard/lib/trpc/routers/identity/search.ts @@ -47,6 +47,10 @@ export const searchIdentities = t.procedure ratelimits: { columns: { id: true, + name: true, + limit: true, + duration: true, + autoApply: true, }, }, }, diff --git a/apps/dashboard/lib/trpc/routers/identity/updateRatelimit.ts b/apps/dashboard/lib/trpc/routers/identity/updateRatelimit.ts new file mode 100644 index 0000000000..7911a56025 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/identity/updateRatelimit.ts @@ -0,0 +1,174 @@ +import { type UnkeyAuditLog, insertAuditLogs } from "@/lib/audit"; +import { type Identity, db, eq, schema } from "@/lib/db"; +import { ratelimitSchema } from "@/lib/schemas/ratelimit"; +import { TRPCError } from "@trpc/server"; +import { newId } from "@unkey/id"; +import { z } from "zod"; +import { requireUser, requireWorkspace, t } from "../../trpc"; + +const baseRatelimitInputSchema = z.object({ + identityId: z.string(), +}); + +export const ratelimitInputSchema = ratelimitSchema.and(baseRatelimitInputSchema); + +type RatelimitInputSchema = z.infer; + +export const updateIdentityRatelimit = t.procedure + .use(requireUser) + .use(requireWorkspace) + .input(ratelimitInputSchema) + .mutation(async ({ input, ctx }) => { + const identity = await db.query.identities + .findFirst({ + where: (table, { eq, and }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.id, input.identityId)), + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to update ratelimits on this identity. Please try again or contact support@unkey.dev", + }); + }); + if (!identity) { + throw new TRPCError({ + message: + "We are unable to find the correct identity. Please try again or contact support@unkey.dev.", + code: "NOT_FOUND", + }); + } + + return updateRatelimitV2(input, identity, { + audit: ctx.audit, + userId: ctx.user.id, + workspaceId: ctx.workspace.id, + }); + }); + +const updateRatelimitV2 = async ( + input: RatelimitInputSchema, + identity: Identity, + ctx: { + workspaceId: string; + userId: string; + audit: { + location: string; + userAgent: string | undefined; + }; + }, +) => { + try { + await db.transaction(async (tx) => { + if (input.ratelimit.enabled && input.ratelimit.data.length > 0) { + // First, fetch existing ratelimits for this identity + const existingRatelimits = await tx + .select() + .from(schema.ratelimits) + .where(eq(schema.ratelimits.identityId, input.identityId)); + + const inputRatelimitIds = new Set( + input.ratelimit.data.filter((r) => r.id).map((r) => r.id), + ); + + // Delete ratelimits that exist in DB but are not in the input (they were removed) + for (const existing of existingRatelimits) { + if (!inputRatelimitIds.has(existing.id)) { + await tx.delete(schema.ratelimits).where(eq(schema.ratelimits.id, existing.id)); + } + } + + // Update or insert each ratelimit sequentially + for (const ratelimit of input.ratelimit.data) { + if (ratelimit.id) { + // Update existing + await tx + .update(schema.ratelimits) + .set({ + duration: ratelimit.refillInterval, + limit: ratelimit.limit, + name: ratelimit.name, + updatedAt: Date.now(), + autoApply: ratelimit.autoApply, + }) + .where(eq(schema.ratelimits.id, ratelimit.id)); + } else { + // Create new + await tx.insert(schema.ratelimits).values({ + id: newId("ratelimit"), + identityId: input.identityId, + duration: ratelimit.refillInterval, + limit: ratelimit.limit, + name: ratelimit.name, + autoApply: ratelimit.autoApply, + workspaceId: ctx.workspaceId, + createdAt: Date.now(), + updatedAt: null, + }); + } + } + } else if (input.ratelimit.enabled) { + // Rate limiting is enabled but no rules provided (edge case). Should not happen in v2. + throw new Error("Rate limiting is enabled but no rules were provided"); + } else { + // If rate limiting is disabled, remove all rate limit rules for this identity + await tx + .delete(schema.ratelimits) + .where(eq(schema.ratelimits.identityId, input.identityId)); + } + const description = input.ratelimit.enabled + ? `Updated rate limits for identity ${identity.id} (${input.ratelimit.data.length} rules)` + : `Disabled rate limits for identity ${identity.id}`; + + const ratelimitMeta = input.ratelimit.enabled + ? { + "ratelimit.enabled": true, + "ratelimit.rules_count": input.ratelimit.data.length, + ...input.ratelimit.data.reduce((acc, rule, index) => { + return { + // biome-ignore lint/performance/noAccumulatingSpread: + ...acc, + [`ratelimit.rule.${index}.name`]: rule.name, + [`ratelimit.rule.${index}.limit`]: rule.limit, + [`ratelimit.rule.${index}.interval`]: rule.refillInterval, + }; + }, {}), + } + : { + "ratelimit.enabled": false, + }; + + const resources: UnkeyAuditLog["resources"] = [ + { + type: "ratelimit", + id: identity.id, + meta: ratelimitMeta, + }, + ]; + + await insertAuditLogs(tx, { + workspaceId: ctx.workspaceId, + actor: { + type: "user", + id: ctx.userId, + }, + event: "ratelimit.update", + description, + resources, + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + }); + } catch (err) { + console.error(err); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to update ratelimit on this identity. Please try again or contact support@unkey.dev", + }); + } + + return { identityId: identity.id }; +}; diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index d60cd216c4..de08826888 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -62,6 +62,7 @@ import { queryIdentities } from "./identity/query"; import { searchIdentities } from "./identity/search"; import { searchIdentitiesWithRelations } from "./identity/searchWithRelations"; import { updateIdentityMetadata } from "./identity/updateMetadata"; +import { updateIdentityRatelimit } from "./identity/updateRatelimit"; import { createKey } from "./key/create"; import { createRootKey } from "./key/createRootKey"; import { deleteKeys } from "./key/delete"; @@ -347,6 +348,7 @@ export const router = t.router({ latestVerification: identityLastVerificationTime, update: t.router({ metadata: updateIdentityMetadata, + ratelimit: updateIdentityRatelimit, }), }), deploy: t.router({ diff --git a/apps/dashboard/lib/trpc/routers/key/updateRatelimit.ts b/apps/dashboard/lib/trpc/routers/key/updateRatelimit.ts index 82de113160..03dd543b0e 100644 --- a/apps/dashboard/lib/trpc/routers/key/updateRatelimit.ts +++ b/apps/dashboard/lib/trpc/routers/key/updateRatelimit.ts @@ -1,6 +1,6 @@ -import { ratelimitSchema } from "@/app/(app)/[workspaceSlug]/apis/[apiId]/_components/create-key/create-key.schema"; import { type UnkeyAuditLog, insertAuditLogs } from "@/lib/audit"; import { type Key, db, eq, schema } from "@/lib/db"; +import { ratelimitSchema } from "@/lib/schemas/ratelimit"; import { TRPCError } from "@trpc/server"; import { newId } from "@unkey/id"; import { z } from "zod";