feat: reset password + page mon-compte (RGPD) + facettes recherche (prix max, équipements)
All checks were successful
CI / test (pull_request) Successful in 2m19s

This commit is contained in:
Claude Integration 2026-06-01 10:16:37 +00:00
parent 0b5e5408e8
commit a6df96db7e
19 changed files with 922 additions and 0 deletions

View file

@ -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");

View file

@ -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])
}

View file

@ -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}"`,
},
},
);
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -53,6 +53,11 @@ export default async function SignInPage({ searchParams }: Props) {
>
Se connecter
</button>
<p className="text-center text-xs text-zinc-500">
<Link href="/mot-de-passe-oublie" className="hover:text-zinc-900 underline">
Mot de passe oublié ?
</Link>
</p>
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
Pas encore de compte ?{" "}
<Link

View file

@ -0,0 +1,67 @@
"use client";
import { useState, useTransition } from "react";
import { deleteAccountAction } from "../actions";
export function DangerZone() {
const [pending, startTransition] = useTransition();
const [step, setStep] = useState<"idle" | "confirm">("idle");
const [typed, setTyped] = useState("");
function deleteAccount() {
startTransition(async () => {
await deleteAccountAction();
});
}
return (
<div className="space-y-2 text-sm text-zinc-700">
<p>
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.
</p>
{step === "idle" ? (
<button
type="button"
onClick={() => 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
</button>
) : (
<div className="space-y-2 rounded border border-rose-300 bg-rose-50 p-3 text-sm">
<p className="text-rose-900">
Pour confirmer, saisissez <code className="rounded bg-white px-1">SUPPRIMER</code> ci-dessous.
</p>
<input
type="text"
value={typed}
onChange={(e) => 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}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setStep("idle")}
disabled={pending}
className="text-xs text-zinc-600 hover:text-zinc-900"
>
Annuler
</button>
<button
type="button"
disabled={typed !== "SUPPRIMER" || pending}
onClick={deleteAccount}
className="rounded-md bg-rose-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
{pending ? "Suppression…" : "Confirmer la suppression"}
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { changePasswordAction } from "../actions";
export function PasswordForm() {
const formRef = useRef<HTMLFormElement>(null);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<form ref={formRef} action={onSubmit} className="space-y-3">
<fieldset disabled={pending} className="space-y-3">
<label className="block">
<span className="text-xs text-zinc-600">Mot de passe actuel</span>
<input name="current" type="password" required className={inputCls} />
</label>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-zinc-600">Nouveau mot de passe</span>
<input name="next" type="password" required minLength={8} className={inputCls} />
</label>
<label className="block">
<span className="text-xs text-zinc-600">Confirmer</span>
<input name="confirm" type="password" required minLength={8} className={inputCls} />
</label>
</div>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Mise à jour…" : "Changer le mot de passe"}
</button>
</fieldset>
</form>
);
}

View file

@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(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 (
<form action={onSubmit} className="space-y-3">
<fieldset disabled={pending} className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-zinc-600">Prénom</span>
<input
name="firstName"
type="text"
required
maxLength={100}
defaultValue={initial.firstName}
className={inputCls}
/>
</label>
<label className="block">
<span className="text-xs text-zinc-600">Nom</span>
<input
name="lastName"
type="text"
required
maxLength={100}
defaultValue={initial.lastName}
className={inputCls}
/>
</label>
</div>
<label className="block">
<span className="text-xs text-zinc-600">Téléphone (optionnel)</span>
<input
name="phone"
type="tel"
maxLength={40}
defaultValue={initial.phone ?? ""}
className={inputCls}
/>
</label>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<button
type="submit"
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : "Enregistrer"}
</button>
</fieldset>
</form>
);
}

View file

@ -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");
}

View file

@ -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 (
<main className="mx-auto max-w-3xl px-6 py-10">
<header className="mb-6">
<h1 className="text-3xl font-semibold text-zinc-900">Mon compte</h1>
<p className="mt-1 text-sm text-zinc-600">
Connecté avec <strong>{user.email}</strong> · inscrit le {dateFmt.format(user.createdAt)}
</p>
</header>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
<ProfileForm
initial={{ firstName: user.firstName, lastName: user.lastName, phone: user.phone }}
/>
</section>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Sécurité
</h2>
<PasswordForm />
</section>
<section className="mb-6 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Mes données (RGPD)
</h2>
<p className="mb-3 text-sm text-zinc-600">
Téléchargez l&apos;intégralité des données associées à votre compte au format JSON,
conformément à l&apos;article 20 du RGPD (droit à la portabilité).
</p>
<a
href="/api/me/export"
className="inline-flex items-center gap-2 rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
>
Télécharger mes données
</a>
</section>
<section className="rounded-lg border border-rose-200 bg-rose-50/50 p-5">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-rose-700">
Zone dangereuse
</h2>
<DangerZone />
</section>
</main>
);
}

View file

@ -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<string | null>(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 (
<form action={onSubmit} className="space-y-3">
<fieldset disabled={pending} className="space-y-3">
<label className="block">
<span className="text-xs text-zinc-600">Nouveau mot de passe</span>
<input
name="password"
type="password"
required
minLength={8}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-zinc-600">Confirmer le mot de passe</span>
<input
name="confirm"
type="password"
required
minLength={8}
className="mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</label>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{error}
</div>
) : null}
<button
type="submit"
className="w-full rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : "Définir le nouveau mot de passe"}
</button>
</fieldset>
</form>
);
}

