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:
@@ -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 P3–P97
|
||||
<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. P3–P97 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). P3–P97 = plage normale. Hors de cette plage ne signifie pas un problème — consultez votre pédiatre.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 } : {};
|
||||
|
||||
@@ -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'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'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" })}
|
||||
|
||||
@@ -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}%` }}>
|
||||
|
||||
Reference in New Issue
Block a user