Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions apps/web/src/app/(dashboard)/dev-settings/api-keys/api-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment on lines 13 to 15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the ~/ alias for local imports in apps/web.

🔧 Proposed fix
-import DeleteApiKey from "./delete-api-key";
-import { EditApiKeyDialog } from "./edit-api-key";
+import DeleteApiKey from "~/app/(dashboard)/dev-settings/api-keys/delete-api-key";
+import { EditApiKeyDialog } from "~/app/(dashboard)/dev-settings/api-keys/edit-api-key";

As per coding guidelines, "Use the /alias for src imports in apps/web (e.g., import { x } from '/utils/x')."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import DeleteApiKey from "./delete-api-key";
import { EditApiKeyDialog } from "./edit-api-key";
import Spinner from "@usesend/ui/src/spinner";
import DeleteApiKey from "~/app/(dashboard)/dev-settings/api-keys/delete-api-key";
import { EditApiKeyDialog } from "~/app/(dashboard)/dev-settings/api-keys/edit-api-key";
import Spinner from "@usesend/ui/src/spinner";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/dev-settings/api-keys/api-list.tsx around lines
13 - 15, The imports at the top of the file use relative/local paths instead of
the apps/web "~/ " alias; update the three local imports so they use the "~/..."
alias (replace ./delete-api-key and ./edit-api-key with imports from
'~/app/(dashboard)/dev-settings/api-keys/delete-api-key' and
'~/app/(dashboard)/dev-settings/api-keys/edit-api-key' or the appropriate module
paths) while leaving external library imports (Spinner from `@usesend/ui`)
unchanged; ensure symbols DeleteApiKey and EditApiKeyDialog remain correctly
referenced after switching to the alias.

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">
Expand Down Expand Up @@ -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>
Comment on lines +80 to +86
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add an accessible label for the icon-only Edit button.

✅ Suggested fix
                       <Button
                         variant="ghost"
                         size="sm"
+                        aria-label="Edit API key"
                         onClick={() => setEditingId(apiKey.id)}
                       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button
variant="ghost"
size="sm"
onClick={() => setEditingId(apiKey.id)}
>
<Edit3 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
aria-label="Edit API key"
onClick={() => setEditingId(apiKey.id)}
>
<Edit3 className="h-4 w-4" />
</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/dev-settings/api-keys/api-list.tsx around lines
80 - 86, The icon-only Edit button lacks an accessible label; update the Button
(the onClick handler that calls setEditingId(apiKey.id) and renders the Edit3
icon) to include an accessible name—e.g., add an aria-label that includes
identifying context (like the API key name or id) or include visually-hidden
text next to the Edit3 icon—so screen readers can announce which API key will be
edited.

<DeleteApiKey apiKey={apiKey} />
<EditApiKeyDialog
apiKey={apiKey}
open={editingId === apiKey.id}
onOpenChange={(open) => {
if (!open) setEditingId(null);
}}
/>
</div>
</TableCell>
</TableRow>
))
Expand Down
184 changes: 184 additions & 0 deletions apps/web/src/app/(dashboard)/dev-settings/api-keys/edit-api-key.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
26 changes: 24 additions & 2 deletions apps/web/src/server/api/routers/api.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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);
}),
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/server/service/api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
},
});
Comment on lines +96 to +128
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against empty update payloads.

If name and domainId are both undefined, the update becomes a no-op and can surface a confusing error downstream. Add an early guard with a clear message.

🛠️ Proposed fix
   try {
+    if (name === undefined && domainId === undefined) {
+      throw new Error("NO_UPDATE_FIELDS");
+    }
+
     if (domainId !== undefined && domainId !== null) {
       const domain = await db.domain.findUnique({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 }),
},
});
export async function updateApiKey({
id,
teamId,
name,
domainId,
}: {
id: number;
teamId: number;
name?: string;
domainId?: number | null;
}) {
try {
if (name === undefined && domainId === undefined) {
throw new Error("NO_UPDATE_FIELDS");
}
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 }),
},
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/server/service/api-service.ts` around lines 96 - 128, The
updateApiKey function can call db.apiKey.update with an empty data object when
both name and domainId are undefined; add an early guard in updateApiKey that
checks if name === undefined && domainId === undefined and throw a clear error
(e.g., "NO_UPDATE_PAYLOAD" or descriptive message) to avoid a no-op update,
while still allowing domainId === null to be used to clear the domain; update
the logic around db.apiKey.update (referencing updateApiKey and the
db.apiKey.update call) to only proceed when there is at least one field to
change.

} catch (error) {
logger.error({ err: error }, "Error updating API key");
throw error;
}
}

export async function deleteApiKey(id: number) {
try {
await db.apiKey.delete({
Expand Down