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:
@@ -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" })}
|
||||
|
||||
Reference in New Issue
Block a user