feat: reset password + page mon-compte (RGPD) + facettes recherche (prix max, équipements)
All checks were successful
CI / test (pull_request) Successful in 2m19s
All checks were successful
CI / test (pull_request) Successful in 2m19s
This commit is contained in:
parent
0b5e5408e8
commit
a6df96db7e
19 changed files with 922 additions and 0 deletions
|
|
@ -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");
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
103
src/app/api/me/export/route.ts
Normal file
103
src/app/api/me/export/route.ts
Normal 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}"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
50
src/app/api/password/reset-request/route.ts
Normal file
50
src/app/api/password/reset-request/route.ts
Normal 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 });
|
||||
}
|
||||
40
src/app/api/password/reset/route.ts
Normal file
40
src/app/api/password/reset/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
67
src/app/mon-compte/_components/DangerZone.tsx
Normal file
67
src/app/mon-compte/_components/DangerZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/app/mon-compte/_components/PasswordForm.tsx
Normal file
69
src/app/mon-compte/_components/PasswordForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/app/mon-compte/_components/ProfileForm.tsx
Normal file
88
src/app/mon-compte/_components/ProfileForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/app/mon-compte/actions.ts
Normal file
115
src/app/mon-compte/actions.ts
Normal 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");
|
||||
}
|
||||
71
src/app/mon-compte/page.tsx
Normal file
71
src/app/mon-compte/page.tsx
Normal 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'intégralité des données associées à votre compte au format JSON,
|
||||
conformément à l'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
33
src/app/mot-de-passe-oublie/[token]/page.tsx
Normal file
33
src/app/mot-de-passe-oublie/[token]/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
52
src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx
Normal file
52
src/app/mot-de-passe-oublie/_components/ResetRequestForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/app/mot-de-passe-oublie/page.tsx
Normal file
34
src/app/mot-de-passe-oublie/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
51
src/lib/password-reset.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue