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 { formatDistanceToNow } from "date-fns";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import DeleteApiKey from "./delete-api-key";
|
import DeleteApiKey from "./delete-api-key";
|
||||||
|
import { EditApiKeyDialog } from "./edit-api-key";
|
||||||
import Spinner from "@usesend/ui/src/spinner";
|
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() {
|
export default function ApiList() {
|
||||||
const apiKeysQuery = api.apiKey.getApiKeys.useQuery();
|
const apiKeysQuery = api.apiKey.getApiKeys.useQuery();
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
@@ -60,14 +65,34 @@ export default function ApiList() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{apiKey.lastUsed
|
{apiKey.lastUsed
|
||||||
? formatDistanceToNow(apiKey.lastUsed, { addSuffix: true })
|
? formatDistanceToNow(apiKey.lastUsed, {
|
||||||
|
addSuffix: true,
|
||||||
|
})
|
||||||
: "Never"}
|
: "Never"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{formatDistanceToNow(apiKey.createdAt, { addSuffix: true })}
|
{formatDistanceToNow(apiKey.createdAt, {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</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 { z } from "zod";
|
||||||
import { ApiPermission } from "@prisma/client";
|
import { ApiPermission } from "@prisma/client";
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
apiKeyProcedure,
|
apiKeyProcedure,
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
teamProcedure,
|
teamProcedure,
|
||||||
} from "~/server/api/trpc";
|
} 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({
|
export const apiRouter = createTRPCRouter({
|
||||||
createToken: teamProcedure
|
createToken: teamProcedure
|
||||||
@@ -45,12 +48,31 @@ export const apiRouter = createTRPCRouter({
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: "desc",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return keys;
|
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 }) => {
|
deleteApiKey: apiKeyProcedure.mutation(async ({ input }) => {
|
||||||
return deleteApiKey(input.id);
|
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) {
|
export async function deleteApiKey(id: number) {
|
||||||
try {
|
try {
|
||||||
await db.apiKey.delete({
|
await db.apiKey.delete({
|
||||||
|
|||||||
Reference in New Issue
Block a user