diff --git a/apiproxy/validateToken.go b/apiproxy/validateToken.go index 14a866c..892ec89 100644 --- a/apiproxy/validateToken.go +++ b/apiproxy/validateToken.go @@ -14,6 +14,9 @@ import ( func CompareToken(hashes []db.ApiKey, apiKey string) (string, error) { for _, hash := range hashes { + if hash.Deactivated { + continue + } err := bcrypt.CompareHashAndPassword([]byte(hash.ApiKey), []byte(apiKey)) // log.Printf("Compared %s with %s", apiKey, hash.ApiKey) if err == nil { diff --git a/apiproxy/validateToken_test.go b/apiproxy/validateToken_test.go new file mode 100644 index 0000000..53e95c1 --- /dev/null +++ b/apiproxy/validateToken_test.go @@ -0,0 +1,58 @@ +package apiproxy + +import ( + "testing" + + db "openai-api-proxy/db" + + "golang.org/x/crypto/bcrypt" +) + +func mustHash(t *testing.T, plain string) string { + t.Helper() + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.MinCost) + if err != nil { + t.Fatalf("failed to hash token: %v", err) + } + return string(hash) +} + +func TestCompareToken_IgnoresDeactivatedKeys(t *testing.T) { + activeToken := "active-secret" + deactivatedToken := "deactivated-secret" + + keys := []db.ApiKey{ + { + UUID: "dead-key", + ApiKey: mustHash(t, deactivatedToken), + Deactivated: true, + }, + { + UUID: "active-key", + ApiKey: mustHash(t, activeToken), + Deactivated: false, + }, + } + + if got, err := CompareToken(keys, activeToken); err != nil || got != "active-key" { + t.Fatalf("expected active key to validate, got uuid=%q err=%v", got, err) + } + + if got, err := CompareToken(keys, deactivatedToken); err == nil { + t.Fatalf("expected deactivated key to be rejected, got uuid=%q", got) + } +} + +func TestCompareToken_AllDeactivatedRejected(t *testing.T) { + keys := []db.ApiKey{ + { + UUID: "dead-1", + ApiKey: mustHash(t, "dead-secret"), + Deactivated: true, + }, + } + + if got, err := CompareToken(keys, "dead-secret"); err == nil { + t.Fatalf("expected deactivated key to be rejected, got uuid=%q", got) + } +} diff --git a/app/AGENTS.md b/app/AGENTS.md index 0bb1276..21b34d0 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -1,3 +1,5 @@ Always run `npm run typecheck` after making changes that affect TypeScript types. Always run `npm run check:write` to auto-fix Biome issues and surface remaining errors. Always run `npm run build` when finishing a feature to do a final check. +Always add new feature entries to `app/RELEASE_NOTES.md`. +When finishing a feature, ask the user whether a new version should be added to `app/RELEASE_NOTES.md`. diff --git a/app/RELEASE_NOTES.md b/app/RELEASE_NOTES.md new file mode 100644 index 0000000..0fdbf99 --- /dev/null +++ b/app/RELEASE_NOTES.md @@ -0,0 +1,4 @@ +# Release Notes + +## 0.1.0 - 2026-03-04 +- Initial release notes tracking. diff --git a/app/drizzle/schema.ts b/app/drizzle/schema.ts index e9ab5f8..4124c77 100644 --- a/app/drizzle/schema.ts +++ b/app/drizzle/schema.ts @@ -85,6 +85,7 @@ export const apikeys = pgTable( owner: varchar({ length: 255 }).notNull(), aiapi: varchar({ length: 255 }), description: varchar({ length: 255 }), + deactivated: boolean("deactivated").default(false).notNull(), }, (table) => [ foreignKey({ diff --git a/app/src/app/my-api-keys/api-keys-client.tsx b/app/src/app/my-api-keys/api-keys-client.tsx index 80244bc..58a499d 100644 --- a/app/src/app/my-api-keys/api-keys-client.tsx +++ b/app/src/app/my-api-keys/api-keys-client.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { Button } from "~/components/ui/button"; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, @@ -24,6 +25,13 @@ export function ApiKeysClient() { const [createdId, setCreatedId] = useState(null); const [copied, setCopied] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); + const [showDeactivated, setShowDeactivated] = useState(false); + const [deactivatingId, setDeactivatingId] = useState(null); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [confirmPayload, setConfirmPayload] = useState<{ + id: string; + label: string; + } | null>(null); const createApiKey = api.apiKey.createApiKey.useMutation({ onSuccess: async (result) => { @@ -39,131 +47,210 @@ export function ApiKeysClient() { }, }); + const deactivateApiKey = api.apiKey.deactivateApiKey.useMutation({ + onSuccess: async () => { + await utils.apiKey.getApiKeys.invalidate(); + toast.success("API-Key deaktiviert."); + }, + onError: () => { + toast.error("API-Key konnte nicht deaktiviert werden."); + }, + onSettled: () => { + setDeactivatingId(null); + }, + }); + + const visibleData = showDeactivated + ? data + : data.filter((row) => !row.deactivated); + return (
+ {isLoading ? ( +
+ Schlüssel werden geladen... +
+ ) : ( + + + { + setDialogOpen(open); + if (!open) { + setToken(null); + setCreatedId(null); + setDescription(""); + setCopied(false); + createApiKey.reset(); + } + }} + open={dialogOpen} + > + }> + API-Key erstellen + + + + Neuer API-Key + + Füge optional eine Beschreibung hinzu und erstelle den + Schlüssel. + + +
+ setDescription(event.target.value)} + placeholder="Beschreibung (optional)" + value={description} + /> + {token ? ( +
+
+ Warnung +
+

+ Dieser Schlüssel wird nur einmal angezeigt. Bitte + jetzt sicher speichern. +

+
+ + {createdId ? ( + + ID: {createdId} + + ) : null} +
+
+ + {copied ? ( + + Kopiert. + + ) : null} +
+
+ ) : null} +
+ +
+ + {createApiKey.error ? ( + + Schlüssel konnte nicht erstellt werden. Bitte erneut + versuchen. + + ) : null} +
+
+
+
+
+ } + data={visibleData.map((row) => ({ + kind: "key", + id: row.id, + description: row.description, + deactivated: row.deactivated, + inputTokens: row.inputTokens, + cachedInputTokens: row.cachedInputTokens, + outputTokens: row.outputTokens, + createdAt: row.createdAt, + cost: row.cost ?? null, + currency: row.currency ?? null, + subRows: row.models.map((modelRow) => ({ + kind: "model", + model: modelRow.model, + inputTokens: modelRow.inputTokens, + cachedInputTokens: modelRow.cachedInputTokens, + outputTokens: modelRow.outputTokens, + cost: modelRow.cost ?? null, + currency: modelRow.currency ?? null, + })), + }))} + deactivatingId={deactivatingId} + onRequestDeactivate={({ id, label }) => { + if (deactivateApiKey.isPending) return; + setConfirmPayload({ id, label }); + setConfirmDialogOpen(true); + }} + /> + )} { - setDialogOpen(open); + setConfirmDialogOpen(open); if (!open) { - setToken(null); - setCreatedId(null); - setDescription(""); - setCopied(false); - createApiKey.reset(); + setConfirmPayload(null); } }} - open={dialogOpen} + open={confirmDialogOpen} > - {isLoading ? ( -
- Schlüssel werden geladen... -
- ) : ( - }> - API-Key erstellen - - } - data={data.map((row) => ({ - kind: "key", - id: row.id, - description: row.description, - inputTokens: row.inputTokens, - cachedInputTokens: row.cachedInputTokens, - outputTokens: row.outputTokens, - createdAt: row.createdAt, - cost: row.cost ?? null, - currency: row.currency ?? null, - subRows: row.models.map((modelRow) => ({ - kind: "model", - model: modelRow.model, - inputTokens: modelRow.inputTokens, - cachedInputTokens: modelRow.cachedInputTokens, - outputTokens: modelRow.outputTokens, - cost: modelRow.cost ?? null, - currency: modelRow.currency ?? null, - })), - }))} - /> - )} - Neuer API-Key + API-Key deaktivieren? - Füge optional eine Beschreibung hinzu und erstelle den Schlüssel. - - -
- setDescription(event.target.value)} - placeholder="Beschreibung (optional)" - value={description} - /> - {token ? ( -
-
- Warnung -
-

- Dieser Schlüssel wird nur einmal angezeigt. Bitte jetzt sicher - speichern. -

-
- - {createdId ? ( - - ID: {createdId} - - ) : null} -
-
- - {copied ? ( - - Kopiert. - - ) : null} -
-
- ) : null} -
- -
- - {createApiKey.error ? ( - - Schlüssel konnte nicht erstellt werden. Bitte erneut - versuchen. + Möchtest du den API-Key{" "} + + {confirmPayload?.label ?? "Ohne Beschreibung"} + {" "} + deaktivieren? + {confirmPayload?.id ? ( + + ID: {confirmPayload.id} ) : null} -
+ + + + }> + Abbrechen + +
diff --git a/app/src/app/my-api-keys/api-keys-table.tsx b/app/src/app/my-api-keys/api-keys-table.tsx index 84ce2bc..7914b9b 100644 --- a/app/src/app/my-api-keys/api-keys-table.tsx +++ b/app/src/app/my-api-keys/api-keys-table.tsx @@ -35,6 +35,7 @@ export type ApiKeyTableRow = { id?: string; description?: string | null; model?: string | null; + deactivated?: boolean; inputTokens: number; cachedInputTokens: number; outputTokens: number; @@ -75,9 +76,13 @@ function formatCost(value: number | null, currency?: string | null) { export function ApiKeysTable({ data, action, + onRequestDeactivate, + deactivatingId, }: { data: ApiKeyTableRow[]; action?: ReactNode; + onRequestDeactivate?: (payload: { id: string; label: string }) => void; + deactivatingId?: string | null; }) { const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); @@ -176,8 +181,34 @@ export function ApiKeysTable({ cell: ({ row, getValue }) => row.original.kind === "model" ? "—" : (getValue() ?? "—"), }, + { + id: "status", + header: "Status", + enableSorting: false, + cell: ({ row }) => { + if (row.original.kind === "model") return "—"; + if (row.original.deactivated) { + return ( + Deaktiviert + ); + } + const id = row.original.id; + if (!id || !onRequestDeactivate) return "Aktiv"; + const label = row.original.description?.trim() || "Ohne Beschreibung"; + return ( + + ); + }, + }, ], - [], + [deactivatingId, onRequestDeactivate], ); const table = useReactTable({ diff --git a/app/src/app/release-notes/page.tsx b/app/src/app/release-notes/page.tsx new file mode 100644 index 0000000..ae661e8 --- /dev/null +++ b/app/src/app/release-notes/page.tsx @@ -0,0 +1,22 @@ +import { getAppVersion, getReleaseNotes } from "~/lib/release-notes"; + +export const metadata = { + title: "Release Notes", +}; + +export default async function ReleaseNotesPage() { + const notes = await getReleaseNotes(); + const version = getAppVersion(); + + return ( +
+
+

Version

+

v{version}

+
+
+				{notes}
+			
+
+ ); +} diff --git a/app/src/components/app-sidebar.tsx b/app/src/components/app-sidebar.tsx index 1463b52..2fe2056 100644 --- a/app/src/components/app-sidebar.tsx +++ b/app/src/components/app-sidebar.tsx @@ -8,14 +8,24 @@ import { SidebarFooter, SidebarHeader, } from "~/components/ui/sidebar"; +import { getAppVersion } from "~/lib/release-notes"; import { auth } from "~/server/auth"; export async function AppSidebar() { const session = await auth(); + const version = getAppVersion(); return ( - + + OpenAI API Proxy + + v{version} + + diff --git a/app/src/components/ui/alert-dialog.tsx b/app/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..cab7da3 --- /dev/null +++ b/app/src/components/ui/alert-dialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import type * as React from "react"; +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return ; +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ); +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( +