feat: UX improvements, webhooks, invite management

- Photo lightbox: ArrowLeft/Right/Escape keyboard navigation
- Growth chart: persistent WHO percentile legend (not hover-only)
- Sleep stats: configurable window selector (18h/19h/20h/21h)
- Quick-add FAB: now visible on desktop (bottom-right corner)
- Invite code reset button in settings (non-admin, PARENT role only)
- Pending email invites: tracked per-token with 7-day expiry, revocable
  from settings UI; new /auth/invite/t/[token] accept page
- Webhooks: Prisma model, CRUD API, HMAC-SHA256 dispatcher wired into
  event.created / growth.created / doctor_note.created; settings UI
- Docker cron service: medication reminders every 15min, milk-expiry
  and daily-digest daily; secured with CRON_SECRET
- Photo uploads persist across redeploys via Docker named volume

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:14:28 +02:00
parent 3f08313bc2
commit 41170d9155
21 changed files with 795 additions and 18 deletions
+3 -1
View File
@@ -471,7 +471,6 @@ export default function GrowthPage() {
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />
OMS P3P97
<span className="text-slate-300 dark:text-slate-600 text-[10px]" title="P50 = médiane : 50% des bébés ont un poids inférieur. P3P97 représente la plage normale OMS."></span>
</span>
</div>
</div>
@@ -487,6 +486,9 @@ export default function GrowthPage() {
<Line dataKey="actualWeight" stroke="#4f46e5" strokeWidth={2.5} dot={{ r: 4, fill: "#4f46e5", strokeWidth: 2, stroke: "#fff" }} connectNulls={false} />
</LineChart>
</ResponsiveContainer>
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-2">
P50 = médiane OMS (50% des bébés en dessous). P3P97 = plage normale. Hors de cette plage ne signifie pas un problème consultez votre pédiatre.
</p>
</div>
)}
+13 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useBaby } from "@/contexts/baby-context";
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
@@ -68,6 +68,18 @@ export default function PhotosPage() {
const current = lightbox ? lightbox.events[lightbox.index] : null;
const handleKey = useCallback((e: KeyboardEvent) => {
if (!lightbox) return;
if (e.key === "ArrowLeft") { e.preventDefault(); prev(); }
else if (e.key === "ArrowRight") { e.preventDefault(); next(); }
else if (e.key === "Escape") setLightbox(null);
}, [lightbox]);
useEffect(() => {
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [handleKey]);
async function deletePhoto(eventId: string) {
const ev = allEvents.find((e) => e.id === eventId);
const existingMeta: Record<string, unknown> = ev ? { ...ev.metadata } : {};
+244 -2
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, ChevronDown, FileText, Plus, Mail, Send, Key, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, ChevronDown, FileText, Plus, Mail, Send, Key, Trash2, Eye, EyeOff, ExternalLink, Webhook, ToggleLeft, ToggleRight, RefreshCw } from "lucide-react";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
@@ -192,6 +192,241 @@ function ApiKeysSection() {
);
}
const ROLE_LABELS: Record<string, string> = { PARENT: "Parent", READONLY: "Lecture", DOCTOR: "Médecin" };
interface PendingInvite {
id: string;
email: string;
role: string;
expiresAt: string;
sentAt: string;
}
function PendingInvitesList() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<{ invites: PendingInvite[] }>({
queryKey: ["pending-invites"],
queryFn: () => fetch("/api/invite/pending").then((r) => r.json()),
});
const invites = data?.invites ?? [];
async function revoke(id: string, email: string) {
if (!confirm(`Révoquer l'invitation de ${email} ?`)) return;
await fetch(`/api/invite/pending/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["pending-invites"] });
}
if (isLoading || invites.length === 0) return null;
return (
<div className="mt-3">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Invitations en attente</p>
<div className="divide-y divide-slate-100 dark:divide-slate-700 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
{invites.map((inv) => {
const expired = new Date(inv.expiresAt) < new Date();
const expiresLabel = expired
? "Expirée"
: `Expire le ${new Date(inv.expiresAt).toLocaleDateString("fr-FR", { day: "numeric", month: "short" })}`;
return (
<div key={inv.id} className="flex items-center gap-3 px-3 py-2.5 bg-white dark:bg-slate-800">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 truncate">{inv.email}</p>
<p className="text-[10px] text-slate-400 dark:text-slate-500">
{ROLE_LABELS[inv.role] ?? inv.role} · <span className={expired ? "text-red-500" : ""}>{expiresLabel}</span>
</p>
</div>
<button
onClick={() => revoke(inv.id, inv.email)}
className="p-1.5 rounded text-slate-300 dark:text-slate-600 hover:text-red-500 transition"
title="Révoquer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
</div>
);
}
function ResetInviteButton({ onReset }: { onReset: () => void }) {
const [loading, setLoading] = useState(false);
async function reset() {
if (!confirm("Réinitialiser le code d'invitation ? Les anciens liens ne fonctionneront plus.")) return;
setLoading(true);
const res = await fetch("/api/family/reset-invite", { method: "POST" });
if (res.ok) onReset();
setLoading(false);
}
return (
<button
onClick={reset}
disabled={loading}
className="flex items-center gap-1 text-xs text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition disabled:opacity-50"
>
<RefreshCw className={`w-3 h-3 ${loading ? "animate-spin" : ""}`} />
Réinitialiser
</button>
);
}
const WEBHOOK_EVENTS = [
{ value: "event.created", label: "Événement créé" },
{ value: "growth.created", label: "Mesure de croissance" },
{ value: "doctor_note.created", label: "Note médecin" },
{ value: "milestone.created", label: "Étape créée" },
];
interface WebhookRecord {
id: string;
url: string;
events: string[];
active: boolean;
createdAt: string;
}
function WebhooksSection() {
const qc = useQueryClient();
const [creating, setCreating] = useState(false);
const [newUrl, setNewUrl] = useState("");
const [newEvents, setNewEvents] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [revealedSecret, setRevealedSecret] = useState<{ id: string; secret: string } | null>(null);
const { data } = useQuery<{ webhooks: WebhookRecord[] }>({
queryKey: ["webhooks"],
queryFn: () => fetch("/api/webhooks").then((r) => r.json()),
});
const hooks = data?.webhooks ?? [];
function toggleEvent(v: string) {
setNewEvents((prev) => prev.includes(v) ? prev.filter((x) => x !== v) : [...prev, v]);
}
async function createHook(e: React.FormEvent) {
e.preventDefault();
if (!newUrl.trim() || newEvents.length === 0) return;
setSaving(true);
const res = await fetch("/api/webhooks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: newUrl.trim(), events: newEvents }),
});
const json = await res.json();
if (json.webhook?.secret) setRevealedSecret({ id: json.webhook.id, secret: json.webhook.secret });
qc.invalidateQueries({ queryKey: ["webhooks"] });
setNewUrl("");
setNewEvents([]);
setCreating(false);
setSaving(false);
}
async function toggleActive(hook: WebhookRecord) {
await fetch(`/api/webhooks/${hook.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: !hook.active }),
});
qc.invalidateQueries({ queryKey: ["webhooks"] });
}
async function deleteHook(id: string) {
if (!confirm("Supprimer ce webhook ?")) return;
await fetch(`/api/webhooks/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["webhooks"] });
if (revealedSecret?.id === id) setRevealedSecret(null);
}
return (
<SectionCard title="Webhooks">
<div className="px-4 py-3 space-y-3">
<p className="text-xs text-slate-500 dark:text-slate-400">
Notifications HTTP vers vos services (n8n, Home Assistant, Zapier) à chaque événement.
</p>
{revealedSecret && (
<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-1">Secret copiez-le maintenant, il ne sera plus affiché.</p>
<code className="block 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">
{revealedSecret.secret}
</code>
<p className="text-[10px] text-green-600 dark:text-green-500 mt-1.5">
Vérifiez la signature : <code>X-Grow-Signature: sha256=HMAC(secret, body)</code>
</p>
<button onClick={() => setRevealedSecret(null)} className="text-[10px] text-green-600 dark:text-green-500 mt-1 hover:underline">Fermer</button>
</div>
)}
{hooks.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">
{hooks.map((hook) => (
<div key={hook.id} className="flex items-center gap-3 px-3 py-2.5 bg-white dark:bg-slate-800">
<Webhook 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 truncate">{hook.url}</p>
<p className="text-[10px] text-slate-400 dark:text-slate-500">{hook.events.join(", ")}</p>
</div>
<button onClick={() => toggleActive(hook)} className="text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition">
{hook.active
? <ToggleRight className="w-5 h-5 text-indigo-600 dark:text-indigo-400" />
: <ToggleLeft className="w-5 h-5" />}
</button>
<button onClick={() => deleteHook(hook.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>
)}
{creating ? (
<form onSubmit={createHook} className="space-y-3 border border-slate-200 dark:border-slate-700 rounded-lg p-3">
<input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://example.com/webhook"
className={inputCls}
required
type="url"
/>
<div>
<p className="text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">Événements</p>
<div className="grid grid-cols-2 gap-1.5">
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev.value} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={newEvents.includes(ev.value)} onChange={() => toggleEvent(ev.value)}
className="rounded border-slate-300 text-indigo-600" />
<span className="text-xs text-slate-600 dark:text-slate-300">{ev.label}</span>
</label>
))}
</div>
</div>
<div className="flex gap-2">
<button type="submit" disabled={saving || !newUrl.trim() || newEvents.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" />
Nouveau webhook
</button>
)}
</div>
</SectionCard>
);
}
export default function SettingsPage() {
const qc = useQueryClient();
const { selectedBaby, setSelectedBabyId } = useBaby();
@@ -546,7 +781,10 @@ export default function SettingsPage() {
))}
</div>
<p className="text-xs text-slate-400 mt-1">Le lien n&apos;expire pas réinitialisez-le si compromis.</p>
<div className="flex items-center justify-between mt-1">
<p className="text-xs text-slate-400">Le lien n&apos;expire pas réinitialisez-le si compromis.</p>
<ResetInviteButton onReset={() => qc.invalidateQueries({ queryKey: ["family"] })} />
</div>
<div className="pt-3 border-t border-slate-100 dark:border-slate-700 mt-3">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-3">Inviter par email</p>
@@ -584,6 +822,7 @@ export default function SettingsPage() {
</p>
)}
</form>
<PendingInvitesList />
</div>
</div>
</SectionCard>
@@ -715,6 +954,9 @@ export default function SettingsPage() {
{/* API Keys */}
<ApiKeysSection />
{/* Webhooks */}
<WebhooksSection />
{/* Sign out */}
<button
onClick={() => signOut({ callbackUrl: "/auth/login" })}
+35 -7
View File
@@ -223,14 +223,21 @@ function FeedHeatmap({ events, isDark }: { events: Event[]; isDark: boolean }) {
);
}
const SLEEP_WINDOW_OPTIONS = [
{ label: "18h 10h", startH: 18 },
{ label: "19h 11h", startH: 19 },
{ label: "20h 12h", startH: 20 },
{ label: "21h 13h", startH: 21 },
];
function SleepTimeline({ events }: { events: Event[] }) {
const WIN_START_H = 19;
const WIN_HOURS = 16; // 19:00 → 11:00 next day
const [winStartH, setWinStartH] = useState(19);
const WIN_HOURS = 16;
const WIN_MS = WIN_HOURS * 3600000;
const rows = Array.from({ length: 7 }, (_, i) => {
const day = startOfDay(subDays(new Date(), 6 - i));
const winStart = day.getTime() + WIN_START_H * 3600000;
const winStart = day.getTime() + winStartH * 3600000;
const winEnd = winStart + WIN_MS;
const blocks = events
@@ -250,15 +257,36 @@ function SleepTimeline({ events }: { events: Event[] }) {
};
});
const markers = [20, 22, 0, 2, 4, 6, 8, 10].map((h) => ({
const endH = (winStartH + WIN_HOURS) % 24;
const markerHours = Array.from({ length: WIN_HOURS / 2 }, (_, i) => (winStartH + 1 + i * 2) % 24);
const markers = markerHours.map((h) => ({
label: h === 0 ? "00h" : `${h}h`,
pos: ((h < WIN_START_H ? h + 24 : h) - WIN_START_H) / WIN_HOURS * 100,
pos: ((h < winStartH ? h + 24 : h) - winStartH) / WIN_HOURS * 100,
}));
const currentOption = SLEEP_WINDOW_OPTIONS.find((o) => o.startH === winStartH);
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-0.5">Nuits 7 derniers jours</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Fenêtre 19h 11h</p>
<div className="flex items-center justify-between mb-0.5">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Nuits 7 derniers jours</p>
<div className="flex gap-1">
{SLEEP_WINDOW_OPTIONS.map((o) => (
<button
key={o.startH}
onClick={() => setWinStartH(o.startH)}
className={`px-2 py-0.5 rounded text-[10px] font-medium transition ${
o.startH === winStartH
? "bg-indigo-600 text-white"
: "text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300"
}`}
>
{o.label}
</button>
))}
</div>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Fenêtre {currentOption?.label ?? `${winStartH}h ${endH}h`}</p>
<div className="relative h-4 ml-10 mb-0.5">
{markers.map((m) => (
<span key={m.label} className="absolute text-[9px] text-slate-400 dark:text-slate-500 -translate-x-1/2" style={{ left: `${m.pos}%` }}>