-
-
Notifications
You must be signed in to change notification settings - Fork 349
feat: add API key editing functionality to the dashboard #358
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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> | ||||||||||||||||||||||||||||||||
|
Comment on lines
+80
to
+86
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||
| <DeleteApiKey apiKey={apiKey} /> | ||||||||||||||||||||||||||||||||
| <EditApiKeyDialog | ||||||||||||||||||||||||||||||||
| apiKey={apiKey} | ||||||||||||||||||||||||||||||||
| open={editingId === apiKey.id} | ||||||||||||||||||||||||||||||||
| onOpenChange={(open) => { | ||||||||||||||||||||||||||||||||
| if (!open) setEditingId(null); | ||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||
| </TableCell> | ||||||||||||||||||||||||||||||||
| </TableRow> | ||||||||||||||||||||||||||||||||
| )) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| 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> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard against empty update payloads. If 🛠️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| logger.error({ err: error }, "Error updating API key"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw error; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function deleteApiKey(id: number) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await db.apiKey.delete({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use the ~/ alias for local imports in apps/web.
🔧 Proposed fix
As per coding guidelines, "Use the
/alias for src imports in apps/web (e.g., import { x } from '/utils/x')."📝 Committable suggestion
🤖 Prompt for AI Agents