diff --git a/prisma/migrations/20260601060000_password_reset_token/migration.sql b/prisma/migrations/20260601060000_password_reset_token/migration.sql
new file mode 100644
index 0000000..50033de
--- /dev/null
+++ b/prisma/migrations/20260601060000_password_reset_token/migration.sql
@@ -0,0 +1,9 @@
+CREATE TABLE "PasswordResetToken" (
+ "tokenHash" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "expiresAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("tokenHash")
+);
+CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");
+CREATE INDEX "PasswordResetToken_expiresAt_idx" ON "PasswordResetToken"("expiresAt");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e9aaf6d..f59864e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -361,3 +361,13 @@ model Translation {
@@id([key, lang])
@@index([lang])
}
+
+model PasswordResetToken {
+ tokenHash String @id
+ userId String
+ expiresAt DateTime
+ createdAt DateTime @default(now())
+
+ @@index([userId])
+ @@index([expiresAt])
+}
diff --git a/src/app/api/me/export/route.ts b/src/app/api/me/export/route.ts
new file mode 100644
index 0000000..a235301
--- /dev/null
+++ b/src/app/api/me/export/route.ts
@@ -0,0 +1,103 @@
+import { NextResponse } from "next/server";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+/** RGPD article 20 — droit à la portabilité. Renvoie un JSON avec toutes les données utilisateur. */
+export async function GET() {
+ const session = await auth();
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
+ }
+ const userId = session.user.id;
+
+ const [user, bookings, reviews, carbets, subscriptions] = await Promise.all([
+ prisma.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ email: true,
+ firstName: true,
+ lastName: true,
+ phone: true,
+ role: true,
+ avatarUrl: true,
+ isActive: true,
+ createdAt: true,
+ updatedAt: true,
+ organizationId: true,
+ },
+ }),
+ prisma.booking.findMany({
+ where: { tenantId: userId },
+ select: {
+ id: true,
+ carbetId: true,
+ startDate: true,
+ endDate: true,
+ guestCount: true,
+ status: true,
+ paymentStatus: true,
+ amount: true,
+ currency: true,
+ createdAt: true,
+ },
+ }),
+ prisma.review.findMany({
+ where: { authorId: userId },
+ select: {
+ id: true,
+ bookingId: true,
+ carbetId: true,
+ rating: true,
+ comment: true,
+ createdAt: true,
+ },
+ }),
+ prisma.carbet.findMany({
+ where: { ownerId: userId },
+ select: { id: true, slug: true, title: true, status: true, createdAt: true },
+ }),
+ prisma.subscription.findMany({
+ where: { ownerId: userId },
+ select: { id: true, carbetId: true, status: true, provider: true, startedAt: true },
+ }),
+ ]);
+
+ await recordAudit({
+ scope: "public.profile",
+ event: "data.export",
+ target: userId,
+ actorEmail: session.user.email ?? null,
+ details: {},
+ });
+
+ const filename = `karbe-mes-donnees-${new Date().toISOString().slice(0, 10)}.json`;
+ return new NextResponse(
+ JSON.stringify(
+ {
+ exportedAt: new Date().toISOString(),
+ rgpdNotice:
+ "Conformément à l'article 20 du RGPD. Pour exercer vos autres droits, contactez contact@karbe.cosmolan.fr.",
+ user,
+ bookings,
+ reviews,
+ carbets,
+ subscriptions,
+ },
+ null,
+ 2,
+ ),
+ {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Content-Disposition": `attachment; filename="${filename}"`,
+ },
+ },
+ );
+}
diff --git a/src/app/api/password/reset-request/route.ts b/src/app/api/password/reset-request/route.ts
new file mode 100644
index 0000000..1eaedc9
--- /dev/null
+++ b/src/app/api/password/reset-request/route.ts
@@ -0,0 +1,50 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { createPasswordResetToken } from "@/lib/password-reset";
+import { prisma } from "@/lib/prisma";
+import { sendPasswordReset } from "@/lib/email";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ email: z.string().trim().toLowerCase().email(),
+});
+
+const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://karbe.cosmolan.fr";
+
+export async function POST(req: Request) {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ // Réponse générique pour ne pas leak la validité du format à un attaquant.
+ return NextResponse.json({ ok: true });
+ }
+
+ const user = await prisma.user.findUnique({
+ where: { email: parsed.data.email },
+ select: { id: true, email: true, firstName: true, isActive: true },
+ });
+
+ if (user && user.isActive) {
+ const token = await createPasswordResetToken(user.id);
+ const resetUrl = `${SITE_URL}/mot-de-passe-oublie/${token}`;
+ sendPasswordReset(user.email, resetUrl).catch(() => {});
+ await recordAudit({
+ scope: "public.password",
+ event: "reset.request",
+ target: user.id,
+ actorEmail: user.email,
+ details: {},
+ });
+ }
+
+ // Réponse identique que l'email existe ou non (énumération-safe).
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/password/reset/route.ts b/src/app/api/password/reset/route.ts
new file mode 100644
index 0000000..1883076
--- /dev/null
+++ b/src/app/api/password/reset/route.ts
@@ -0,0 +1,40 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+
+import { consumePasswordResetToken } from "@/lib/password-reset";
+import { recordAudit } from "@/lib/admin/audit";
+
+export const runtime = "nodejs";
+
+const schema = z.object({
+ token: z.string().min(20).max(200),
+ password: z.string().min(8).max(200),
+});
+
+export async function POST(req: Request) {
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ return NextResponse.json({ error: "Corps JSON invalide." }, { status: 400 });
+ }
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues.map((i) => i.message).join(" · ") },
+ { status: 400 },
+ );
+ }
+ const result = await consumePasswordResetToken(parsed.data.token, parsed.data.password);
+ if (!result.ok) {
+ return NextResponse.json({ error: result.reason }, { status: 400 });
+ }
+ await recordAudit({
+ scope: "public.password",
+ event: "reset.success",
+ target: result.userId,
+ actorEmail: null,
+ details: {},
+ });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/connexion/page.tsx b/src/app/connexion/page.tsx
index e66082e..5ac18e4 100644
--- a/src/app/connexion/page.tsx
+++ b/src/app/connexion/page.tsx
@@ -53,6 +53,11 @@ export default async function SignInPage({ searchParams }: Props) {
>
Se connecter
+
+
+ Mot de passe oublié ?
+
+
Pas encore de compte ?{" "}
("idle");
+ const [typed, setTyped] = useState("");
+
+ function deleteAccount() {
+ startTransition(async () => {
+ await deleteAccountAction();
+ });
+ }
+
+ return (
+
+
+ La suppression anonymise votre compte (nom, email, téléphone effacés). Vos réservations
+ passées restent en base pour les obligations comptables, mais ne sont plus rattachées à
+ des données personnelles identifiantes.
+
+ {step === "idle" ? (
+
setStep("confirm")}
+ className="rounded-md border border-rose-300 bg-white px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-50"
+ >
+ Supprimer mon compte
+
+ ) : (
+
+
+ Pour confirmer, saisissez SUPPRIMER ci-dessous.
+
+
setTyped(e.target.value)}
+ className="w-full rounded-md border border-rose-300 px-3 py-1.5 text-sm focus:border-rose-500 focus:outline-none"
+ disabled={pending}
+ />
+
+ setStep("idle")}
+ disabled={pending}
+ className="text-xs text-zinc-600 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ {pending ? "Suppression…" : "Confirmer la suppression"}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/mon-compte/_components/PasswordForm.tsx b/src/app/mon-compte/_components/PasswordForm.tsx
new file mode 100644
index 0000000..b809b87
--- /dev/null
+++ b/src/app/mon-compte/_components/PasswordForm.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useRef, useState, useTransition } from "react";
+
+import { changePasswordAction } from "../actions";
+
+export function PasswordForm() {
+ const formRef = useRef(null);
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ setSuccess(null);
+ const next = (fd.get("next") as string | null) ?? "";
+ const confirm = (fd.get("confirm") as string | null) ?? "";
+ if (next !== confirm) {
+ setError("Les deux nouveaux mots de passe ne correspondent pas.");
+ return;
+ }
+ startTransition(async () => {
+ const res = await changePasswordAction(fd);
+ if (res && res.ok === false) setError(res.error);
+ else {
+ setSuccess("Mot de passe mis à jour.");
+ formRef.current?.reset();
+ }
+ });
+ }
+
+ const inputCls =
+ "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none";
+
+ return (
+
+ );
+}
diff --git a/src/app/mon-compte/_components/ProfileForm.tsx b/src/app/mon-compte/_components/ProfileForm.tsx
new file mode 100644
index 0000000..23eac80
--- /dev/null
+++ b/src/app/mon-compte/_components/ProfileForm.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+import { updateProfileAction } from "../actions";
+
+type Props = {
+ initial: { firstName: string; lastName: string; phone: string | null };
+};
+
+export function ProfileForm({ initial }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ setSuccess(null);
+ startTransition(async () => {
+ const res = await updateProfileAction(fd);
+ if (res && res.ok === false) setError(res.error);
+ else {
+ setSuccess("Profil enregistré.");
+ router.refresh();
+ }
+ });
+ }
+
+ const inputCls =
+ "mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-zinc-900 focus:outline-none";
+
+ return (
+
+ );
+}
diff --git a/src/app/mon-compte/actions.ts b/src/app/mon-compte/actions.ts
new file mode 100644
index 0000000..c002a49
--- /dev/null
+++ b/src/app/mon-compte/actions.ts
@@ -0,0 +1,115 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth, signOut } from "@/auth";
+import { prisma } from "@/lib/prisma";
+import { hashPassword, verifyPassword } from "@/lib/password";
+import { recordAudit } from "@/lib/admin/audit";
+
+const profileSchema = z.object({
+ firstName: z.string().trim().min(1).max(100),
+ lastName: z.string().trim().min(1).max(100),
+ phone: z.string().trim().max(40).optional().nullable(),
+});
+
+const passwordSchema = z
+ .object({
+ current: z.string().min(1),
+ next: z.string().min(8).max(200),
+ })
+ .refine((d) => d.current !== d.next, { message: "Le nouveau mdp doit être différent de l'ancien." });
+
+async function requireSelf() {
+ const session = await auth();
+ if (!session?.user?.id) throw new Error("Non authentifié");
+ return session;
+}
+
+export async function updateProfileAction(fd: FormData) {
+ const session = await requireSelf();
+ const parsed = profileSchema.safeParse({
+ firstName: fd.get("firstName"),
+ lastName: fd.get("lastName"),
+ phone: ((fd.get("phone") as string | null) ?? "").trim() || null,
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
+ }
+ await prisma.user.update({
+ where: { id: session.user.id },
+ data: parsed.data,
+ });
+ await recordAudit({
+ scope: "public.profile",
+ event: "profile.update",
+ target: session.user.id,
+ actorEmail: session.user.email ?? null,
+ details: parsed.data,
+ });
+ revalidatePath("/mon-compte");
+ return { ok: true as const };
+}
+
+export async function changePasswordAction(fd: FormData) {
+ const session = await requireSelf();
+ const parsed = passwordSchema.safeParse({
+ current: fd.get("current"),
+ next: fd.get("next"),
+ });
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
+ }
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { passwordHash: true },
+ });
+ if (!user) return { ok: false as const, error: "Utilisateur introuvable." };
+ const ok = await verifyPassword(parsed.data.current, user.passwordHash);
+ if (!ok) return { ok: false as const, error: "Mot de passe actuel incorrect." };
+ await prisma.user.update({
+ where: { id: session.user.id },
+ data: { passwordHash: await hashPassword(parsed.data.next) },
+ });
+ await recordAudit({
+ scope: "public.profile",
+ event: "password.change",
+ target: session.user.id,
+ actorEmail: session.user.email ?? null,
+ details: {},
+ });
+ return { ok: true as const };
+}
+
+/** Supprime le compte + cascade : bookings restent en DB (anonymisées) pour les obligations comptables. */
+export async function deleteAccountAction() {
+ const session = await requireSelf();
+ const userId = session.user.id;
+ const email = session.user.email ?? "";
+
+ // Anonymise plutôt que supprimer pour préserver l'historique comptable des bookings.
+ const anon = `anonymise-${userId}@karbe.invalid`;
+ await prisma.user.update({
+ where: { id: userId },
+ data: {
+ email: anon,
+ firstName: "Compte",
+ lastName: "supprimé",
+ phone: null,
+ passwordHash: "", // verrouille le login (bcrypt.compare retournera toujours false)
+ isActive: false,
+ },
+ });
+ await prisma.passwordResetToken.deleteMany({ where: { userId } });
+ await recordAudit({
+ scope: "public.profile",
+ event: "account.delete",
+ target: userId,
+ actorEmail: email,
+ details: { anonymisedTo: anon },
+ });
+ await signOut({ redirect: false });
+ redirect("/?account=deleted");
+}
diff --git a/src/app/mon-compte/page.tsx b/src/app/mon-compte/page.tsx
new file mode 100644
index 0000000..982b78a
--- /dev/null
+++ b/src/app/mon-compte/page.tsx
@@ -0,0 +1,71 @@
+import { redirect } from "next/navigation";
+
+import { auth } from "@/auth";
+import { prisma } from "@/lib/prisma";
+
+import { ProfileForm } from "./_components/ProfileForm";
+import { PasswordForm } from "./_components/PasswordForm";
+import { DangerZone } from "./_components/DangerZone";
+
+export const dynamic = "force-dynamic";
+
+export default async function MyAccountPage() {
+ const session = await auth();
+ if (!session?.user?.id) redirect("/connexion?next=/mon-compte");
+
+ const user = await prisma.user.findUnique({
+ where: { id: session.user.id },
+ select: { email: true, firstName: true, lastName: true, phone: true, role: true, createdAt: true },
+ });
+ if (!user) redirect("/connexion");
+
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
+
+ return (
+
+
+
+
+
+
+
+
+
+ Mes données (RGPD)
+
+
+ Téléchargez l'intégralité des données associées à votre compte au format JSON,
+ conformément à l'article 20 du RGPD (droit à la portabilité).
+
+
+ Télécharger mes données
+
+
+
+
+
+ Zone dangereuse
+
+
+
+
+ );
+}
diff --git a/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx b/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx
new file mode 100644
index 0000000..e77c3e7
--- /dev/null
+++ b/src/app/mot-de-passe-oublie/[token]/_components/ResetForm.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+export function ResetForm({ token }: { token: string }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [error, setError] = useState(null);
+
+ function onSubmit(fd: FormData) {
+ setError(null);
+ const password = (fd.get("password") as string | null) ?? "";
+ const confirm = (fd.get("confirm") as string | null) ?? "";
+ if (password.length < 8) {
+ setError("Mot de passe trop court (8 caractères min).");
+ return;
+ }
+ if (password !== confirm) {
+ setError("Les deux mots de passe ne correspondent pas.");
+ return;
+ }
+ startTransition(async () => {
+ const res = await fetch("/api/password/reset", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, password }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ setError(json?.error || `Erreur ${res.status}`);
+ return;
+ }
+ router.push("/connexion?reset=ok");
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/mot-de-passe-oublie/[token]/page.tsx b/src/app/mot-de-passe-oublie/[token]/page.tsx
new file mode 100644
index 0000000..80893ac
--- /dev/null
+++ b/src/app/mot-de-passe-oublie/[token]/page.tsx
@@ -0,0 +1,33 @@
+import Link from "next/link";
+
+import { ResetForm } from "./_components/ResetForm";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ token: string }> };
+
+export default async function ResetPage({ params }: PageProps) {
+ const { token } = await params;
+
+ return (
+
+
+
+
+
+
+
+
+ Retour à la connexion
+
+
+
+
+ );
+}
diff --git a/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx b/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx
new file mode 100644
index 0000000..563622a
--- /dev/null
+++ b/src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { useState, useTransition } from "react";
+
+export function ResetRequestForm() {
+ const [pending, startTransition] = useTransition();
+ const [done, setDone] = useState(false);
+
+ function onSubmit(fd: FormData) {
+ const email = (fd.get("email") as string | null)?.trim() ?? "";
+ if (!email) return;
+ startTransition(async () => {
+ await fetch("/api/password/reset-request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ }).catch(() => {});
+ setDone(true);
+ });
+ }
+
+ if (done) {
+ return (
+
+ Si un compte existe pour cet email, vous recevrez un lien dans quelques instants. Pensez à
+ vérifier vos spams.
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/mot-de-passe-oublie/page.tsx b/src/app/mot-de-passe-oublie/page.tsx
new file mode 100644
index 0000000..1ac4a60
--- /dev/null
+++ b/src/app/mot-de-passe-oublie/page.tsx
@@ -0,0 +1,34 @@
+import { redirect } from "next/navigation";
+import Link from "next/link";
+
+import { auth } from "@/auth";
+import { ResetRequestForm } from "./_components/ResetRequestForm";
+
+export const dynamic = "force-dynamic";
+
+export default async function ForgotPasswordPage() {
+ const session = await auth();
+ if (session?.user?.id) redirect("/");
+
+ return (
+
+
+
+
+
+
+
+
+ Retour à la connexion
+
+
+
+
+ );
+}
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
index 5e88e57..96d9836 100644
--- a/src/components/SiteHeader.tsx
+++ b/src/components/SiteHeader.tsx
@@ -44,6 +44,9 @@ export async function SiteHeader() {
Mes réservations
+
+ Mon compte
+
{isOwner ? (
Espace hôte
diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts
index 0f25da3..bed9fd5 100644
--- a/src/lib/carbet-search.ts
+++ b/src/lib/carbet-search.ts
@@ -16,6 +16,8 @@ export type CarbetSearchFilters = {
// Filtre plugin access-type : si "river-only" exclu, on garde uniquement
// ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe.
accessibility?: "road-only" | "all";
+ priceMax?: number;
+ amenities?: string[];
};
export type RawSearchParams = {
@@ -69,6 +71,24 @@ export function parseSearchFilters(
filters.accessibility = accessibility;
}
+ const priceMaxRaw = pickString(searchParams.priceMax);
+ if (priceMaxRaw) {
+ const priceMax = Number(priceMaxRaw);
+ if (Number.isFinite(priceMax) && priceMax > 0 && priceMax <= 10000) {
+ filters.priceMax = priceMax;
+ }
+ }
+
+ const amenitiesRaw = searchParams.amenities;
+ if (amenitiesRaw) {
+ const arr = Array.isArray(amenitiesRaw) ? amenitiesRaw : [amenitiesRaw];
+ const keys = arr
+ .flatMap((s) => s.split(","))
+ .map((s) => s.trim())
+ .filter((s) => /^[a-z0-9-]{1,40}$/.test(s));
+ if (keys.length > 0) filters.amenities = keys.slice(0, 10);
+ }
+
return filters;
}
@@ -112,6 +132,16 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput {
where.accessType = AccessType.ROAD_AND_RIVER;
}
+ if (filters.priceMax !== undefined) {
+ where.nightlyPrice = { lte: filters.priceMax };
+ }
+
+ if (filters.amenities && filters.amenities.length > 0) {
+ where.AND = filters.amenities.map((key) => ({
+ amenities: { some: { amenity: { key } } },
+ }));
+ }
+
if (filters.startDate && filters.endDate) {
where.availabilities = {
some: {
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 3712ee7..4652796 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -186,6 +186,23 @@ export async function sendBookingConfirmed(
});
}
+export async function sendPasswordReset(
+ to: string,
+ resetUrl: string,
+): Promise {
+ await sendEmail({
+ to,
+ subject: "Réinitialisation de votre mot de passe Karbé",
+ html: wrap(
+ "Réinitialiser votre mot de passe",
+ `Vous avez demandé à réinitialiser votre mot de passe Karbé. Cliquez sur le lien ci-dessous pour choisir un nouveau mot de passe (valable 1 heure) :
+ Réinitialiser mon mot de passe
+ Si vous n'avez pas fait cette demande, ignorez simplement cet email — votre mot de passe ne change pas.
`,
+ ),
+ text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`,
+ });
+}
+
export async function sendBookingRefunded(
to: string,
firstName: string,
diff --git a/src/lib/password-reset.ts b/src/lib/password-reset.ts
new file mode 100644
index 0000000..31959da
--- /dev/null
+++ b/src/lib/password-reset.ts
@@ -0,0 +1,51 @@
+import "server-only";
+
+import crypto from "node:crypto";
+
+import { prisma } from "@/lib/prisma";
+import { hashPassword } from "@/lib/password";
+
+const TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
+
+function hashToken(token: string): string {
+ return crypto.createHash("sha256").update(token).digest("hex");
+}
+
+/** Crée un token (renvoie la version *plain* à mettre dans l'URL email). */
+export async function createPasswordResetToken(userId: string): Promise {
+ const token = crypto.randomBytes(32).toString("base64url");
+ const tokenHash = hashToken(token);
+ const expiresAt = new Date(Date.now() + TOKEN_TTL_MS);
+ await prisma.passwordResetToken.create({
+ data: { tokenHash, userId, expiresAt },
+ });
+ return token;
+}
+
+/** Vérifie un token plain → renvoie userId si valide & non expiré. */
+export async function consumePasswordResetToken(
+ plainToken: string,
+ newPassword: string,
+): Promise<{ ok: true; userId: string } | { ok: false; reason: string }> {
+ const tokenHash = hashToken(plainToken);
+ const row = await prisma.passwordResetToken.findUnique({ where: { tokenHash } });
+ if (!row) return { ok: false, reason: "Lien invalide." };
+ if (row.expiresAt < new Date()) {
+ await prisma.passwordResetToken.delete({ where: { tokenHash } }).catch(() => {});
+ return { ok: false, reason: "Lien expiré." };
+ }
+ const passwordHash = await hashPassword(newPassword);
+ await prisma.$transaction([
+ prisma.user.update({ where: { id: row.userId }, data: { passwordHash } }),
+ prisma.passwordResetToken.deleteMany({ where: { userId: row.userId } }),
+ ]);
+ return { ok: true, userId: row.userId };
+}
+
+/** Cleanup utility — peut être lancé par un cron. */
+export async function purgeExpiredResetTokens(): Promise {
+ const result = await prisma.passwordResetToken.deleteMany({
+ where: { expiresAt: { lt: new Date() } },
+ });
+ return result.count;
+}