View file

@ -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 (
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
<header>
<h1 className="text-2xl font-semibold text-zinc-900">Nouveau mot de passe</h1>
<p className="mt-1 text-sm text-zinc-500">
Choisissez un mot de passe d&apos;au moins 8 caractères. Vous serez redirigé vers la
connexion une fois enregistré.
</p>
</header>
<ResetForm token={token} />
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
<Link href="/connexion" className="text-zinc-900 underline">
Retour à la connexion
</Link>
</p>
</div>
</main>
);
}

View file

@ -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 (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
Si un compte existe pour cet email, vous recevrez un lien dans quelques instants. Pensez à
vérifier vos spams.
</div>
);
}
return (
<form action={onSubmit} className="space-y-3">
<fieldset disabled={pending} className="space-y-3">
<label className="block">
<span className="text-xs text-zinc-600">Email</span>
<input
name="email"
type="email"
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm"
/>
</label>
<button
type="submit"
className="w-full rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Envoi…" : "Envoyer le lien"}
</button>
</fieldset>
</form>
);
}

View file

@ -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 (
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
<div className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6 shadow-sm">
<header>
<h1 className="text-2xl font-semibold text-zinc-900">Mot de passe oublié</h1>
<p className="mt-1 text-sm text-zinc-500">
Saisissez votre email. Si un compte existe, vous recevrez un lien valable 1 heure pour
choisir un nouveau mot de passe.
</p>
</header>
<ResetRequestForm />
<p className="border-t border-zinc-100 pt-3 text-center text-sm text-zinc-500">
<Link href="/connexion" className="text-zinc-900 underline">
Retour à la connexion
</Link>
</p>
</div>
</main>
);
}

View file

@ -44,6 +44,9 @@ export async function SiteHeader() {
<Link href="/mes-reservations" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mes réservations
</Link>
<Link href="/mon-compte" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Mon compte
</Link>
{isOwner ? (
<Link href="/espace-hote" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Espace hôte

View file

@ -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: {

View file

@ -186,6 +186,23 @@ export async function sendBookingConfirmed(
});
}
export async function sendPasswordReset(
to: string,
resetUrl: string,
): Promise<void> {
await sendEmail({
to,
subject: "Réinitialisation de votre mot de passe Karbé",
html: wrap(
"Réinitialiser votre mot de passe",
`<p>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) :</p>
<p><a href="${resetUrl}" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Réinitialiser mon mot de passe</a></p>
<p style="font-size:12px;color:#71717a;">Si vous n'avez pas fait cette demande, ignorez simplement cet email votre mot de passe ne change pas.</p>`,
),
text: `Réinitialiser votre mot de passe Karbé : ${resetUrl} (valable 1h).`,
});
}
export async function sendBookingRefunded(
to: string,
firstName: string,

51
src/lib/password-reset.ts Normal file
View file

@ -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<string> {
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<number> {
const result = await prisma.passwordResetToken.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
return result.count;
}