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