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:
@@ -33,6 +33,7 @@ services:
|
|||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
|
||||||
|
CRON_SECRET: ${CRON_SECRET:-}
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
build:
|
build:
|
||||||
@@ -45,6 +46,22 @@ services:
|
|||||||
DATABASE_URL: "postgresql://grow:${POSTGRES_PASSWORD}@db:5432/grow"
|
DATABASE_URL: "postgresql://grow:${POSTGRES_PASSWORD}@db:5432/grow"
|
||||||
restart: "no"
|
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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
|
|||||||
@@ -92,12 +92,36 @@ model Family {
|
|||||||
babies Baby[]
|
babies Baby[]
|
||||||
eventTemplates EventTemplate[]
|
eventTemplates EventTemplate[]
|
||||||
apiKeys ApiKey[]
|
apiKeys ApiKey[]
|
||||||
|
webhooks Webhook[]
|
||||||
|
pendingInvites PendingInvite[]
|
||||||
pinnedNote String?
|
pinnedNote String?
|
||||||
pinnedNoteUpdatedAt DateTime?
|
pinnedNoteUpdatedAt DateTime?
|
||||||
pinnedNoteAuthor String?
|
pinnedNoteAuthor String?
|
||||||
createdAt DateTime @default(now())
|
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 {
|
model ApiKey {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
familyId String
|
familyId String
|
||||||
|
|||||||
@@ -471,7 +471,6 @@ export default function GrowthPage() {
|
|||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />
|
<span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />
|
||||||
OMS P3–P97
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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} />
|
<Line dataKey="actualWeight" stroke="#4f46e5" strokeWidth={2.5} dot={{ r: 4, fill: "#4f46e5", strokeWidth: 2, stroke: "#fff" }} connectNulls={false} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useBaby } from "@/contexts/baby-context";
|
import { useBaby } from "@/contexts/baby-context";
|
||||||
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
|
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 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) {
|
async function deletePhoto(eventId: string) {
|
||||||
const ev = allEvents.find((e) => e.id === eventId);
|
const ev = allEvents.find((e) => e.id === eventId);
|
||||||
const existingMeta: Record<string, unknown> = ev ? { ...ev.metadata } : {};
|
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 React, { useState, useEffect } from "react";
|
||||||
import { useBaby } from "@/contexts/baby-context";
|
import { useBaby } from "@/contexts/baby-context";
|
||||||
import { getThresholds, saveThresholds, requestPermission } from "@/lib/notifications";
|
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 }) {
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
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() {
|
export default function SettingsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { selectedBaby, setSelectedBabyId } = useBaby();
|
const { selectedBaby, setSelectedBabyId } = useBaby();
|
||||||
@@ -546,7 +781,10 @@ export default function SettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
<PendingInvitesList />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -715,6 +954,9 @@ export default function SettingsPage() {
|
|||||||
{/* API Keys */}
|
{/* API Keys */}
|
||||||
<ApiKeysSection />
|
<ApiKeysSection />
|
||||||
|
|
||||||
|
{/* Webhooks */}
|
||||||
|
<WebhooksSection />
|
||||||
|
|
||||||
{/* Sign out */}
|
{/* Sign out */}
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: "/auth/login" })}
|
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[] }) {
|
function SleepTimeline({ events }: { events: Event[] }) {
|
||||||
const WIN_START_H = 19;
|
const [winStartH, setWinStartH] = useState(19);
|
||||||
const WIN_HOURS = 16; // 19:00 → 11:00 next day
|
const WIN_HOURS = 16;
|
||||||
const WIN_MS = WIN_HOURS * 3600000;
|
const WIN_MS = WIN_HOURS * 3600000;
|
||||||
|
|
||||||
const rows = Array.from({ length: 7 }, (_, i) => {
|
const rows = Array.from({ length: 7 }, (_, i) => {
|
||||||
const day = startOfDay(subDays(new Date(), 6 - 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 winEnd = winStart + WIN_MS;
|
||||||
|
|
||||||
const blocks = events
|
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`,
|
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 (
|
return (
|
||||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
|
<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>
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Fenêtre 19h – 11h</p>
|
<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">
|
<div className="relative h-4 ml-10 mb-0.5">
|
||||||
{markers.map((m) => (
|
{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}%` }}>
|
<span key={m.label} className="absolute text-[9px] text-slate-400 dark:text-slate-500 -translate-x-1/2" style={{ left: `${m.pos}%` }}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
@@ -53,5 +54,12 @@ export async function POST(req: Request) {
|
|||||||
include: { user: { select: { id: true, name: true } } },
|
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 });
|
return NextResponse.json(note, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { logAudit } from "@/lib/audit";
|
import { logAudit } from "@/lib/audit";
|
||||||
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const session = await auth();
|
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}` });
|
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 });
|
return NextResponse.json(event, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const session = await auth();
|
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 });
|
return NextResponse.json(log, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -31,9 +31,14 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? "";
|
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 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 {
|
try {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to: email,
|
to: email,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,20 @@ import { NextResponse } from "next/server";
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { notifyFamilyFeedAlert } from "@/lib/push-notify";
|
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 now = new Date();
|
||||||
|
|
||||||
const reminders = await prisma.medicationReminder.findMany({
|
const reminders = await prisma.medicationReminder.findMany({
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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'invitation n'est plus valide. Demandez un nouvel e-mail d'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-né</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'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} />;
|
||||||
|
}
|
||||||
@@ -39,25 +39,25 @@ export function QuickAddFab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* FAB — only on mobile, hidden on md+ (sidebar handles desktop) */}
|
{/* FAB — mobile: above nav bar; desktop: bottom-right corner */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setSheetOpen((v) => !v)}
|
onClick={() => setSheetOpen((v) => !v)}
|
||||||
aria-label="Enregistrer une action"
|
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" />}
|
{sheetOpen ? <X className="w-6 h-6" /> : <Plus className="w-6 h-6" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Sheet */}
|
{/* Sheet — bottom sheet on both mobile and desktop */}
|
||||||
{sheetOpen && (
|
{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 */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/40"
|
className="absolute inset-0 bg-black/40"
|
||||||
onClick={() => setSheetOpen(false)}
|
onClick={() => setSheetOpen(false)}
|
||||||
/>
|
/>
|
||||||
{/* Panel */}
|
{/* 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">
|
<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">
|
<span className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||||
Enregistrer
|
Enregistrer
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user