diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx
index f079901c73..711ef5bc9b 100644
--- a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/page.tsx
@@ -164,6 +164,7 @@ export default async function APIKeyDetailPage(props: {
>
Back to API Keys listing
+
res.data.at(0)?.lastUsed ?? 0,
+ );
+
return (
+
);
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-confirmation-dialog.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-confirmation-dialog.tsx
new file mode 100644
index 0000000000..56b8c93ce1
--- /dev/null
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-confirmation-dialog.tsx
@@ -0,0 +1,58 @@
+"use client";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+type Props = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ onClick: () => void;
+ lastUsed: number;
+};
+
+export function RerollConfirmationDialog({ open, setOpen, onClick, lastUsed }: Props) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-key.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-key.tsx
new file mode 100644
index 0000000000..329a093eef
--- /dev/null
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-key.tsx
@@ -0,0 +1,251 @@
+"use client";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Form, FormDescription, FormField, FormItem, FormMessage } from "@/components/ui/form";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { trpc } from "@/lib/trpc/client";
+import { parseTrpcError } from "@/lib/utils";
+import { zodResolver } from "@hookform/resolvers/zod";
+import type { EncryptedKey, Key, Permission, Role } from "@unkey/db";
+import ms from "ms";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+import { RerollConfirmationDialog } from "./reroll-confirmation-dialog";
+import { RerollNewKeyDialog } from "./reroll-new-key-dialog";
+
+type Props = {
+ apiId: string;
+ apiKey: Key & {
+ roles: Role[];
+ permissions: Permission[];
+ encrypted: EncryptedKey;
+ };
+ lastUsed: number;
+};
+
+const EXPIRATION_OPTIONS = [
+ { key: "now", value: "Now" },
+ { key: "5m", value: "5 minutes" },
+ { key: "30m", value: "30 minutes" },
+ { key: "1h", value: "1 hour" },
+ { key: "6h", value: "6 hours" },
+ { key: "24h", value: "24 hours" },
+ { key: "7d", value: "7 days" },
+];
+
+const formSchema = z.object({
+ expiresIn: z.coerce.string(),
+});
+
+export const RerollKey: React.FC = ({ apiKey, apiId, lastUsed }: Props) => {
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ reValidateMode: "onBlur",
+ defaultValues: {
+ expiresIn: "1h",
+ },
+ });
+
+ const createKey = trpc.key.create.useMutation({
+ onMutate() {
+ toast.success("Rerolling Key");
+ },
+ onError(err) {
+ console.error(err);
+ const message = parseTrpcError(err);
+ toast.error(message);
+ },
+ });
+
+ const copyRolesToNewKey = trpc.rbac.connectRoleToKey.useMutation({
+ onError(err) {
+ console.error(err);
+ const message = parseTrpcError(err);
+ toast.error(message);
+ },
+ });
+
+ const copyPermissionsToNewKey = trpc.rbac.addPermissionToKey.useMutation({
+ onError(err) {
+ console.error(err);
+ const message = parseTrpcError(err);
+ toast.error(message);
+ },
+ });
+
+ const copyEncryptedToNewKey = trpc.key.update.encrypted.useMutation({
+ onError(err) {
+ console.error(err);
+ const message = parseTrpcError(err);
+ toast.error(message);
+ },
+ });
+
+ const updateDeletedAt = trpc.key.update.deletedAt.useMutation({
+ onSuccess() {
+ toast.success("Key Rerolled.");
+ },
+ onError(err) {
+ console.error(err);
+ const message = parseTrpcError(err);
+ toast.error(message);
+ },
+ });
+
+ const updateExpiration = trpc.key.update.expiration.useMutation({
+ onSuccess() {
+ toast.success("Key Rerolled.");
+ },
+ onError(err) {
+ console.error(err);
+ const message = parseTrpcError(err);
+ toast.error(message);
+ },
+ });
+
+ async function onSubmit(values: z.infer) {
+ const ratelimit = apiKey.ratelimitLimit
+ ? {
+ async: apiKey.ratelimitAsync ?? false,
+ duration: apiKey.ratelimitDuration ?? 0,
+ limit: apiKey.ratelimitLimit ?? 0,
+ }
+ : undefined;
+
+ const refill = apiKey.refillInterval
+ ? {
+ interval: apiKey.refillInterval ?? "daily",
+ amount: apiKey.refillAmount ?? 0,
+ }
+ : undefined;
+
+ const newKey = await createKey.mutateAsync({
+ ...apiKey,
+ keyAuthId: apiKey.keyAuthId,
+ name: apiKey.name || undefined,
+ environment: apiKey.environment || undefined,
+ meta: apiKey.meta ? JSON.parse(apiKey.meta) : undefined,
+ expires: apiKey.expires?.getTime() ?? undefined,
+ remaining: apiKey.remaining ?? undefined,
+ identityId: apiKey.identityId ?? undefined,
+ ratelimit,
+ refill,
+ });
+
+ apiKey.roles?.forEach(async (role) => {
+ await copyRolesToNewKey.mutateAsync({ roleId: role.id, keyId: newKey.keyId });
+ });
+
+ apiKey.permissions?.forEach(async (permission) => {
+ await copyPermissionsToNewKey.mutateAsync({
+ permission: permission.name,
+ keyId: newKey.keyId,
+ });
+ });
+
+ if (apiKey.encrypted) {
+ await copyEncryptedToNewKey.mutateAsync({
+ encrypted: apiKey.encrypted.encrypted,
+ encryptiodKeyId: apiKey.encrypted.encryptionKeyId,
+ keyId: newKey.keyId,
+ });
+ }
+
+ if (values.expiresIn === "now") {
+ await updateDeletedAt.mutateAsync({
+ keyId: apiKey.id,
+ deletedAt: new Date(Date.now()),
+ });
+ } else {
+ const miliseconds = ms(values.expiresIn);
+ const expiration = new Date(Date.now() + miliseconds);
+
+ await updateExpiration.mutateAsync({
+ keyId: apiKey.id,
+ expiration,
+ enableExpiration: true,
+ })
+ }
+ }
+
+ const [confirmatioDialogOpen, setConfirmationDialogOpen] = useState(false);
+ const confirmationSubmit = () => {
+ setConfirmationDialogOpen(false);
+ onSubmit(form.getValues());
+ };
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-new-key-dialog.tsx b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-new-key-dialog.tsx
new file mode 100644
index 0000000000..8bb3521b9d
--- /dev/null
+++ b/apps/dashboard/app/(app)/apis/[apiId]/keys/[keyAuthId]/[keyId]/settings/reroll-new-key-dialog.tsx
@@ -0,0 +1,76 @@
+"use client";
+import { CopyButton } from "@/components/dashboard/copy-button";
+import { VisibleButton } from "@/components/dashboard/visible-button";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Code } from "@/components/ui/code";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { AlertCircle } from "lucide-react";
+import Link from "next/link";
+import { useState } from "react";
+
+type Props = {
+ newKey:
+ | {
+ keyId: `key_${string}`;
+ key: string;
+ }
+ | undefined;
+ apiId: string;
+ keyAuthId: string;
+};
+
+export function RerollNewKeyDialog({ newKey, apiId, keyAuthId }: Props) {
+ if (!newKey) {
+ return null;
+ }
+
+ const split = newKey.key.split("_");
+ const maskedKey =
+ split.length >= 2 ? `${split[0]}_${"*".repeat(split[1].length)}` : "*".repeat(split[0].length);
+ const [showKey, setShowKey] = useState(false);
+
+ const [open, setOpen] = useState(Boolean(newKey));
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx
index f463f13052..fa35e44dc9 100644
--- a/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx
+++ b/apps/dashboard/app/(app)/settings/root-keys/[keyId]/permissions/permission_toggle.tsx
@@ -1,10 +1,8 @@
"use client";
-import { CopyButton } from "@/components/dashboard/copy-button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { toast } from "@/components/ui/toaster";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { trpc } from "@/lib/trpc/client";
import { parseTrpcError } from "@/lib/utils";
import { Loader2 } from "lucide-react";
@@ -32,7 +30,7 @@ export const PermissionToggle: React.FC = ({
const router = useRouter();
const [optimisticChecked, setOptimisticChecked] = useState(checked);
- const addPermission = trpc.rbac.addPermissionToRootKey.useMutation({
+ const addPermission = trpc.rbac.addPermissionToKey.useMutation({
onMutate: () => {
setOptimisticChecked(true);
},
@@ -60,7 +58,7 @@ export const PermissionToggle: React.FC = ({
cancel: {
label: "Undo",
onClick: () => {
- addPermission.mutate({ rootKeyId, permission: permissionName });
+ addPermission.mutate({ keyId: rootKeyId, permission: permissionName });
},
},
});
@@ -98,7 +96,7 @@ export const PermissionToggle: React.FC = ({
}
} else {
if (!preventEnabling) {
- addPermission.mutate({ rootKeyId, permission: permissionName });
+ addPermission.mutate({ keyId: rootKeyId, permission: permissionName });
}
}
}}
diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts
index f5873f80a3..95cb0cd5c2 100644
--- a/apps/dashboard/lib/trpc/routers/index.ts
+++ b/apps/dashboard/lib/trpc/routers/index.ts
@@ -9,7 +9,9 @@ import { createKey } from "./key/create";
import { createRootKey } from "./key/createRootKey";
import { deleteKeys } from "./key/delete";
import { deleteRootKeys } from "./key/deleteRootKey";
+import { updateKeyDeletedAt } from "./key/updateDeletedAt";
import { updateKeyEnabled } from "./key/updateEnabled";
+import { updateKeyEncrypted } from "./key/updateEncrypted";
import { updateKeyExpiration } from "./key/updateExpiration";
import { updateKeyMetadata } from "./key/updateMetadata";
import { updateKeyName } from "./key/updateName";
@@ -27,7 +29,7 @@ import { deleteNamespace } from "./ratelimit/deleteNamespace";
import { deleteOverride } from "./ratelimit/deleteOverride";
import { updateNamespaceName } from "./ratelimit/updateNamespaceName";
import { updateOverride } from "./ratelimit/updateOverride";
-import { addPermissionToRootKey } from "./rbac/addPermissionToRootKey";
+import { addPermissionToKey } from "./rbac/addPermissionToKey";
import { connectPermissionToRole } from "./rbac/connectPermissionToRole";
import { connectRoleToKey } from "./rbac/connectRoleToKey";
import { createPermission } from "./rbac/createPermission";
@@ -66,7 +68,9 @@ export const router = t.router({
create: createKey,
delete: deleteKeys,
update: t.router({
+ deletedAt: updateKeyDeletedAt,
enabled: updateKeyEnabled,
+ encrypted: updateKeyEncrypted,
expiration: updateKeyExpiration,
metadata: updateKeyMetadata,
name: updateKeyName,
@@ -112,7 +116,7 @@ export const router = t.router({
createIssue: createPlainIssue,
}),
rbac: t.router({
- addPermissionToRootKey: addPermissionToRootKey,
+ addPermissionToKey: addPermissionToKey,
connectPermissionToRole: connectPermissionToRole,
connectRoleToKey: connectRoleToKey,
createPermission: createPermission,
diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts
index fd1b4b0bed..2ff5dd5a92 100644
--- a/apps/dashboard/lib/trpc/routers/key/create.ts
+++ b/apps/dashboard/lib/trpc/routers/key/create.ts
@@ -33,6 +33,7 @@ export const createKey = t.procedure
.optional(),
enabled: z.boolean().default(true),
environment: z.string().optional(),
+ identityId: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -92,6 +93,7 @@ export const createKey = t.procedure
deletedAt: null,
enabled: input.enabled,
environment: input.environment,
+ identityId: input.identityId,
})
.catch((_err) => {
throw new TRPCError({
diff --git a/apps/dashboard/lib/trpc/routers/key/updateDeletedAt.ts b/apps/dashboard/lib/trpc/routers/key/updateDeletedAt.ts
new file mode 100644
index 0000000000..5a988c8d3f
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/key/updateDeletedAt.ts
@@ -0,0 +1,71 @@
+import { db, eq, schema } from "@/lib/db";
+import { ingestAuditLogs } from "@/lib/tinybird";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { auth, t } from "../../trpc";
+
+export const updateKeyDeletedAt = t.procedure
+ .use(auth)
+ .input(
+ z.object({
+ keyId: z.string(),
+ deletedAt: z.date(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const key = await db.query.keys.findFirst({
+ where: (table, { eq, and, isNull }) =>
+ and(eq(table.id, input.keyId), isNull(table.deletedAt)),
+ with: {
+ workspace: true,
+ },
+ });
+ if (!key || key.workspace.tenantId !== ctx.tenant.id) {
+ throw new TRPCError({
+ message:
+ "We are unable to find the the correct key. Please contact support using support@unkey.dev.",
+ code: "NOT_FOUND",
+ });
+ }
+
+ try {
+ await db.transaction(async (tx) => {
+ await tx
+ .update(schema.keys)
+ .set({
+ deletedAt: input.deletedAt,
+ })
+ .where(eq(schema.keys.id, key.id));
+
+ await ingestAuditLogs({
+ workspaceId: key.workspace.id,
+ actor: {
+ type: "user",
+ id: ctx.user.id,
+ },
+ event: "key.update",
+ description: `Changed the deletion date of ${key.id} to ${input.deletedAt.toUTCString()}`,
+ resources: [
+ {
+ type: "key",
+ id: key.id,
+ },
+ ],
+ context: {
+ location: ctx.audit.location,
+ userAgent: ctx.audit.userAgent,
+ },
+ }).catch((err) => {
+ tx.rollback();
+ throw err;
+ });
+ });
+ } catch (_err) {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ "We were unable to update this key. Please contact support using support@unkey.dev",
+ });
+ }
+ return true;
+ });
diff --git a/apps/dashboard/lib/trpc/routers/key/updateEncrypted.ts b/apps/dashboard/lib/trpc/routers/key/updateEncrypted.ts
new file mode 100644
index 0000000000..5559679501
--- /dev/null
+++ b/apps/dashboard/lib/trpc/routers/key/updateEncrypted.ts
@@ -0,0 +1,70 @@
+import { db, schema } from "@/lib/db";
+import { ingestAuditLogs } from "@/lib/tinybird";
+import { TRPCError } from "@trpc/server";
+import { z } from "zod";
+import { auth, t } from "../../trpc";
+
+export const updateKeyEncrypted = t.procedure
+ .use(auth)
+ .input(
+ z.object({
+ keyId: z.string(),
+ encrypted: z.string(),
+ encryptiodKeyId: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const key = await db.query.keys.findFirst({
+ where: (table, { eq, isNull, and }) =>
+ and(eq(table.id, input.keyId), isNull(table.deletedAt)),
+ with: {
+ workspace: true,
+ },
+ });
+ if (!key || key.workspace.tenantId !== ctx.tenant.id) {
+ throw new TRPCError({
+ message:
+ "We are unable to find the correct key. Please contact support using support@unkey.dev.",
+ code: "NOT_FOUND",
+ });
+ }
+
+ const tuple = {
+ keyId: input.keyId,
+ encrypted: input.encrypted,
+ encryptionKeyId: input.encryptiodKeyId,
+ workspaceId: ctx.tenant.id,
+ };
+ await db
+ .insert(schema.encryptedKeys)
+ .values({ ...tuple })
+ .onDuplicateKeyUpdate({ set: { ...tuple } })
+ .catch((_err) => {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message:
+ "We are unable to update key encrypted. Please contact support using support@unkey.dev",
+ });
+ });
+
+ await ingestAuditLogs({
+ workspaceId: key.workspace.id,
+ actor: {
+ type: "user",
+ id: ctx.user.id,
+ },
+ event: "key.update",
+ description: `Created a encrypted relation to ${key.id}`,
+ resources: [
+ {
+ type: "key",
+ id: key.id,
+ },
+ ],
+ context: {
+ location: ctx.audit.location,
+ userAgent: ctx.audit.userAgent,
+ },
+ });
+ return true;
+ });
diff --git a/apps/dashboard/lib/trpc/routers/rbac/addPermissionToRootKey.ts b/apps/dashboard/lib/trpc/routers/rbac/addPermissionToKey.ts
similarity index 89%
rename from apps/dashboard/lib/trpc/routers/rbac/addPermissionToRootKey.ts
rename to apps/dashboard/lib/trpc/routers/rbac/addPermissionToKey.ts
index 2ff9505a4b..18d838beb6 100644
--- a/apps/dashboard/lib/trpc/routers/rbac/addPermissionToRootKey.ts
+++ b/apps/dashboard/lib/trpc/routers/rbac/addPermissionToKey.ts
@@ -6,11 +6,11 @@ import { z } from "zod";
import { auth, t } from "../../trpc";
import { upsertPermissions } from "../rbac";
-export const addPermissionToRootKey = t.procedure
+export const addPermissionToKey = t.procedure
.use(auth)
.input(
z.object({
- rootKeyId: z.string(),
+ keyId: z.string(),
permission: z.string(),
}),
)
@@ -35,9 +35,9 @@ export const addPermissionToRootKey = t.procedure
});
}
- const rootKey = await db.query.keys.findFirst({
+ const key = await db.query.keys.findFirst({
where: (table, { eq, and }) =>
- and(eq(table.forWorkspaceId, workspace.id), eq(table.id, input.rootKeyId)),
+ and(eq(table.forWorkspaceId, workspace.id), eq(table.id, input.keyId)),
with: {
permissions: {
with: {
@@ -46,7 +46,7 @@ export const addPermissionToRootKey = t.procedure
},
},
});
- if (!rootKey) {
+ if (!key) {
throw new TRPCError({
code: "NOT_FOUND",
message:
@@ -54,14 +54,14 @@ export const addPermissionToRootKey = t.procedure
});
}
- const { permissions, auditLogs } = await upsertPermissions(ctx, rootKey.workspaceId, [
+ const { permissions, auditLogs } = await upsertPermissions(ctx, key.workspaceId, [
permission.data,
]);
const p = permissions[0];
await db
.insert(schema.keysPermissions)
.values({
- keyId: rootKey.id,
+ keyId: key.id,
permissionId: p.id,
workspaceId: p.workspaceId,
})
@@ -80,11 +80,11 @@ export const addPermissionToRootKey = t.procedure
workspaceId: workspace.id,
actor: { type: "user", id: ctx.user.id },
event: "authorization.connect_permission_and_key",
- description: `Attached ${p.id} to ${rootKey.id}`,
+ description: `Attached ${p.id} to ${key.id}`,
resources: [
{
type: "key",
- id: rootKey.id,
+ id: key.id,
},
{
type: "permission",