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" ? ( + + ) : ( +
+

+ 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} + /> +
+ + +
+
+ )} +
+ ); +} 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 ( +
+
+ +
+ + +
+ + {error ? ( +
{error}
+ ) : null} + {success ? ( +
{success}
+ ) : null} + + +
+
+ ); +} 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 ( +
+
+
+ + +
+ + + {error ? ( +
{error}
+ ) : null} + {success ? ( +
{success}
+ ) : null} + + +
+
+ ); +} 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 ( +
+
+

Mon compte

+

+ Connecté avec {user.email} · inscrit le {dateFmt.format(user.createdAt)} +

+
+ +
+

Identité

+ +
+ +
+

+ Sécurité +

+ +
+ +
+

+ 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 ( +
+
+ + + {error ? ( +
+ {error} +
+ ) : null} + +
+
+ ); +} 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 ( +
+
+
+

Nouveau mot de passe

+

+ Choisissez un mot de passe d'au moins 8 caractères. Vous serez redirigé vers la + connexion une fois enregistré. +

+
+ + + +

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

Mot de passe oublié

+

+ Saisissez votre email. Si un compte existe, vous recevrez un lien valable 1 heure pour + choisir un nouveau mot de passe. +

+
+ + + +

+ + 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; +}