From f59a6c5d8a37618708d7ebaf665ced4fa486005f Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Mon, 15 Jun 2026 17:33:25 +0200 Subject: [PATCH] feat: REST API v1 + API key management + Swagger docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **API system** - ApiKey model: SHA-256-hashed tokens (gw_), per-family, scoped - Migration: 20260615210000_api_keys - src/lib/api-auth.ts: verifyApiKey(), hasScope(), generateApiKey(), prepareKey() **V1 endpoints** (all require Bearer gw_ token): - GET /api/v1/babies — list family babies (any read scope) - GET /api/v1/events — query events (events:read), babyId/type/from/to/limit/offset - POST /api/v1/events — log event (events:write), full metadata support - GET /api/v1/growth — growth logs (growth:read) - POST /api/v1/growth — add measurement (growth:write), weight in grams - GET /api/v1/summary — today's counts + sleep + last feed (summary:read) - GET /api/v1/milk — stock lots + totalMl (milk:read) **API key management** - GET /api/api-keys — list keys (session auth) - POST /api/api-keys — create key, returns raw token once (session auth) - DELETE /api/api-keys/[id] — revoke key (session auth) **Documentation** - GET /api/v1/openapi.json — OpenAPI 3.0 spec (CORS open) - GET /api-docs — Swagger UI (CDN, dark themed) **Settings UI** — "Clés API" section: create key with scope checkboxes, copy token banner (shown once), revoke, link to /api-docs Co-Authored-By: Claude Sonnet 4.6 --- .../20260615210000_api_keys/migration.sql | 19 ++ prisma/schema.prisma | 13 + src/app/(app)/settings/page.tsx | 156 +++++++++- src/app/api-docs/route.ts | 34 ++ src/app/api/api-keys/[id]/route.ts | 16 + src/app/api/api-keys/route.ts | 45 +++ src/app/api/v1/babies/route.ts | 18 ++ src/app/api/v1/events/route.ts | 99 ++++++ src/app/api/v1/growth/route.ts | 53 ++++ src/app/api/v1/milk/route.ts | 31 ++ src/app/api/v1/openapi.json/route.ts | 291 ++++++++++++++++++ src/app/api/v1/summary/route.ts | 44 +++ src/lib/api-auth.ts | 68 ++++ 13 files changed, 886 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260615210000_api_keys/migration.sql create mode 100644 src/app/api-docs/route.ts create mode 100644 src/app/api/api-keys/[id]/route.ts create mode 100644 src/app/api/api-keys/route.ts create mode 100644 src/app/api/v1/babies/route.ts create mode 100644 src/app/api/v1/events/route.ts create mode 100644 src/app/api/v1/growth/route.ts create mode 100644 src/app/api/v1/milk/route.ts create mode 100644 src/app/api/v1/openapi.json/route.ts create mode 100644 src/app/api/v1/summary/route.ts create mode 100644 src/lib/api-auth.ts diff --git a/prisma/migrations/20260615210000_api_keys/migration.sql b/prisma/migrations/20260615210000_api_keys/migration.sql new file mode 100644 index 0000000..67ae147 --- /dev/null +++ b/prisma/migrations/20260615210000_api_keys/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "ApiKey" ( + "id" TEXT NOT NULL, + "familyId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "keyHash" TEXT NOT NULL, + "keyPrefix" TEXT NOT NULL, + "scopes" TEXT[], + "lastUsedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_keyHash_key" ON "ApiKey"("keyHash"); + +-- AddForeignKey +ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9c96d58..d1c41fb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -91,12 +91,25 @@ model Family { users User[] babies Baby[] eventTemplates EventTemplate[] + apiKeys ApiKey[] pinnedNote String? pinnedNoteUpdatedAt DateTime? pinnedNoteAuthor String? createdAt DateTime @default(now()) } +model ApiKey { + id String @id @default(cuid()) + familyId String + family Family @relation(fields: [familyId], references: [id], onDelete: Cascade) + name String + keyHash String @unique + keyPrefix String + scopes String[] + lastUsedAt DateTime? + createdAt DateTime @default(now()) +} + model EventTemplate { id String @id @default(cuid()) familyId String diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 106601b..4a42820 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -5,7 +5,7 @@ import { signOut } from "next-auth/react"; import React, { useState, useEffect } from "react"; import { useBaby } from "@/contexts/baby-context"; import { getThresholds, saveThresholds, requestPermission } from "@/lib/notifications"; -import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus, Mail, Send } from "lucide-react"; +import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus, Mail, Send, Key, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react"; function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( @@ -28,6 +28,157 @@ interface Family { const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 dark:text-slate-100"; +const ALL_SCOPES = [ + { value: "events:read", label: "Lire les événements" }, + { value: "events:write", label: "Créer des événements" }, + { value: "growth:read", label: "Lire la croissance" }, + { value: "growth:write", label: "Ajouter des mesures" }, + { value: "milk:read", label: "Lire le stock de lait" }, + { value: "summary:read", label: "Résumé journalier" }, +]; + +function ApiKeysSection() { + const qc = useQueryClient(); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [newScopes, setNewScopes] = useState([]); + const [saving, setSaving] = useState(false); + const [newToken, setNewToken] = useState(null); + const [tokenCopied, setTokenCopied] = useState(false); + + const { data } = useQuery<{ keys: { id: string; name: string; keyPrefix: string; scopes: string[]; lastUsedAt: string | null; createdAt: string }[] }>({ + queryKey: ["api-keys"], + queryFn: () => fetch("/api/api-keys").then((r) => r.json()), + }); + const keys = data?.keys ?? []; + + function toggleScope(s: string) { + setNewScopes((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]); + } + + async function createKey(e: React.FormEvent) { + e.preventDefault(); + if (!newName.trim() || newScopes.length === 0) return; + setSaving(true); + const res = await fetch("/api/api-keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName.trim(), scopes: newScopes }), + }); + const json = await res.json(); + qc.invalidateQueries({ queryKey: ["api-keys"] }); + setNewToken(json.token); + setNewName(""); + setNewScopes([]); + setCreating(false); + setSaving(false); + } + + async function deleteKey(id: string) { + if (!confirm("Révoquer cette clé ?")) return; + await fetch(`/api/api-keys/${id}`, { method: "DELETE" }); + qc.invalidateQueries({ queryKey: ["api-keys"] }); + } + + function copyToken() { + if (!newToken) return; + navigator.clipboard.writeText(newToken); + setTokenCopied(true); + setTimeout(() => setTokenCopied(false), 2000); + } + + return ( +
+
+
+

+ Permettent à des services externes de lire et écrire des données. +

+ + Docs + +
+ + {/* Newly created token — shown once */} + {newToken && ( +
+

Clé créée — copiez-la maintenant, elle ne sera plus affichée.

+
+ + {newToken} + + +
+ +
+ )} + + {/* Existing keys */} + {keys.length > 0 && ( +
+ {keys.map((k) => ( +
+ +
+

{k.name}

+

+ {k.keyPrefix}… · {k.scopes.join(", ")} + {k.lastUsedAt ? ` · utilisée ${new Date(k.lastUsedAt).toLocaleDateString("fr")}` : " · jamais utilisée"} +

+
+ +
+ ))} +
+ )} + + {/* Create form */} + {creating ? ( +
+ setNewName(e.target.value)} + placeholder="Nom (ex: Home Assistant)" className={inputCls} required /> +
+

Permissions

+
+ {ALL_SCOPES.map((s) => ( + + ))} +
+
+
+ + +
+
+ ) : ( + + )} +
+
+ ); +} + export default function SettingsPage() { const qc = useQueryClient(); const { selectedBaby, setSelectedBabyId } = useBaby(); @@ -536,6 +687,9 @@ export default function SettingsPage() { )} + {/* API Keys */} + + {/* Sign out */}