feat: add API key editing functionality to the dashboard (#358)

- new edit button in /dev-settings
- new updateApiKey mutation in api router
- new edit dialog-component
- new update-function in api-service
- changed sorting of api-key query to avoid list items jumping after updates
This commit is contained in:
Dan
2026-02-25 07:11:11 -05:00
committed by GitHub
parent 0c9ebc86a3
commit b2ed09e7a7
4 changed files with 275 additions and 5 deletions
@@ -11,10 +11,15 @@ import {
import { formatDistanceToNow } from "date-fns";
import { api } from "~/trpc/react";
import DeleteApiKey from "./delete-api-key";
import { EditApiKeyDialog } from "./edit-api-key";
import Spinner from "@usesend/ui/src/spinner";
import { useState } from "react";
import { Edit3 } from "lucide-react";
import { Button } from "@usesend/ui/src/button";
export default function ApiList() {
const apiKeysQuery = api.apiKey.getApiKeys.useQuery();
const [editingId, setEditingId] = useState<number | null>(null);
return (
<div className="mt-10">
@@ -60,14 +65,34 @@ export default function ApiList() {
</TableCell>
<TableCell>
{apiKey.lastUsed
? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })
? formatDistanceToNow(apiKey.lastUsed, {
addSuffix: true,
})
: "Never"}
</TableCell>
<TableCell>
{formatDistanceToNow(apiKey.createdAt, { addSuffix: true })}
{formatDistanceToNow(apiKey.createdAt, {
addSuffix: true,
})}
</TableCell>
<TableCell>
<DeleteApiKey apiKey={apiKey} />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingId(apiKey.id)}
>
<Edit3 className="h-4 w-4" />
</Button>
<DeleteApiKey apiKey={apiKey} />
<EditApiKeyDialog
apiKey={apiKey}
open={editingId === apiKey.id}
onOpenChange={(open) => {
if (!open) setEditingId(null);
}}
/>
</div>
</TableCell>
</TableRow>
))
@@ -0,0 +1,184 @@
"use client";
import { useEffect } from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@usesend/ui/src/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@usesend/ui/src/form";
import { Input } from "@usesend/ui/src/input";
import { Button } from "@usesend/ui/src/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@usesend/ui/src/select";
import { api } from "~/trpc/react";
import { toast } from "@usesend/ui/src/toaster";
const editApiKeySchema = z.object({
name: z
.string({ required_error: "Name is required" })
.min(1, { message: "Name is required" }),
domainId: z.string().optional(),
});
type EditApiKeyFormValues = z.infer<typeof editApiKeySchema>;
interface ApiKeyData {
id: number;
name: string;
domainId: number | null;
domain?: { name: string } | null;
}
export function EditApiKeyDialog({
apiKey,
open,
onOpenChange,
}: {
apiKey: ApiKeyData;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const updateApiKey = api.apiKey.updateApiKey.useMutation();
const domainsQuery = api.domain.domains.useQuery();
const utils = api.useUtils();
const form = useForm<EditApiKeyFormValues>({
resolver: zodResolver(editApiKeySchema),
defaultValues: {
name: apiKey.name,
domainId: apiKey.domainId ? apiKey.domainId.toString() : "all",
},
});
useEffect(() => {
if (open) {
form.reset({
name: apiKey.name,
domainId: apiKey.domainId ? apiKey.domainId.toString() : "all",
});
}
}, [open, apiKey, form]);
function handleSubmit(values: EditApiKeyFormValues) {
const domainId =
values.domainId === "all" ? null : Number(values.domainId);
updateApiKey.mutate(
{
id: apiKey.id,
name: values.name,
domainId,
},
{
onSuccess: () => {
utils.apiKey.invalidate();
toast.success("API key updated");
onOpenChange(false);
},
onError: (error) => {
toast.error(error.message);
},
},
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit API key</DialogTitle>
</DialogHeader>
<div className="py-2">
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="name"
render={({ field, formState }) => (
<FormItem>
<FormLabel>API key name</FormLabel>
<FormControl>
<Input placeholder="prod key" {...field} />
</FormControl>
{formState.errors.name ? (
<FormMessage />
) : (
<FormDescription>
Use a name to easily identify this API key.
</FormDescription>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>Domain access</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select domain access" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">All Domains</SelectItem>
{domainsQuery.data?.map(
(domain: { id: number; name: string }) => (
<SelectItem
key={domain.id}
value={domain.id.toString()}
>
{domain.name}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormDescription>
Choose which domain this API key can send emails from.
</FormDescription>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
className="w-[120px] hover:bg-gray-100 focus:bg-gray-100"
type="submit"
disabled={updateApiKey.isPending}
>
{updateApiKey.isPending ? "Saving..." : "Save changes"}
</Button>
</div>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
+24 -2
View File
@@ -1,13 +1,16 @@
import { z } from "zod";
import { ApiPermission } from "@prisma/client";
import { TRPCError } from "@trpc/server";
import {
apiKeyProcedure,
createTRPCRouter,
teamProcedure,
} from "~/server/api/trpc";
import { addApiKey, deleteApiKey } from "~/server/service/api-service";
import {
addApiKey,
deleteApiKey,
updateApiKey,
} from "~/server/service/api-service";
export const apiRouter = createTRPCRouter({
createToken: teamProcedure
@@ -45,12 +48,31 @@ export const apiRouter = createTRPCRouter({
name: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
return keys;
}),
updateApiKey: apiKeyProcedure
.input(
z.object({
name: z.string().min(1).optional(),
domainId: z.number().int().positive().nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
return await updateApiKey({
id: input.id,
teamId: ctx.team.id,
name: input.name,
domainId: input.domainId,
});
}),
deleteApiKey: apiKeyProcedure.mutation(async ({ input }) => {
return deleteApiKey(input.id);
}),
@@ -93,6 +93,45 @@ export async function getTeamAndApiKey(apiKey: string) {
}
}
export async function updateApiKey({
id,
teamId,
name,
domainId,
}: {
id: number;
teamId: number;
name?: string;
domainId?: number | null;
}) {
try {
if (domainId !== undefined && domainId !== null) {
const domain = await db.domain.findUnique({
where: {
id: domainId,
teamId: teamId,
},
select: { id: true },
});
if (!domain) {
throw new Error("DOMAIN_NOT_FOUND");
}
}
return await db.apiKey.update({
where: { id, teamId },
data: {
...(name !== undefined && { name }),
...(domainId !== undefined && { domainId }),
},
});
} catch (error) {
logger.error({ err: error }, "Error updating API key");
throw error;
}
}
export async function deleteApiKey(id: number) {
try {
await db.apiKey.delete({