TypeScript 97.5%
SQL 1.4%
Python 0.8%
1"use client";2import * as React from "react";3import Link from "next/link";4import { useRouter } from "next/navigation";5import { MoreHorizontal, RefreshCw, Ban, KeyRound } from "lucide-react";6import { revokeApiKey, rotateApiKey, type CreatedKey } from "@/actions/api-keys";7import type { ApiKeyRow } from "@/lib/queries/account";8import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";9import { Badge, StatusBadge } from "@/components/ui/badge";10import { Button } from "@/components/ui/button";11import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";12import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";13import { Alert } from "@/components/ui/alert";14import { EmptyState } from "@/components/ui/empty-state";15import { SimpleTooltip } from "@/components/ui/tooltip";16import { formatDateOnly, timeAgo, formatDate } from "@/lib/format";17import { maskedKey } from "@/lib/account-snippets";18import { KeyReveal } from "./key-reveal";19import { CreateKeyDialog, type ProjectOption } from "./create-key-dialog";2021type PendingAction = { kind: "rotate" | "revoke"; key: ApiKeyRow } | null;2223export function KeysTable({ keys, projects, defaultProjectId, showProject }: { keys: ApiKeyRow[]; projects: ProjectOption[]; defaultProjectId: string; showProject: boolean }) {24 const router = useRouter();25 const [pendingAction, setPendingAction] = React.useState<PendingAction>(null);26 const [rotated, setRotated] = React.useState<CreatedKey | null>(null);27 const [error, setError] = React.useState<string | null>(null);28 const [busy, startTransition] = React.useTransition();2930 function confirm() {31 if (!pendingAction) return;32 const { kind, key } = pendingAction;33 setError(null);34 startTransition(async () => {35 if (kind === "revoke") {36 const res = await revokeApiKey(key.id);37 if (!res.ok) {38 setError(res.error);39 return;40 }41 setPendingAction(null);42 router.refresh();43 } else {44 const res = await rotateApiKey(key.id);45 if (!res.ok) {46 setError(res.error);47 return;48 }49 setPendingAction(null);50 if (res.data) setRotated(res.data);51 }52 });53 }5455 if (keys.length === 0) {56 return (57 <EmptyState58 icon={KeyRound}59 title="No API keys yet"60 description="API keys authenticate requests to Fetcha. Create one, copy it once, and use it as a Bearer token."61 action={<CreateKeyDialog projects={projects} defaultProjectId={defaultProjectId} />}62 />63 );64 }6566 return (67 <>68 {error && !pendingAction ? <Alert variant="danger" className="mb-3">{error}</Alert> : null}69 <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">70 <Table>71 <TableHeader>72 <TableRow className="hover:bg-transparent">73 <TableHead>Name</TableHead>74 <TableHead>Key</TableHead>75 {showProject ? <TableHead>Project</TableHead> : null}76 <TableHead>Mode</TableHead>77 <TableHead>Scopes</TableHead>78 <TableHead>Created</TableHead>79 <TableHead>Last used</TableHead>80 <TableHead>Expires</TableHead>81 <TableHead>Status</TableHead>82 <TableHead className="w-10 text-right">83 <span className="sr-only">Actions</span>84 </TableHead>85 </TableRow>86 </TableHeader>87 <TableBody>88 {keys.map((k) => {89 const inactive = k.status !== "active";90 return (91 <TableRow key={k.id} className={inactive ? "text-fg-muted" : undefined}>92 <TableCell className="font-medium text-fg">{k.name}</TableCell>93 <TableCell>94 <code className="font-mono text-[12.5px] tabular">{maskedKey(k.keyPrefix, k.last4)}</code>95 </TableCell>96 {showProject ? (97 <TableCell>98 <Link href={`/dashboard/projects/${k.projectId}`} className="text-fg-muted underline-offset-4 hover:text-fg hover:underline">99 {k.projectName}100 </Link>101 </TableCell>102 ) : null}103 <TableCell>104 <Badge variant={k.mode === "live" ? "success" : "warning"}>{k.mode}</Badge>105 </TableCell>106 <TableCell>107 <div className="flex flex-wrap gap-1">108 {k.scopes.map((s) => (109 <Badge key={s} variant="outline" className="font-mono text-[11px]">110 {s}111 </Badge>112 ))}113 </div>114 </TableCell>115 <TableCell className="whitespace-nowrap tabular">116 <SimpleTooltip content={formatDate(k.createdAt)}>117 <span>{formatDateOnly(k.createdAt)}</span>118 </SimpleTooltip>119 </TableCell>120 <TableCell className="whitespace-nowrap tabular">{timeAgo(k.lastUsedAt)}</TableCell>121 <TableCell className="whitespace-nowrap tabular">{k.expiresAt ? formatDateOnly(k.expiresAt) : "Never"}</TableCell>122 <TableCell>123 <StatusBadge status={k.status} />124 </TableCell>125 <TableCell className="text-right">126 <DropdownMenu>127 <DropdownMenuTrigger asChild>128 <Button variant="ghost" size="icon-sm" aria-label={`Actions for ${k.name}`}>129 <MoreHorizontal />130 </Button>131 </DropdownMenuTrigger>132 <DropdownMenuContent align="end">133 <DropdownMenuItem disabled={k.status === "revoked"} onSelect={() => setPendingAction({ kind: "rotate", key: k })}>134 <RefreshCw /> Rotate key135 </DropdownMenuItem>136 <DropdownMenuSeparator />137 <DropdownMenuItem destructive disabled={k.status === "revoked"} onSelect={() => setPendingAction({ kind: "revoke", key: k })}>138 <Ban /> Revoke139 </DropdownMenuItem>140 </DropdownMenuContent>141 </DropdownMenu>142 </TableCell>143 </TableRow>144 );145 })}146 </TableBody>147 </Table>148 </div>149150 {/* Confirm rotate / revoke */}151 <Dialog152 open={Boolean(pendingAction)}153 onOpenChange={(o) => {154 if (!o && !busy) {155 setPendingAction(null);156 setError(null);157 }158 }}159 >160 <DialogContent size="sm">161 {pendingAction ? (162 <>163 <DialogHeader>164 <DialogTitle>{pendingAction.kind === "rotate" ? "Rotate this key?" : "Revoke this key?"}</DialogTitle>165 <DialogDescription>166 {pendingAction.kind === "rotate" ? (167 <>168 A new secret is generated for <strong className="text-fg">{pendingAction.key.name}</strong> with the same project, mode, scopes and expiration. The current key{" "}169 <code className="font-mono">{maskedKey(pendingAction.key.keyPrefix, pendingAction.key.last4)}</code> stops working immediately.170 </>171 ) : (172 <>173 <strong className="text-fg">{pendingAction.key.name}</strong> (<code className="font-mono">{maskedKey(pendingAction.key.keyPrefix, pendingAction.key.last4)}</code>) will be rejected on the next request. This cannot be undone.174 </>175 )}176 </DialogDescription>177 </DialogHeader>178 {error ? <Alert variant="danger">{error}</Alert> : null}179 <DialogFooter>180 <Button variant="outline" onClick={() => setPendingAction(null)} disabled={busy}>181 Cancel182 </Button>183 <Button variant={pendingAction.kind === "revoke" ? "danger" : "primary"} onClick={confirm} loading={busy}>184 {pendingAction.kind === "rotate" ? "Rotate key" : "Revoke key"}185 </Button>186 </DialogFooter>187 </>188 ) : null}189 </DialogContent>190 </Dialog>191192 {/* Show the rotated key once */}193 <Dialog194 open={Boolean(rotated)}195 onOpenChange={() => {196 /* closing only through the Done button */197 }}198 >199 <DialogContent size="lg" onEscapeKeyDown={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()}>200 {rotated ? (201 <>202 <DialogHeader>203 <DialogTitle>Key rotated</DialogTitle>204 <DialogDescription>Update your integrations with the new secret. The previous key has been revoked.</DialogDescription>205 </DialogHeader>206 <KeyReveal207 plaintext={rotated.plaintext}208 name={rotated.name}209 onDone={() => {210 setRotated(null);211 router.refresh();212 }}213 />214 </>215 ) : null}216 </DialogContent>217 </Dialog>218 </>219 );220}221