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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user