SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
9.5 KB · 221 lines tsx
Raw Blame History
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