diff --git a/apps/api/src/pkg/testutil/harness.ts b/apps/api/src/pkg/testutil/harness.ts index a9c8288b0b..71d8e5a576 100644 --- a/apps/api/src/pkg/testutil/harness.ts +++ b/apps/api/src/pkg/testutil/harness.ts @@ -287,6 +287,8 @@ export abstract class Harness { createdAtM: Date.now(), updatedAtM: null, deletedAtM: null, + defaultPrefix: null, + defaultBytes: null, }; const userKeyAuth: KeyAuth = { id: newId("test"), @@ -297,6 +299,8 @@ export abstract class Harness { createdAtM: Date.now(), updatedAtM: null, deletedAtM: null, + defaultPrefix: null, + defaultBytes: null, }; const unkeyApi: Api = { diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx index 24c1dcfe61..39a8f55211 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-enabled.tsx @@ -56,7 +56,7 @@ export const UpdateKeyEnabled: React.FC = ({ apiKey }) => { }); async function onSubmit(values: z.infer) { - updateEnabled.mutateAsync(values); + await updateEnabled.mutateAsync(values); } return ( diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-expiration.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-expiration.tsx index 6e4a51f63e..23bb38c8ff 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-expiration.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-expiration.tsx @@ -79,7 +79,7 @@ export const UpdateKeyExpiration: React.FC = ({ apiKey }) => { }); async function onSubmit(values: z.infer) { - changeExpiration.mutateAsync(values); + await changeExpiration.mutateAsync(values); } return ( diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-name.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-name.tsx index 7981ec5b86..188e5b72ad 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-name.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-name.tsx @@ -61,7 +61,7 @@ export const UpdateKeyName: React.FC = ({ apiKey }) => { }); async function onSubmit(values: z.infer) { - updateName.mutateAsync(values); + await updateName.mutateAsync(values); } return (
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-ratelimit.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-ratelimit.tsx index 720816f161..a74b2cad4e 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-ratelimit.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-ratelimit.tsx @@ -94,7 +94,7 @@ export const UpdateKeyRatelimit: React.FC = ({ apiKey }) => { }, }); async function onSubmit(values: z.infer) { - updateRatelimit.mutateAsync(values); + await updateRatelimit.mutateAsync(values); } return ( diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx index 5030b63087..0a9d978f65 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/update-key-remaining.tsx @@ -117,7 +117,7 @@ export const UpdateKeyRemaining: React.FC = ({ apiKey }) => { if (values.refill?.interval === "none") { delete values.refill; } - updateRemaining.mutateAsync(values); + await updateRemaining.mutateAsync(values); } return ( 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 index e8102431b0..b63ffd734e 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/client.tsx @@ -145,9 +145,11 @@ const formSchema = z.object({ type Props = { apiId: string; keyAuthId: string; + defaultBytes: number | null; + defaultPrefix: string | null; }; -export const CreateKey: React.FC = ({ apiId, keyAuthId }) => { +export const CreateKey: React.FC = ({ apiId, keyAuthId, defaultBytes, defaultPrefix }) => { const router = useRouter(); const form = useForm>({ @@ -158,7 +160,8 @@ export const CreateKey: React.FC = ({ apiId, keyAuthId }) => { shouldFocusError: true, delayError: 100, defaultValues: { - bytes: 16, + prefix: defaultPrefix || undefined, + bytes: defaultBytes || 16, expireEnabled: false, limitEnabled: false, metaEnabled: false, diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/page.tsx index 51c8eeaeec..78dbfc00f7 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/new/page.tsx @@ -23,5 +23,12 @@ export default async function CreateKeypage(props: { return notFound(); } - return ; + return ( + + ); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/default-bytes.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/default-bytes.tsx new file mode 100644 index 0000000000..adbd6b3fc4 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/default-bytes.tsx @@ -0,0 +1,109 @@ +"use client"; +import { Loading } from "@/components/dashboard/loading"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { FormField } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +const formSchema = z.object({ + keyAuthId: z.string(), + workspaceId: z.string(), + defaultBytes: z + .number() + .min(8, "Byte size needs to be at least 8") + .max(255, "Byte size cannot exceed 255") + .optional(), +}); + +type Props = { + keyAuth: { + id: string; + workspaceId: string; + defaultBytes: number | undefined | null; + }; +}; + +export const DefaultBytes: React.FC = ({ keyAuth }) => { + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + defaultBytes: keyAuth.defaultBytes ?? undefined, + keyAuthId: keyAuth.id, + workspaceId: keyAuth.workspaceId, + }, + }); + + const setDefaultBytes = trpc.api.setDefaultBytes.useMutation({ + onSuccess() { + toast.success("Default Byte length for this API is updated!"); + router.refresh(); + }, + onError(err) { + console.error(err); + toast.error(err.message); + }, + }); + + async function onSubmit(values: z.infer) { + if (values.defaultBytes === keyAuth.defaultBytes || !values.defaultBytes) { + return toast.error( + "Please provide a different byte-size than already existing one as default", + ); + } + await setDefaultBytes.mutateAsync(values); + } + + return ( + + + + Default Bytes + Set default Bytes for the keys under this API. + + +
+ + + + ( + field.onChange(Number(e.target.value))} + /> + )} + /> +
+
+ + + +
+ + ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/default-prefix.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/default-prefix.tsx new file mode 100644 index 0000000000..56ee285285 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/default-prefix.tsx @@ -0,0 +1,98 @@ +"use client"; +import { Loading } from "@/components/dashboard/loading"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { FormField } from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/toaster"; +import { trpc } from "@/lib/trpc/client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +const formSchema = z.object({ + keyAuthId: z.string(), + workspaceId: z.string(), + defaultPrefix: z.string(), +}); + +type Props = { + keyAuth: { + id: string; + workspaceId: string; + defaultPrefix: string | undefined | null; + }; +}; + +export const DefaultPrefix: React.FC = ({ keyAuth }) => { + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + defaultPrefix: keyAuth.defaultPrefix ?? undefined, + keyAuthId: keyAuth.id, + workspaceId: keyAuth.workspaceId, + }, + }); + + const setDefaultPrefix = trpc.api.setDefaultPrefix.useMutation({ + onSuccess() { + toast.success("Default prefix for this API is updated!"); + router.refresh(); + }, + onError(err) { + console.error(err); + toast.error(err.message); + }, + }); + async function onSubmit(values: z.infer) { + if (values.defaultPrefix.length > 8) { + return toast.error("Default prefix is too long, maximum length is 8 characters."); + } + if (values.defaultPrefix === keyAuth.defaultPrefix) { + return toast.error("Please provide a different prefix than already existing one as default"); + } + await setDefaultPrefix.mutateAsync(values); + } + + return ( +
+ + + Default Prefix + Set default prefix for the keys under this API. + + +
+ + + + } + /> +
+
+ + + +
+
+ ); +}; diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx index 949fefe05f..6faa740610 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx @@ -4,6 +4,8 @@ import { Code } from "@/components/ui/code"; import { getTenantId } from "@/lib/auth"; import { and, db, eq, isNull, schema, sql } from "@/lib/db"; import { notFound, redirect } from "next/navigation"; +import { DefaultBytes } from "./default-bytes"; +import { DefaultPrefix } from "./default-prefix"; import { DeleteApi } from "./delete-api"; import { DeleteProtection } from "./delete-protection"; import { UpdateApiName } from "./update-api-name"; @@ -41,10 +43,23 @@ export default async function SettingsPage(props: Props) { .from(schema.keys) .where(and(eq(schema.keys.keyAuthId, api.keyAuthId!), isNull(schema.keys.deletedAt))) .then((rows) => Number.parseInt(rows.at(0)?.count ?? "0")); + const keyAuth = await db.query.keyAuth.findFirst({ + where: (table, { eq, and, isNull }) => + and(eq(table.id, api.keyAuthId!), isNull(table.deletedAt)), + with: { + workspace: true, + api: true, + }, + }); + if (!keyAuth || keyAuth.workspace.tenantId !== tenantId) { + return notFound(); + } return (
+ + diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/update-api-name.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/update-api-name.tsx index bf3502aa0a..e5680471dd 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/settings/update-api-name.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/update-api-name.tsx @@ -56,7 +56,7 @@ export const UpdateApiName: React.FC = ({ api }) => { if (values.name === api.name || !values.name) { return toast.error("Please provide a valid name before saving."); } - updateName.mutateAsync(values); + await updateName.mutateAsync(values); } return ( diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/settings/update-namespace-name.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/settings/update-namespace-name.tsx index abbdddc3b1..b77f2a6af0 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/settings/update-namespace-name.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/settings/update-namespace-name.tsx @@ -52,7 +52,7 @@ export const UpdateNamespaceName: React.FC = ({ namespace }) => { if (values.name === namespace.name || !values.name) { return toast.error("Please provide a valid name before saving."); } - updateName.mutateAsync(values); + await updateName.mutateAsync(values); } return ( diff --git a/apps/dashboard/app/(app)/settings/general/update-workspace-name.tsx b/apps/dashboard/app/(app)/settings/general/update-workspace-name.tsx index a2a2b82f54..c22b46b6f1 100644 --- a/apps/dashboard/app/(app)/settings/general/update-workspace-name.tsx +++ b/apps/dashboard/app/(app)/settings/general/update-workspace-name.tsx @@ -48,7 +48,7 @@ export const UpdateWorkspaceName: React.FC = ({ workspace }) => { }); async function onSubmit(values: z.infer) { - updateName.mutateAsync(values); + await updateName.mutateAsync(values); } return ( diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/update-root-key-name.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/update-root-key-name.tsx index 976d408dc8..9ad1f0e22e 100644 --- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/update-root-key-name.tsx +++ b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/update-root-key-name.tsx @@ -59,7 +59,7 @@ export const UpdateRootKeyName: React.FC = ({ apiKey }) => { }); async function onSubmit(values: z.infer) { - updateName.mutateAsync(values); + await updateName.mutateAsync(values); } return (
diff --git a/apps/dashboard/lib/trpc/routers/api/setDefaultBytes.ts b/apps/dashboard/lib/trpc/routers/api/setDefaultBytes.ts new file mode 100644 index 0000000000..63d59fe8b6 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/api/setDefaultBytes.ts @@ -0,0 +1,93 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { insertAuditLogs } from "@/lib/audit"; +import { db, eq, schema } from "@/lib/db"; +import { ingestAuditLogsTinybird } from "@/lib/tinybird"; +import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; + +export const setDefaultApiBytes = rateLimitedProcedure(ratelimit.update) + .input( + z.object({ + defaultBytes: z + .number() + .min(8, "Byte size needs to be at least 8") + .max(255, "Byte size cannot exceed 255") + .optional(), + keyAuthId: z.string(), + workspaceId: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const keyAuth = await db.query.keyAuth + .findFirst({ + where: (table, { eq }) => eq(table.id, input.keyAuthId), + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to find the KeyAuth. Please contact support using support@unkey.dev.", + }); + }); + if (!keyAuth || keyAuth.workspaceId !== input.workspaceId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "We are unable to find the correct keyAuth. Please contact support using support@unkey.dev", + }); + } + await db.transaction(async (tx) => { + await tx + .update(schema.keyAuth) + .set({ + defaultBytes: input.defaultBytes, + }) + .where(eq(schema.keyAuth.id, input.keyAuthId)) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to update the API default bytes. Please contact support using support@unkey.dev.", + }); + }); + await insertAuditLogs(tx, { + workspaceId: keyAuth.workspaceId, + actor: { + type: "user", + id: ctx.user.id, + }, + event: "api.update", + description: `Changed ${keyAuth.workspaceId} default byte size for keys from ${keyAuth.defaultBytes} to ${input.defaultBytes}`, + resources: [ + { + type: "keyAuth", + id: keyAuth.id, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + }); + await ingestAuditLogsTinybird({ + workspaceId: keyAuth.workspaceId, + actor: { + type: "user", + id: ctx.user.id, + }, + event: "api.update", + description: `Changed ${keyAuth.id} default byte size for keys from ${keyAuth.defaultBytes} to ${input.defaultBytes}`, + resources: [ + { + type: "keyAuth", + id: keyAuth.id, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + }); diff --git a/apps/dashboard/lib/trpc/routers/api/setDefaultPrefix.ts b/apps/dashboard/lib/trpc/routers/api/setDefaultPrefix.ts new file mode 100644 index 0000000000..a9dfe02e6f --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/api/setDefaultPrefix.ts @@ -0,0 +1,89 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; + +import { insertAuditLogs } from "@/lib/audit"; +import { db, eq, schema } from "@/lib/db"; +import { ingestAuditLogsTinybird } from "@/lib/tinybird"; +import { rateLimitedProcedure, ratelimit } from "@/lib/trpc/ratelimitProcedure"; + +export const setDefaultApiPrefix = rateLimitedProcedure(ratelimit.update) + .input( + z.object({ + defaultPrefix: z.string().max(8, "Prefix can be a maximum of 8 characters"), + keyAuthId: z.string(), + workspaceId: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const keyAuth = await db.query.keyAuth + .findFirst({ + where: (table, { eq }) => eq(table.id, input.keyAuthId), + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to find KeyAuth. Please contact support using support@unkey.dev.", + }); + }); + if (!keyAuth || keyAuth.workspaceId !== input.workspaceId) { + throw new TRPCError({ + code: "NOT_FOUND", + message: + "We are unable to find the correct keyAuth. Please contact support using support@unkey.dev", + }); + } + await db.transaction(async (tx) => { + await tx + .update(schema.keyAuth) + .set({ + defaultPrefix: input.defaultPrefix, + }) + .where(eq(schema.keyAuth.id, input.keyAuthId)) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We were unable to update the API default prefix. Please contact support using support@unkey.dev.", + }); + }); + await insertAuditLogs(tx, { + workspaceId: keyAuth.workspaceId, + actor: { + type: "user", + id: ctx.user.id, + }, + event: "api.update", + description: `Changed ${keyAuth.workspaceId} default prefix from ${keyAuth.defaultPrefix} to ${input.defaultPrefix}`, + resources: [ + { + type: "keyAuth", + id: keyAuth.id, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + }); + await ingestAuditLogsTinybird({ + workspaceId: keyAuth.workspaceId, + actor: { + type: "user", + id: ctx.user.id, + }, + event: "api.update", + description: `Changed ${keyAuth.id} default prefix from ${keyAuth.defaultPrefix}} to ${input.defaultPrefix}`, + resources: [ + { + type: "keyAuth", + id: keyAuth.id, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + }); diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index 7691902d8c..5e8fce109d 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -1,6 +1,8 @@ import { t } from "../trpc"; import { createApi } from "./api/create"; import { deleteApi } from "./api/delete"; +import { setDefaultApiBytes } from "./api/setDefaultBytes"; +import { setDefaultApiPrefix } from "./api/setDefaultPrefix"; import { updateAPIDeleteProtection } from "./api/updateDeleteProtection"; import { updateApiIpWhitelist } from "./api/updateIpWhitelist"; import { updateApiName } from "./api/updateName"; @@ -73,6 +75,8 @@ export const router = t.router({ create: createApi, delete: deleteApi, updateName: updateApiName, + setDefaultPrefix: setDefaultApiPrefix, + setDefaultBytes: setDefaultApiBytes, updateIpWhitelist: updateApiIpWhitelist, updateDeleteProtection: updateAPIDeleteProtection, }), diff --git a/internal/db/src/schema/keyAuth.ts b/internal/db/src/schema/keyAuth.ts index 7f05d1b0cb..4cc1bf2dcd 100644 --- a/internal/db/src/schema/keyAuth.ts +++ b/internal/db/src/schema/keyAuth.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm"; -import { boolean, datetime, mysqlTable, varchar } from "drizzle-orm/mysql-core"; +import { boolean, datetime, int, mysqlTable, varchar } from "drizzle-orm/mysql-core"; import { apis } from "./apis"; import { keys } from "./keys"; import { lifecycleDatesMigration } from "./util/lifecycle_dates"; @@ -16,6 +16,8 @@ export const keyAuth = mysqlTable("key_auth", { ...lifecycleDatesMigration, storeEncryptedKeys: boolean("store_encrypted_keys").notNull().default(false), + defaultPrefix: varchar("default_prefix", { length: 8 }), + defaultBytes: int("default_bytes").default(16), }); export const keyAuthRelations = relations(keyAuth, ({ one, many }) => ({