From a40cde270909f5abc9a8affa4d19c52e9c65fedd Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 8 Jan 2026 14:21:32 +0100 Subject: [PATCH] feat: custom api key (#1715) * custom api key * phase 2 * implment phase 3 * implment phase 4 * implement phase 5 * implement phase 6 * format * fix build * ci: apply automated fixes * fixing stuff * ci: apply automated fixes * fix test * fmt * use react-hook-form * ci: apply automated fixes * improve pr * improve pr * improve db * fix: ui * fix: truncate text * fix: copy button --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Maximilian Kaske --- .../(dashboard)/settings/general/layout.tsx | 2 +- .../settings/api-key/data-table.tsx | 80 +- .../forms/settings/form-api-key.tsx | 259 +- apps/dashboard/src/lib/trpc/shared.ts | 2 +- apps/server/src/libs/middlewares/auth.ts | 66 +- package.json | 10 +- packages/api/src/edge.ts | 2 - packages/api/src/lambda.ts | 2 + packages/api/src/router/apiKey.ts | 105 +- packages/api/src/service/apiKey.test.ts | 434 +++ packages/api/src/service/apiKey.ts | 149 + .../db/drizzle/0052_illegal_killraven.sql | 18 + packages/db/drizzle/meta/0052_snapshot.json | 3071 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/package.json | 2 + packages/db/src/index.ts | 1 - packages/db/src/schema/api-keys/api_key.ts | 41 + packages/db/src/schema/api-keys/index.ts | 2 + packages/db/src/schema/api-keys/validation.ts | 22 + packages/db/src/schema/index.ts | 1 + packages/db/src/utils.ts | 18 - packages/db/src/utils/api-key.test.ts | 194 ++ packages/db/src/utils/api-key.ts | 63 + packages/db/src/utils/index.ts | 1 + pnpm-lock.yaml | 43 +- 25 files changed, 4444 insertions(+), 151 deletions(-) create mode 100644 packages/api/src/service/apiKey.test.ts create mode 100644 packages/api/src/service/apiKey.ts create mode 100644 packages/db/drizzle/0052_illegal_killraven.sql create mode 100644 packages/db/drizzle/meta/0052_snapshot.json create mode 100644 packages/db/src/schema/api-keys/api_key.ts create mode 100644 packages/db/src/schema/api-keys/index.ts create mode 100644 packages/db/src/schema/api-keys/validation.ts delete mode 100644 packages/db/src/utils.ts create mode 100644 packages/db/src/utils/api-key.test.ts create mode 100644 packages/db/src/utils/api-key.ts create mode 100644 packages/db/src/utils/index.ts diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx index 0c9a86da..e841c225 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx @@ -17,7 +17,7 @@ export default async function Layout({ const queryClient = getQueryClient(); await queryClient.prefetchQuery(trpc.member.list.queryOptions()); await queryClient.prefetchQuery(trpc.invitation.list.queryOptions()); - await queryClient.prefetchQuery(trpc.apiKey.get.queryOptions()); + await queryClient.prefetchQuery(trpc.apiKeyRouter.getAll.queryOptions()); return ( diff --git a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx index 46f4d3eb..e3c01d80 100644 --- a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx +++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx @@ -1,29 +1,77 @@ +import { QuickActions } from "@/components/dropdowns/quick-actions"; import { Table, TableBody, TableCell, TableHead, + TableHeader, TableRow, } from "@/components/ui/table"; +import { formatDate } from "@/lib/formatter"; +import { useTRPC } from "@/lib/trpc/client"; import type { RouterOutputs } from "@openstatus/api"; +import { useMutation } from "@tanstack/react-query"; -type ApiKey = RouterOutputs["apiKey"]["get"]; +type ApiKey = RouterOutputs["apiKeyRouter"]["getAll"][number]; + +export function DataTable({ + apiKeys, + refetch, +}: { + apiKeys: ApiKey[]; + refetch: () => void; +}) { + const trpc = useTRPC(); + const revokeApiKeyMutation = useMutation( + trpc.apiKeyRouter.revoke.mutationOptions({ + onSuccess: () => refetch(), + }), + ); -export function DataTable({ apiKey }: { apiKey: ApiKey }) { return ( - - - - Created At - - {new Date(apiKey.createdAt).toLocaleDateString()} - - - - Token - {apiKey.start}... - - -
+
+ + + + Name + Description + Prefix + Expires + + Actions + + + + + {apiKeys.map((apiKey) => ( + + {apiKey.name} + + {apiKey.description ?? "-"} + + + {apiKey.prefix}... + + + {apiKey.expiresAt ? formatDate(apiKey.expiresAt) : "-"} + + +
+ + await revokeApiKeyMutation.mutateAsync({ + keyId: apiKey.id, + }), + }} + /> +
+
+
+ ))} +
+
+
); } diff --git a/apps/dashboard/src/components/forms/settings/form-api-key.tsx b/apps/dashboard/src/components/forms/settings/form-api-key.tsx index 2cb09d61..90130e59 100644 --- a/apps/dashboard/src/components/forms/settings/form-api-key.tsx +++ b/apps/dashboard/src/components/forms/settings/form-api-key.tsx @@ -7,7 +7,6 @@ import { } from "@/components/content/empty-state"; import { EmptyStateContainer } from "@/components/content/empty-state"; import { DataTable } from "@/components/data-table/settings/api-key/data-table"; -import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; import { FormCard, FormCardContent, @@ -26,52 +25,105 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation, useQuery } from "@tanstack/react-query"; import { isTRPCClientError } from "@trpc/client"; -import { Copy } from "lucide-react"; +import { format } from "date-fns"; +import { CalendarIcon, Check, Copy } from "lucide-react"; import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; import { toast } from "sonner"; +import { z } from "zod"; // we should prefetch the api key on the server (layout) +const schema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), + expiresAt: z.string().optional(), +}); + +type FormValues = z.infer; + export function FormApiKey() { const trpc = useTRPC(); const [isPending, startTransition] = useTransition(); - const { copy } = useCopyToClipboard(); + const { copy, isCopied } = useCopyToClipboard(); const [result, setResult] = useState<{ - keyId: string; + token: string; key: string; } | null>(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + description: "", + expiresAt: "", + }, + }); + const { data: workspace } = useQuery( trpc.workspace.getWorkspace.queryOptions(), ); - const { data: apiKey, refetch } = useQuery(trpc.apiKey.get.queryOptions()); + const { data: apiKeys = [], refetch } = useQuery( + trpc.apiKeyRouter.getAll.queryOptions(), + ); const createApiKeyMutation = useMutation( - trpc.apiKey.create.mutationOptions({ + trpc.apiKeyRouter.create.mutationOptions({ onSuccess: (data) => { if (data) { - setResult(data); + refetch(); + setResult({ token: data.token, key: data.key.name }); + setCreateDialogOpen(false); + form.reset(); } else { throw new Error("Failed to create API key"); } }, }), ); - const revokeApiKeyMutation = useMutation( - trpc.apiKey.revoke.mutationOptions({ - onSuccess: () => refetch(), - }), - ); - async function createAction() { - if (isPending || !workspace) return; + function createAction(values: FormValues) { + if (isPending || !workspace) { + return; + } startTransition(async () => { try { const promise = createApiKeyMutation.mutateAsync({ - ownerId: workspace.id, + name: values.name.trim(), + description: values.description?.trim() || undefined, + expiresAt: values.expiresAt ? new Date(values.expiresAt) : undefined, }); toast.promise(promise, { loading: "Creating...", @@ -93,21 +145,21 @@ export function FormApiKey() { return ( - API Key + API Keys - Create and revoke your API key. + Create and manage your API keys. - {!apiKey ? ( + {apiKeys.length === 0 ? ( - No API key + No API keys Access your data via API. ) : ( - + )} @@ -122,52 +174,159 @@ export function FormApiKey() { . - {!apiKey ? ( - - ) : ( - { - await revokeApiKeyMutation.mutateAsync({ - keyId: apiKey.keyId, - }); - }} - /> - )} + + + + + +
+ + + Create API Key + + Create a new API key to access your workspace data. + + +
+ ( + + Name + + + + + + )} + /> + ( + + Description + +