feat: REST API v1 + API key management + Swagger docs

**API system**
- ApiKey model: SHA-256-hashed tokens (gw_<hex>), 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:33:25 +02:00
parent 009090b381
commit f59a6c5d8a
13 changed files with 886 additions and 1 deletions
+155 -1
View File
@@ -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<string[]>([]);
const [saving, setSaving] = useState(false);
const [newToken, setNewToken] = useState<string | null>(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 (
<Section title="Clés API">
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-500 dark:text-slate-400">
Permettent à des services externes de lire et écrire des données.
</p>
<a href="/api-docs" target="_blank"
className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400 hover:underline">
<ExternalLink className="w-3 h-3" />Docs
</a>
</div>
{/* Newly created token — shown once */}
{newToken && (
<div className="bg-green-50 dark:bg-green-950/40 border border-green-200 dark:border-green-800 rounded-lg p-3">
<p className="text-xs font-semibold text-green-700 dark:text-green-400 mb-2">Clé créée copiez-la maintenant, elle ne sera plus affichée.</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-[10px] bg-white dark:bg-slate-900 border border-green-200 dark:border-green-800 rounded px-2 py-1.5 break-all text-green-800 dark:text-green-300 select-all">
{newToken}
</code>
<button onClick={copyToken}
className="flex-shrink-0 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-lg text-xs font-medium transition flex items-center gap-1">
{tokenCopied ? <><Check className="w-3 h-3" />Copié</> : <><Copy className="w-3 h-3" />Copier</>}
</button>
</div>
<button onClick={() => setNewToken(null)} className="text-[10px] text-green-600 dark:text-green-500 mt-2 hover:underline">Fermer</button>
</div>
)}
{/* Existing keys */}
{keys.length > 0 && (
<div className="divide-y divide-slate-100 dark:divide-slate-700 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
{keys.map((k) => (
<div key={k.id} className="flex items-center gap-3 px-3 py-2.5 bg-white dark:bg-slate-800">
<Key className="w-4 h-4 text-slate-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{k.name}</p>
<p className="text-[10px] text-slate-400 dark:text-slate-500">
{k.keyPrefix} · {k.scopes.join(", ")}
{k.lastUsedAt ? ` · utilisée ${new Date(k.lastUsedAt).toLocaleDateString("fr")}` : " · jamais utilisée"}
</p>
</div>
<button onClick={() => deleteKey(k.id)}
className="p-1.5 rounded text-slate-300 dark:text-slate-600 hover:text-red-500 transition">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Create form */}
{creating ? (
<form onSubmit={createKey} className="space-y-3 border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<input value={newName} onChange={(e) => setNewName(e.target.value)}
placeholder="Nom (ex: Home Assistant)" className={inputCls} required />
<div>
<p className="text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">Permissions</p>
<div className="grid grid-cols-2 gap-1.5">
{ALL_SCOPES.map((s) => (
<label key={s.value} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={newScopes.includes(s.value)} onChange={() => toggleScope(s.value)}
className="rounded border-slate-300 text-indigo-600" />
<span className="text-xs text-slate-600 dark:text-slate-300">{s.label}</span>
</label>
))}
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving || !newName.trim() || newScopes.length === 0}
className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-xs font-medium disabled:opacity-50">
{saving ? "..." : "Créer"}
</button>
<button type="button" onClick={() => setCreating(false)}
className="px-3 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-xs text-slate-500">
Annuler
</button>
</div>
</form>
) : (
<button onClick={() => setCreating(true)}
className="flex items-center gap-2 text-sm text-indigo-600 dark:text-indigo-400 hover:underline">
<Plus className="w-4 h-4" />
Nouvelle clé API
</button>
)}
</div>
</Section>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
const { selectedBaby, setSelectedBabyId } = useBaby();
@@ -536,6 +687,9 @@ export default function SettingsPage() {
</Section>
)}
{/* API Keys */}
<ApiKeysSection />
{/* Sign out */}
<button
onClick={() => signOut({ callbackUrl: "/auth/login" })}