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
+17
View File
@@ -33,6 +33,7 @@ services:
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
CRON_SECRET: ${CRON_SECRET:-}
migrate:
build:
@@ -45,6 +46,22 @@ services:
DATABASE_URL: "postgresql://grow:${POSTGRES_PASSWORD}@db:5432/grow"
restart: "no"
cron:
image: alpine:3.19
restart: unless-stopped
depends_on:
- app
environment:
APP_URL: "http://app:3000"
CRON_SECRET: ${CRON_SECRET:-}
command: >
sh -c "
echo '*/15 * * * * wget -qO- --header=\"Authorization: Bearer $$CRON_SECRET\" --post-data= $$APP_URL/api/reminders/check > /dev/null 2>&1' > /etc/crontabs/root &&
echo '0 8 * * * wget -qO- --header=\"Authorization: Bearer $$CRON_SECRET\" --post-data= $$APP_URL/api/cron/milk-expiry > /dev/null 2>&1' >> /etc/crontabs/root &&
echo '0 7 * * * wget -qO- --header=\"Authorization: Bearer $$CRON_SECRET\" --post-data= $$APP_URL/api/cron/daily-digest > /dev/null 2>&1' >> /etc/crontabs/root &&
crond -f -l 8
"
volumes:
postgres_data:
uploads_data:
+24
View File
@@ -92,12 +92,36 @@ model Family {
babies Baby[]
eventTemplates EventTemplate[]
apiKeys ApiKey[]
webhooks Webhook[]
pendingInvites PendingInvite[]
pinnedNote String?
pinnedNoteUpdatedAt DateTime?
pinnedNoteAuthor String?
createdAt DateTime @default(now())
}
model PendingInvite {
id String @id @default(cuid())
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
email String
role UserRole @default(PARENT)
token String @unique @default(cuid())
expiresAt DateTime
sentAt DateTime @default(now())
}
model Webhook {
id String @id @default(cuid())
familyId String
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
url String
secret String
events String[]
active Boolean @default(true)
createdAt DateTime @default(now())
}
model ApiKey {
id String @id @default(cuid())
familyId String
+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}%` }}>
+8
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { dispatchWebhook } from "@/lib/webhooks";
export async function GET(req: Request) {
const session = await auth();
@@ -53,5 +54,12 @@ export async function POST(req: Request) {
include: { user: { select: { id: true, name: true } } },
});
const familyId = (session.user as { familyId?: string }).familyId;
if (familyId) {
dispatchWebhook(familyId, "doctor_note.created", {
note: { id: note.id, babyId: note.babyId, date: note.date, reason: note.reason },
}).catch(() => {});
}
return NextResponse.json(note, { status: 201 });
}
+8
View File
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/audit";
import { dispatchWebhook } from "@/lib/webhooks";
export async function GET(req: Request) {
const session = await auth();
@@ -66,5 +67,12 @@ export async function POST(req: Request) {
await logAudit(session, { action: "event.create", targetId: event.id, detail: `${type} pour bébé ${babyId}` });
const familyId = (session.user as { familyId?: string }).familyId;
if (familyId) {
dispatchWebhook(familyId, "event.created", {
event: { id: event.id, type: event.type, startedAt: event.startedAt, endedAt: event.endedAt, babyId: event.babyId },
}).catch(() => {});
}
return NextResponse.json(event, { status: 201 });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
function generateInviteCode(): string {
return crypto.randomUUID().replace(/-/g, "").slice(0, 25);
}
export async function POST() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { familyId: true, role: true },
});
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
if (user.role !== "PARENT") return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 });
const inviteCode = generateInviteCode();
await prisma.family.update({
where: { id: user.familyId },
data: { inviteCode },
});
return NextResponse.json({ inviteCode });
}
+8
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { dispatchWebhook } from "@/lib/webhooks";
export async function GET(req: Request) {
const session = await auth();
@@ -39,5 +40,12 @@ export async function POST(req: Request) {
},
});
const familyId = (session.user as { familyId?: string }).familyId;
if (familyId) {
dispatchWebhook(familyId, "growth.created", {
log: { id: log.id, babyId: log.babyId, date: log.date, weight: log.weight, height: log.height },
}).catch(() => {});
}
return NextResponse.json(log, { status: 201 });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { familyId: true },
});
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
const { id } = await params;
const invite = await prisma.pendingInvite.findFirst({
where: { id, familyId: user.familyId },
});
if (!invite) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
await prisma.pendingInvite.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET() {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { familyId: true },
});
if (!user?.familyId) return NextResponse.json({ invites: [] });
const invites = await prisma.pendingInvite.findMany({
where: { familyId: user.familyId },
orderBy: { sentAt: "desc" },
select: { id: true, email: true, role: true, expiresAt: true, sentAt: true },
});
return NextResponse.json({ invites });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const { name, email, password, token } = await req.json();
if (!name || !email || !password || !token) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
}
const invite = await prisma.pendingInvite.findUnique({ where: { token } });
if (!invite) return NextResponse.json({ error: "Lien d'invitation invalide" }, { status: 404 });
if (invite.expiresAt < new Date()) return NextResponse.json({ error: "Lien d'invitation expiré" }, { status: 410 });
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) return NextResponse.json({ error: "Email déjà utilisé" }, { status: 409 });
const hashed = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: { name, email, password: hashed, familyId: invite.familyId, role: invite.role },
});
await prisma.pendingInvite.delete({ where: { token } });
return NextResponse.json({ id: user.id, email: user.email, name: user.name, role: user.role });
}
+6 -1
View File
@@ -31,9 +31,14 @@ export async function POST(req: Request) {
}
const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? "";
const inviteUrl = `${origin}/auth/invite/${user.family.inviteCode}?role=${role}`;
const assignedRole = role as Role;
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000);
const pending = await prisma.pendingInvite.create({
data: { familyId: user.family.id, email, role: assignedRole, expiresAt },
});
const inviteUrl = `${origin}/auth/invite/t/${pending.token}`;
try {
await sendEmail({
to: email,
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const token = new URL(req.url).searchParams.get("token");
if (!token) return NextResponse.json({ error: "Token manquant" }, { status: 400 });
const invite = await prisma.pendingInvite.findUnique({
where: { token },
include: { family: { select: { name: true } } },
});
if (!invite) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
if (invite.expiresAt < new Date()) return NextResponse.json({ error: "Expiré" }, { status: 410 });
return NextResponse.json({
email: invite.email,
role: invite.role,
expiresAt: invite.expiresAt,
familyName: invite.family.name,
});
}
+14 -1
View File
@@ -2,7 +2,20 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { notifyFamilyFeedAlert } from "@/lib/push-notify";
export async function POST() {
async function getCronSecret(): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
return row?.value || process.env.CRON_SECRET || "";
}
export async function POST(req: Request) {
const secret = await getCronSecret();
if (secret) {
const authHeader = req.headers.get("authorization") ?? "";
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
}
const now = new Date();
const reminders = await prisma.medicationReminder.findMany({
+41
View File
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
if (!session?.user?.id || !familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const body = await req.json();
const hook = await prisma.webhook.findFirst({ where: { id, familyId } });
if (!hook) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
const updated = await prisma.webhook.update({
where: { id },
data: {
...(body.url !== undefined && { url: body.url }),
...(body.events !== undefined && { events: body.events }),
...(body.active !== undefined && { active: body.active }),
},
select: { id: true, url: true, events: true, active: true, createdAt: true },
});
return NextResponse.json({ webhook: updated });
}
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
if (!session?.user?.id || !familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const hook = await prisma.webhook.findFirst({ where: { id, familyId } });
if (!hook) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
await prisma.webhook.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { generateWebhookSecret } from "@/lib/webhooks";
export async function GET() {
const session = await auth();
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
if (!session?.user?.id || !familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const webhooks = await prisma.webhook.findMany({
where: { familyId },
orderBy: { createdAt: "asc" },
select: { id: true, url: true, events: true, active: true, createdAt: true },
});
return NextResponse.json({ webhooks });
}
export async function POST(req: Request) {
const session = await auth();
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
if (!session?.user?.id || !familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { url, events } = await req.json();
if (!url || !events?.length) return NextResponse.json({ error: "url et events requis" }, { status: 400 });
try { new URL(url); } catch { return NextResponse.json({ error: "URL invalide" }, { status: 400 }); }
const secret = generateWebhookSecret();
const webhook = await prisma.webhook.create({
data: { familyId, url, events, secret },
select: { id: true, url: true, events: true, active: true, createdAt: true, secret: true },
});
return NextResponse.json({ webhook }, { status: 201 });
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useState, use, useEffect } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Leaf, Check, AlertTriangle } from "lucide-react";
const ROLE_META: Record<string, { label: string; description: string; color: string }> = {
PARENT: {
label: "Accès parent",
description: "toutes les fonctionnalités",
color: "bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border-indigo-200 dark:border-indigo-800",
},
READONLY: {
label: "Accès consultation",
description: "lecture seule",
color: "bg-slate-50 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-200 dark:border-slate-700",
},
DOCTOR: {
label: "Accès médecin",
description: "consultation + notes cliniques",
color: "bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-800",
},
};
const inputCls =
"w-full px-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-400 transition text-sm";
interface InviteInfo {
email: string;
role: string;
expiresAt: string;
familyName?: string;
}
function TokenInviteForm({ token }: { token: string }) {
const router = useRouter();
const [info, setInfo] = useState<InviteInfo | null>(null);
const [invalid, setInvalid] = useState(false);
const [form, setForm] = useState({ name: "", email: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
fetch(`/api/invite/token-info?token=${encodeURIComponent(token)}`)
.then((r) => r.json())
.then((data) => {
if (data.error || !data.email) { setInvalid(true); return; }
setInfo(data);
setForm((f) => ({ ...f, email: data.email }));
})
.catch(() => setInvalid(true));
}, [token]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/invite/redeem", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, token }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur lors de l'inscription");
setLoading(false);
return;
}
await signIn("credentials", { email: form.email, password: form.password, redirect: false });
router.push("/dashboard");
}
if (invalid) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
<div className="w-full max-w-md text-center">
<div className="inline-flex items-center justify-center w-12 h-12 bg-red-100 dark:bg-red-950 rounded-2xl mb-4">
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
</div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-2">Lien invalide ou expiré</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">Ce lien d&apos;invitation n&apos;est plus valide. Demandez un nouvel e-mail d&apos;invitation.</p>
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 text-sm font-medium hover:underline">Retour à la connexion</Link>
</div>
</div>
);
}
if (!info) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
const meta = ROLE_META[info.role] ?? ROLE_META.PARENT;
const expiresDate = new Date(info.expiresAt).toLocaleDateString("fr-FR", { day: "numeric", month: "long" });
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-12 h-12 bg-indigo-600 rounded-2xl mb-4">
<Leaf className="w-6 h-6 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Grow</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Suivi nouveau-</p>
</div>
<div className="bg-white dark:bg-slate-900 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-800 p-8">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-1">Rejoindre la famille</h2>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-5">Lien valide jusqu&apos;au {expiresDate}</p>
<div className={`flex items-center gap-2 px-3 py-2.5 rounded-xl border mb-6 ${meta.color}`}>
<Check className="w-4 h-4 flex-shrink-0" />
<span className="text-sm font-medium">
{meta.label} <span className="font-normal opacity-75">· {meta.description}</span>
</span>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Prénom</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required className={inputCls} placeholder="Thomas" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Email</label>
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Mot de passe</label>
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={8} className={inputCls} placeholder="8 caractères minimum" />
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-900 px-4 py-3 rounded-xl text-sm">
{error}
</div>
)}
<button type="submit" disabled={loading}
className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 dark:disabled:bg-indigo-900 text-white font-semibold py-3 rounded-xl transition text-sm">
{loading ? "Rejoindre..." : "Rejoindre la famille"}
</button>
</form>
<p className="text-center text-sm text-slate-500 dark:text-slate-400 mt-6">
Déjà un compte ?{" "}
<Link href="/auth/login" className="text-indigo-600 dark:text-indigo-400 font-medium hover:underline">Se connecter</Link>
</p>
</div>
</div>
</div>
);
}
export default function TokenInvitePage({ params }: { params: Promise<{ token: string }> }) {
const { token } = use(params);
return <TokenInviteForm token={token} />;
}
+5 -5
View File
@@ -39,25 +39,25 @@ export function QuickAddFab() {
return (
<>
{/* FAB — only on mobile, hidden on md+ (sidebar handles desktop) */}
{/* FAB — mobile: above nav bar; desktop: bottom-right corner */}
<button
onClick={() => setSheetOpen((v) => !v)}
aria-label="Enregistrer une action"
className="md:hidden fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] right-4 z-40 w-14 h-14 rounded-full bg-indigo-600 text-white shadow-lg flex items-center justify-center active:scale-95 transition-transform"
className="fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] right-4 md:bottom-8 md:right-8 z-40 w-14 h-14 rounded-full bg-indigo-600 text-white shadow-lg flex items-center justify-center active:scale-95 transition-transform"
>
{sheetOpen ? <X className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
</button>
{/* Sheet */}
{/* Sheet — bottom sheet on both mobile and desktop */}
{sheetOpen && (
<div className="md:hidden fixed inset-0 z-30 flex flex-col justify-end">
<div className="fixed inset-0 z-30 flex flex-col justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => setSheetOpen(false)}
/>
{/* Panel */}
<div className="relative bg-white dark:bg-slate-900 rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+5rem)] z-10">
<div className="relative bg-white dark:bg-slate-900 rounded-t-2xl pb-[calc(env(safe-area-inset-bottom)+5rem)] md:pb-6 z-10 md:max-w-lg md:mx-auto md:w-full md:mb-0 md:rounded-2xl md:shadow-2xl">
<div className="px-4 pt-4 pb-3 flex items-center justify-between">
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
Enregistrer
+42
View File
@@ -0,0 +1,42 @@
import { createHmac, randomBytes } from "crypto";
import { prisma } from "./prisma";
export type WebhookEventType =
| "event.created"
| "growth.created"
| "doctor_note.created"
| "milestone.created"
| "medication.due";
export function generateWebhookSecret(): string {
return randomBytes(32).toString("hex");
}
export async function dispatchWebhook(
familyId: string,
eventType: WebhookEventType,
payload: Record<string, unknown>
): Promise<void> {
const hooks = await prisma.webhook.findMany({
where: { familyId, active: true, events: { has: eventType } },
});
if (hooks.length === 0) return;
const body = JSON.stringify({ event: eventType, ...payload });
await Promise.allSettled(
hooks.map(async (hook) => {
const sig = createHmac("sha256", hook.secret).update(body).digest("hex");
await fetch(hook.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Grow-Signature": `sha256=${sig}`,
"X-Grow-Event": eventType,
},
body,
signal: AbortSignal.timeout(10_000),
});
})
);
}