From 41170d9155544cb95cc72b99ac6974c6c02a88b7 Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Mon, 15 Jun 2026 23:14:28 +0200 Subject: [PATCH] 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 --- docker-compose.yml | 17 ++ prisma/schema.prisma | 24 +++ src/app/(app)/growth/page.tsx | 4 +- src/app/(app)/photos/page.tsx | 14 +- src/app/(app)/settings/page.tsx | 246 ++++++++++++++++++++++- src/app/(app)/stats/page.tsx | 42 +++- src/app/api/doctor-notes/route.ts | 8 + src/app/api/events/route.ts | 8 + src/app/api/family/reset-invite/route.ts | 29 +++ src/app/api/growth/route.ts | 8 + src/app/api/invite/pending/[id]/route.ts | 24 +++ src/app/api/invite/pending/route.ts | 22 ++ src/app/api/invite/redeem/route.ts | 27 +++ src/app/api/invite/send-email/route.ts | 7 +- src/app/api/invite/token-info/route.ts | 22 ++ src/app/api/reminders/check/route.ts | 15 +- src/app/api/webhooks/[id]/route.ts | 41 ++++ src/app/api/webhooks/route.ts | 38 ++++ src/app/auth/invite/t/[token]/page.tsx | 165 +++++++++++++++ src/components/quick-add-fab.tsx | 10 +- src/lib/webhooks.ts | 42 ++++ 21 files changed, 795 insertions(+), 18 deletions(-) create mode 100644 src/app/api/family/reset-invite/route.ts create mode 100644 src/app/api/invite/pending/[id]/route.ts create mode 100644 src/app/api/invite/pending/route.ts create mode 100644 src/app/api/invite/redeem/route.ts create mode 100644 src/app/api/invite/token-info/route.ts create mode 100644 src/app/api/webhooks/[id]/route.ts create mode 100644 src/app/api/webhooks/route.ts create mode 100644 src/app/auth/invite/t/[token]/page.tsx create mode 100644 src/lib/webhooks.ts diff --git a/docker-compose.yml b/docker-compose.yml index c9660e3..6818ddb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5434472..5783b99 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/(app)/growth/page.tsx b/src/app/(app)/growth/page.tsx index 4a6bba9..df03f9d 100644 --- a/src/app/(app)/growth/page.tsx +++ b/src/app/(app)/growth/page.tsx @@ -471,7 +471,6 @@ export default function GrowthPage() { OMS P3–P97 - @@ -487,6 +486,9 @@ export default function GrowthPage() { +

+ 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. +

)} diff --git a/src/app/(app)/photos/page.tsx b/src/app/(app)/photos/page.tsx index 813a3fc..b056e5e 100644 --- a/src/app/(app)/photos/page.tsx +++ b/src/app/(app)/photos/page.tsx @@ -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 = ev ? { ...ev.metadata } : {}; diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index a743141..1373f37 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -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 = { 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 ( +
+

Invitations en attente

+
+ {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 ( +
+
+

{inv.email}

+

+ {ROLE_LABELS[inv.role] ?? inv.role} · {expiresLabel} +

+
+ +
+ ); + })} +
+
+ ); +} + +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 ( + + ); +} + +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([]); + 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 ( + +
+

+ Notifications HTTP vers vos services (n8n, Home Assistant, Zapier…) à chaque événement. +

+ + {revealedSecret && ( +
+

Secret — copiez-le maintenant, il ne sera plus affiché.

+ + {revealedSecret.secret} + +

+ Vérifiez la signature : X-Grow-Signature: sha256=HMAC(secret, body) +

+ +
+ )} + + {hooks.length > 0 && ( +
+ {hooks.map((hook) => ( +
+ +
+

{hook.url}

+

{hook.events.join(", ")}

+
+ + +
+ ))} +
+ )} + + {creating ? ( +
+ setNewUrl(e.target.value)} + placeholder="https://example.com/webhook" + className={inputCls} + required + type="url" + /> +
+

Événements

+
+ {WEBHOOK_EVENTS.map((ev) => ( + + ))} +
+
+
+ + +
+
+ ) : ( + + )} +
+
+ ); +} + export default function SettingsPage() { const qc = useQueryClient(); const { selectedBaby, setSelectedBabyId } = useBaby(); @@ -546,7 +781,10 @@ export default function SettingsPage() { ))} -

Le lien n'expire pas — réinitialisez-le si compromis.

+
+

Le lien n'expire pas — réinitialisez-le si compromis.

+ qc.invalidateQueries({ queryKey: ["family"] })} /> +

Inviter par email

@@ -584,6 +822,7 @@ export default function SettingsPage() {

)} +
@@ -715,6 +954,9 @@ export default function SettingsPage() { {/* API Keys */} + {/* Webhooks */} + + {/* Sign out */} + ))} + + +

Fenêtre {currentOption?.label ?? `${winStartH}h – ${endH}h`}

{markers.map((m) => ( diff --git a/src/app/api/doctor-notes/route.ts b/src/app/api/doctor-notes/route.ts index 2c82b37..3ec8fdd 100644 --- a/src/app/api/doctor-notes/route.ts +++ b/src/app/api/doctor-notes/route.ts @@ -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 }); } diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 8c224d6..15d8ad3 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -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 }); } diff --git a/src/app/api/family/reset-invite/route.ts b/src/app/api/family/reset-invite/route.ts new file mode 100644 index 0000000..45f073d --- /dev/null +++ b/src/app/api/family/reset-invite/route.ts @@ -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 }); +} diff --git a/src/app/api/growth/route.ts b/src/app/api/growth/route.ts index d741f8a..e60f2b9 100644 --- a/src/app/api/growth/route.ts +++ b/src/app/api/growth/route.ts @@ -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 }); } diff --git a/src/app/api/invite/pending/[id]/route.ts b/src/app/api/invite/pending/[id]/route.ts new file mode 100644 index 0000000..3c71543 --- /dev/null +++ b/src/app/api/invite/pending/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/invite/pending/route.ts b/src/app/api/invite/pending/route.ts new file mode 100644 index 0000000..2b1b80a --- /dev/null +++ b/src/app/api/invite/pending/route.ts @@ -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 }); +} diff --git a/src/app/api/invite/redeem/route.ts b/src/app/api/invite/redeem/route.ts new file mode 100644 index 0000000..79b2acc --- /dev/null +++ b/src/app/api/invite/redeem/route.ts @@ -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 }); +} diff --git a/src/app/api/invite/send-email/route.ts b/src/app/api/invite/send-email/route.ts index d63cc27..d5b107f 100644 --- a/src/app/api/invite/send-email/route.ts +++ b/src/app/api/invite/send-email/route.ts @@ -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, diff --git a/src/app/api/invite/token-info/route.ts b/src/app/api/invite/token-info/route.ts new file mode 100644 index 0000000..056b2ed --- /dev/null +++ b/src/app/api/invite/token-info/route.ts @@ -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, + }); +} diff --git a/src/app/api/reminders/check/route.ts b/src/app/api/reminders/check/route.ts index ef9c200..e1ffd18 100644 --- a/src/app/api/reminders/check/route.ts +++ b/src/app/api/reminders/check/route.ts @@ -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 { + 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({ diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts new file mode 100644 index 0000000..b6c769d --- /dev/null +++ b/src/app/api/webhooks/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts new file mode 100644 index 0000000..e046792 --- /dev/null +++ b/src/app/api/webhooks/route.ts @@ -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 }); +} diff --git a/src/app/auth/invite/t/[token]/page.tsx b/src/app/auth/invite/t/[token]/page.tsx new file mode 100644 index 0000000..681f1ec --- /dev/null +++ b/src/app/auth/invite/t/[token]/page.tsx @@ -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 = { + 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(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 ( +
+
+
+ +
+

Lien invalide ou expiré

+

Ce lien d'invitation n'est plus valide. Demandez un nouvel e-mail d'invitation.

+ Retour à la connexion +
+
+ ); + } + + if (!info) { + return ( +
+
+
+ ); + } + + const meta = ROLE_META[info.role] ?? ROLE_META.PARENT; + const expiresDate = new Date(info.expiresAt).toLocaleDateString("fr-FR", { day: "numeric", month: "long" }); + + return ( +
+
+
+
+ +
+

Grow

+

Suivi nouveau-né

+
+ +
+

Rejoindre la famille

+

Lien valide jusqu'au {expiresDate}

+ +
+ + + {meta.label} · {meta.description} + +
+ +
+
+ + setForm({ ...form, name: e.target.value })} required className={inputCls} placeholder="Thomas" /> +
+
+ + setForm({ ...form, email: e.target.value })} required className={inputCls} /> +
+
+ + setForm({ ...form, password: e.target.value })} required minLength={8} className={inputCls} placeholder="8 caractères minimum" /> +
+ + {error && ( +
+ {error} +
+ )} + + +
+ +

+ Déjà un compte ?{" "} + Se connecter +

+
+
+
+ ); +} + +export default function TokenInvitePage({ params }: { params: Promise<{ token: string }> }) { + const { token } = use(params); + return ; +} diff --git a/src/components/quick-add-fab.tsx b/src/components/quick-add-fab.tsx index cf77c8b..997d744 100644 --- a/src/components/quick-add-fab.tsx +++ b/src/components/quick-add-fab.tsx @@ -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 */} - {/* Sheet */} + {/* Sheet — bottom sheet on both mobile and desktop */} {sheetOpen && ( -
+
{/* Backdrop */}
setSheetOpen(false)} /> {/* Panel */} -
+
Enregistrer diff --git a/src/lib/webhooks.ts b/src/lib/webhooks.ts new file mode 100644 index 0000000..276830f --- /dev/null +++ b/src/lib/webhooks.ts @@ -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 +): Promise { + 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), + }); + }) + ); +}