feat(ka-id): courriels transactionnels Resend — vérification d'adresse à l'inscription, lien magique sans mot de passe, code 2FA au login mot de passe
- lib email zéro dépendance (API REST Resend, domaine groupe-ka.com), gabarits encre/lime - table auth_tokens (SHA-256 seulement, usage unique, TTL, max 5 essais) + colonne users.email_verified (comptes existants réputés vérifiés) - register : compte créé mais session ouverte seulement au clic du lien (24 h) ; envoi échoué → compte retiré pour permettre la reprise - login : mot de passe exact → code 6 chiffres (10 min) validé par /api/auth/2fa ; adresse non confirmée → lien renvoyé - /api/auth/magic (+ /magic/verify) : connexion sans mot de passe, réponse identique compte ou non (anti-énumération) - callbacks Google/Apple marquent email_verified ; AuthForm : étapes code/courriel envoyé + bouton « lien de connexion sans mot de passe » Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
11 changed files +699 −44
added
src/app/api/auth/2fa/route.ts
+49 −0
@@ -0,0 +1,49 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Deuxième étape du login mot de passe : validation du code à 6 chiffres | |
| 3 | +// envoyé par courriel, puis ouverture de la session. | |
| 4 | +import { NextRequest, NextResponse } from "next/server"; | |
| 5 | +import { consumeLoginCode, findUserById, touchLastLogin } from "@/lib/db"; | |
| 6 | +import { | |
| 7 | + mintSessionToken, | |
| 8 | + SESSION_COOKIE, | |
| 9 | + sessionCookieOptions, | |
| 10 | +} from "@/lib/auth"; | |
| 11 | + | |
| 12 | +export async function POST(req: NextRequest) { | |
| 13 | + let body: { email?: string; code?: string }; | |
| 14 | + try { | |
| 15 | + body = await req.json(); | |
| 16 | + } catch { | |
| 17 | + return NextResponse.json({ error: "Requête invalide." }, { status: 400 }); | |
| 18 | + } | |
| 19 | + const email = (body.email ?? "").trim().toLowerCase(); | |
| 20 | + const code = (body.code ?? "").trim(); | |
| 21 | + if (!email || !/^\d{6}$/.test(code)) | |
| 22 | + return NextResponse.json( | |
| 23 | + { error: "Entrez le code à 6 chiffres reçu par courriel." }, | |
| 24 | + { status: 400 }, | |
| 25 | + ); | |
| 26 | + | |
| 27 | + const result = consumeLoginCode(email, code); | |
| 28 | + if (!result.ok || !result.userId) { | |
| 29 | + const msg = | |
| 30 | + result.reason === "tentatives" | |
| 31 | + ? "Trop d'essais — reconnectez-vous pour recevoir un nouveau code." | |
| 32 | + : result.reason === "expire" | |
| 33 | + ? "Ce code a expiré — reconnectez-vous pour en recevoir un nouveau." | |
| 34 | + : "Code incorrect — vérifiez le courriel reçu."; | |
| 35 | + return NextResponse.json({ error: msg }, { status: 401 }); | |
| 36 | + } | |
| 37 | + const user = findUserById(result.userId); | |
| 38 | + if (!user) | |
| 39 | + return NextResponse.json({ error: "Compte introuvable." }, { status: 401 }); | |
| 40 | + | |
| 41 | + touchLastLogin(user.id); | |
| 42 | + const res = NextResponse.json({ ok: true }); | |
| 43 | + res.cookies.set( | |
| 44 | + SESSION_COOKIE, | |
| 45 | + await mintSessionToken(user.id), | |
| 46 | + sessionCookieOptions, | |
| 47 | + ); | |
| 48 | + return res; | |
| 49 | +} | |
modified
src/app/api/auth/callback/apple/route.ts
+2 −0
@@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | findUserByAppleSub, |
| 18 | 18 | ensureKaId, |
| 19 | 19 | touchLastLogin, |
| 20 | + markEmailVerified, | |
| 20 | 21 | } from "@/lib/db"; |
| 21 | 22 | import { |
| 22 | 23 | BASE_URL, |
@@ -146,6 +147,7 @@ export async function POST(req: NextRequest) { | ||
| 146 | 147 | } |
| 147 | 148 | |
| 148 | 149 | ensureKaId(user.id); |
| 150 | + markEmailVerified(user.id); // Apple atteste email_verified | |
| 149 | 151 | touchLastLogin(user.id); |
| 150 | 152 | const res = NextResponse.redirect(new URL(next, BASE_URL), { status: 303 }); |
| 151 | 153 | res.cookies.set( |
modified
src/app/api/auth/callback/google/route.ts
+2 −1
@@ -3,7 +3,7 @@ | ||
| 3 | 3 | // l'id_token (JWKS Google), création/liaison du KA ID, ouverture de session. |
| 4 | 4 | import { NextRequest, NextResponse } from "next/server"; |
| 5 | 5 | import { createRemoteJWKSet, jwtVerify } from "jose"; |
| 6 | −import { db, findUserByEmail, findUserByGoogleSub, ensureKaId, touchLastLogin } from "@/lib/db"; | |
| 6 | +import { db, findUserByEmail, findUserByGoogleSub, ensureKaId, touchLastLogin, markEmailVerified } from "@/lib/db"; | |
| 7 | 7 | import { |
| 8 | 8 | BASE_URL, |
| 9 | 9 | mintSessionToken, |
@@ -102,6 +102,7 @@ export async function GET(req: NextRequest) { | ||
| 102 | 102 | } |
| 103 | 103 | |
| 104 | 104 | ensureKaId(user!.id); |
| 105 | + markEmailVerified(user!.id); // Google atteste email_verified | |
| 105 | 106 | touchLastLogin(user!.id); |
| 106 | 107 | const res = NextResponse.redirect(new URL(next, BASE_URL)); |
| 107 | 108 | res.cookies.set( |
added
src/app/api/auth/email/verify/route.ts
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Clic sur le lien de confirmation d'adresse : l'adresse est prouvée, | |
| 3 | +// on ouvre la session et on reprend le parcours (next mémorisé au signup). | |
| 4 | +import { NextRequest, NextResponse } from "next/server"; | |
| 5 | +import { | |
| 6 | + consumeAuthToken, | |
| 7 | + findUserById, | |
| 8 | + markEmailVerified, | |
| 9 | + touchLastLogin, | |
| 10 | +} from "@/lib/db"; | |
| 11 | +import { | |
| 12 | + BASE_URL, | |
| 13 | + mintSessionToken, | |
| 14 | + SESSION_COOKIE, | |
| 15 | + sessionCookieOptions, | |
| 16 | + safeNext, | |
| 17 | +} from "@/lib/auth"; | |
| 18 | + | |
| 19 | +export async function GET(req: NextRequest) { | |
| 20 | + const token = req.nextUrl.searchParams.get("token") ?? ""; | |
| 21 | + const row = token ? consumeAuthToken(token, "verify-email") : undefined; | |
| 22 | + if (!row?.user_id || !findUserById(row.user_id)) | |
| 23 | + return NextResponse.redirect( | |
| 24 | + new URL("/connexion?error=jeton-invalide", BASE_URL), | |
| 25 | + ); | |
| 26 | + | |
| 27 | + markEmailVerified(row.user_id); | |
| 28 | + touchLastLogin(row.user_id); | |
| 29 | + | |
| 30 | + let next = "/compte"; | |
| 31 | + try { | |
| 32 | + next = safeNext((JSON.parse(row.payload ?? "{}") as { next?: string }).next); | |
| 33 | + } catch { | |
| 34 | + /* payload absent */ | |
| 35 | + } | |
| 36 | + const res = NextResponse.redirect(new URL(next, BASE_URL)); | |
| 37 | + res.cookies.set( | |
| 38 | + SESSION_COOKIE, | |
| 39 | + await mintSessionToken(row.user_id), | |
| 40 | + sessionCookieOptions, | |
| 41 | + ); | |
| 42 | + return res; | |
| 43 | +} | |
modified
src/app/api/auth/login/route.ts
+52 −16
@@ -1,15 +1,19 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | +// Login courriel + mot de passe. Depuis 2026-08-26, le mot de passe seul ne | |
| 3 | +// suffit plus : un code à 6 chiffres est envoyé par courriel (Resend) et la | |
| 4 | +// session n'est ouverte qu'après sa validation (/api/auth/2fa). | |
| 5 | +import crypto from "crypto"; | |
| 2 | 6 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | −import { findUserByEmail, touchLastLogin } from "@/lib/db"; | |
| 7 | +import { findUserByEmail, createAuthToken } from "@/lib/db"; | |
| 8 | +import { verifyPassword, BASE_URL, safeNext } from "@/lib/auth"; | |
| 4 | 9 | import { |
| 5 | − verifyPassword, | |
| 6 | − mintSessionToken, | |
| 7 | − SESSION_COOKIE, | |
| 8 | − sessionCookieOptions, | |
| 9 | −} from "@/lib/auth"; | |
| 10 | + sendEmail, | |
| 11 | + loginCodeEmail, | |
| 12 | + verificationEmail, | |
| 13 | +} from "@/lib/email"; | |
| 10 | 14 | |
| 11 | 15 | export async function POST(req: NextRequest) { |
| 12 | − let body: { email?: string; password?: string }; | |
| 16 | + let body: { email?: string; password?: string; next?: string }; | |
| 13 | 17 | try { |
| 14 | 18 | body = await req.json(); |
| 15 | 19 | } catch { |
@@ -23,7 +27,7 @@ export async function POST(req: NextRequest) { | ||
| 23 | 27 | return NextResponse.json( |
| 24 | 28 | { |
| 25 | 29 | error: user |
| 26 | − ? "Ce KA ID a été créé avec Google — utilisez « Continuer avec Google »." | |
| 30 | + ? "Ce KA ID a été créé avec Google ou Apple — utilisez le bouton correspondant, ou le lien de connexion par courriel." | |
| 27 | 31 | : "Courriel ou mot de passe incorrect.", |
| 28 | 32 | }, |
| 29 | 33 | { status: 401 }, |
@@ -35,12 +39,44 @@ export async function POST(req: NextRequest) { | ||
| 35 | 39 | { status: 401 }, |
| 36 | 40 | ); |
| 37 | 41 | |
| 38 | − touchLastLogin(user.id); | |
| 39 | − const res = NextResponse.json({ ok: true }); | |
| 40 | − res.cookies.set( | |
| 41 | − SESSION_COOKIE, | |
| 42 | − await mintSessionToken(user.id), | |
| 43 | − sessionCookieOptions, | |
| 44 | − ); | |
| 45 | − return res; | |
| 42 | + // Adresse jamais confirmée (inscription interrompue) : on renvoie le lien. | |
| 43 | + if (!user.email_verified) { | |
| 44 | + const raw = crypto.randomBytes(32).toString("hex"); | |
| 45 | + createAuthToken({ | |
| 46 | + email, | |
| 47 | + userId: user.id, | |
| 48 | + purpose: "verify-email", | |
| 49 | + raw, | |
| 50 | + ttlMinutes: 24 * 60, | |
| 51 | + payload: { next: safeNext(body.next) }, | |
| 52 | + }); | |
| 53 | + try { | |
| 54 | + await sendEmail({ | |
| 55 | + to: email, | |
| 56 | + ...verificationEmail(user.name, `${BASE_URL}/api/auth/email/verify?token=${raw}`), | |
| 57 | + }); | |
| 58 | + } catch (err) { | |
| 59 | + console.error("login: renvoi du lien de vérification échoué", err); | |
| 60 | + } | |
| 61 | + return NextResponse.json({ ok: true, verify: true, email }); | |
| 62 | + } | |
| 63 | + | |
| 64 | + const code = crypto.randomInt(100000, 1000000).toString(); | |
| 65 | + createAuthToken({ | |
| 66 | + email, | |
| 67 | + userId: user.id, | |
| 68 | + purpose: "login-code", | |
| 69 | + raw: code, | |
| 70 | + ttlMinutes: 10, | |
| 71 | + }); | |
| 72 | + try { | |
| 73 | + await sendEmail({ to: email, ...loginCodeEmail(user.name, code) }); | |
| 74 | + } catch (err) { | |
| 75 | + console.error("login: envoi du code 2FA échoué", err); | |
| 76 | + return NextResponse.json( | |
| 77 | + { error: "Impossible d'envoyer le code de confirmation — réessayez dans un instant." }, | |
| 78 | + { status: 502 }, | |
| 79 | + ); | |
| 80 | + } | |
| 81 | + return NextResponse.json({ ok: true, twofa: true, email }); | |
| 46 | 82 | } |
added
src/app/api/auth/magic/route.ts
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Connexion sans mot de passe : envoi d'un lien magique (jeton usage unique, | |
| 3 | +// 15 minutes) par courriel. Réponse identique que le compte existe ou non, | |
| 4 | +// pour ne pas permettre l'énumération des adresses. | |
| 5 | +import crypto from "crypto"; | |
| 6 | +import { NextRequest, NextResponse } from "next/server"; | |
| 7 | +import { findUserByEmail, createAuthToken } from "@/lib/db"; | |
| 8 | +import { BASE_URL, safeNext } from "@/lib/auth"; | |
| 9 | +import { sendEmail, magicLinkEmail } from "@/lib/email"; | |
| 10 | + | |
| 11 | +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; | |
| 12 | + | |
| 13 | +export async function POST(req: NextRequest) { | |
| 14 | + let body: { email?: string; next?: string }; | |
| 15 | + try { | |
| 16 | + body = await req.json(); | |
| 17 | + } catch { | |
| 18 | + return NextResponse.json({ error: "Requête invalide." }, { status: 400 }); | |
| 19 | + } | |
| 20 | + const email = (body.email ?? "").trim().toLowerCase(); | |
| 21 | + if (!EMAIL_RE.test(email)) | |
| 22 | + return NextResponse.json( | |
| 23 | + { error: "Entrez d'abord votre adresse courriel." }, | |
| 24 | + { status: 400 }, | |
| 25 | + ); | |
| 26 | + | |
| 27 | + const user = findUserByEmail(email); | |
| 28 | + if (user) { | |
| 29 | + const raw = crypto.randomBytes(32).toString("hex"); | |
| 30 | + createAuthToken({ | |
| 31 | + email, | |
| 32 | + userId: user.id, | |
| 33 | + purpose: "magic-link", | |
| 34 | + raw, | |
| 35 | + ttlMinutes: 15, | |
| 36 | + payload: { next: safeNext(body.next) }, | |
| 37 | + }); | |
| 38 | + try { | |
| 39 | + await sendEmail({ | |
| 40 | + to: email, | |
| 41 | + ...magicLinkEmail(user.name, `${BASE_URL}/api/auth/magic/verify?token=${raw}`), | |
| 42 | + }); | |
| 43 | + } catch (err) { | |
| 44 | + console.error("magic: envoi Resend échoué", err); | |
| 45 | + return NextResponse.json( | |
| 46 | + { error: "Impossible d'envoyer le courriel — réessayez dans un instant." }, | |
| 47 | + { status: 502 }, | |
| 48 | + ); | |
| 49 | + } | |
| 50 | + } | |
| 51 | + return NextResponse.json({ ok: true, sent: true, email }); | |
| 52 | +} | |
added
src/app/api/auth/magic/verify/route.ts
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Clic sur le lien magique : le jeton prouve la possession de la boîte | |
| 3 | +// courriel → session ouverte, adresse marquée vérifiée. | |
| 4 | +import { NextRequest, NextResponse } from "next/server"; | |
| 5 | +import { | |
| 6 | + consumeAuthToken, | |
| 7 | + findUserById, | |
| 8 | + markEmailVerified, | |
| 9 | + touchLastLogin, | |
| 10 | +} from "@/lib/db"; | |
| 11 | +import { | |
| 12 | + BASE_URL, | |
| 13 | + mintSessionToken, | |
| 14 | + SESSION_COOKIE, | |
| 15 | + sessionCookieOptions, | |
| 16 | + safeNext, | |
| 17 | +} from "@/lib/auth"; | |
| 18 | + | |
| 19 | +export async function GET(req: NextRequest) { | |
| 20 | + const token = req.nextUrl.searchParams.get("token") ?? ""; | |
| 21 | + const row = token ? consumeAuthToken(token, "magic-link") : undefined; | |
| 22 | + if (!row?.user_id || !findUserById(row.user_id)) | |
| 23 | + return NextResponse.redirect( | |
| 24 | + new URL("/connexion?error=jeton-invalide", BASE_URL), | |
| 25 | + ); | |
| 26 | + | |
| 27 | + markEmailVerified(row.user_id); | |
| 28 | + touchLastLogin(row.user_id); | |
| 29 | + | |
| 30 | + let next = "/compte"; | |
| 31 | + try { | |
| 32 | + next = safeNext((JSON.parse(row.payload ?? "{}") as { next?: string }).next); | |
| 33 | + } catch { | |
| 34 | + /* payload absent */ | |
| 35 | + } | |
| 36 | + const res = NextResponse.redirect(new URL(next, BASE_URL)); | |
| 37 | + res.cookies.set( | |
| 38 | + SESSION_COOKIE, | |
| 39 | + await mintSessionToken(row.user_id), | |
| 40 | + sessionCookieOptions, | |
| 41 | + ); | |
| 42 | + return res; | |
| 43 | +} | |
modified
src/app/api/auth/register/route.ts
+36 −16
@@ -1,13 +1,13 @@ | ||
| 1 | 1 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | +// Inscription par courriel + mot de passe. Depuis 2026-08-26, le compte doit | |
| 3 | +// prouver son adresse : on envoie un lien de confirmation (Resend) et la | |
| 4 | +// session n'est ouverte qu'au clic sur ce lien (/api/auth/email/verify). | |
| 5 | +import crypto from "crypto"; | |
| 2 | 6 | import { NextRequest, NextResponse } from "next/server"; |
| 3 | −import { db, findUserByEmail, ensureKaId, touchLastLogin } from "@/lib/db"; | |
| 7 | +import { db, findUserByEmail, ensureKaId, createAuthToken } from "@/lib/db"; | |
| 4 | 8 | import { ROLES } from "@/lib/roles"; |
| 5 | −import { | |
| 6 | − hashPassword, | |
| 7 | − mintSessionToken, | |
| 8 | − SESSION_COOKIE, | |
| 9 | − sessionCookieOptions, | |
| 10 | −} from "@/lib/auth"; | |
| 9 | +import { hashPassword, BASE_URL, safeNext } from "@/lib/auth"; | |
| 10 | +import { sendEmail, verificationEmail } from "@/lib/email"; | |
| 11 | 11 | |
| 12 | 12 | const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; |
| 13 | 13 | |
@@ -17,6 +17,7 @@ export async function POST(req: NextRequest) { | ||
| 17 | 17 | password?: string; |
| 18 | 18 | name?: string; |
| 19 | 19 | role?: string; |
| 20 | + next?: string; | |
| 20 | 21 | }; |
| 21 | 22 | try { |
| 22 | 23 | body = await req.json(); |
@@ -63,14 +64,33 @@ export async function POST(req: NextRequest) { | ||
| 63 | 64 | "INSERT INTO users (email, name, password_hash, role) VALUES (?, ?, ?, ?)", |
| 64 | 65 | ) |
| 65 | 66 | .run(email, name, hashPassword(password), role); |
| 66 | − ensureKaId(Number(info.lastInsertRowid)); | |
| 67 | − touchLastLogin(Number(info.lastInsertRowid)); | |
| 67 | + const userId = Number(info.lastInsertRowid); | |
| 68 | + ensureKaId(userId); | |
| 68 | 69 | |
| 69 | − const res = NextResponse.json({ ok: true }); | |
| 70 | − res.cookies.set( | |
| 71 | − SESSION_COOKIE, | |
| 72 | − await mintSessionToken(Number(info.lastInsertRowid)), | |
| 73 | − sessionCookieOptions, | |
| 74 | − ); | |
| 75 | − return res; | |
| 70 | + const raw = crypto.randomBytes(32).toString("hex"); | |
| 71 | + createAuthToken({ | |
| 72 | + email, | |
| 73 | + userId, | |
| 74 | + purpose: "verify-email", | |
| 75 | + raw, | |
| 76 | + ttlMinutes: 24 * 60, | |
| 77 | + payload: { next: safeNext(body.next) }, | |
| 78 | + }); | |
| 79 | + try { | |
| 80 | + await sendEmail({ | |
| 81 | + to: email, | |
| 82 | + ...verificationEmail(name, `${BASE_URL}/api/auth/email/verify?token=${raw}`), | |
| 83 | + }); | |
| 84 | + } catch (err) { | |
| 85 | + // Impossible d'envoyer : on retire le compte pour permettre une nouvelle | |
| 86 | + // tentative propre (sinon le 409 bloquerait toute réinscription). | |
| 87 | + db.prepare("DELETE FROM users WHERE id = ?").run(userId); | |
| 88 | + console.error("register: envoi Resend échoué", err); | |
| 89 | + return NextResponse.json( | |
| 90 | + { error: "Impossible d'envoyer le courriel de confirmation — réessayez dans un instant." }, | |
| 91 | + { status: 502 }, | |
| 92 | + ); | |
| 93 | + } | |
| 94 | + | |
| 95 | + return NextResponse.json({ ok: true, verify: true, email }); | |
| 76 | 96 | } |
modified
src/app/connexion/AuthForm.tsx
+180 −11
@@ -20,8 +20,15 @@ const ERRORS: Record<string, string> = { | ||
| 20 | 20 | "echange-apple-refuse": "Apple a refusé l'échange — réessayez.", |
| 21 | 21 | "profil-apple-incomplet": |
| 22 | 22 | "Votre compte Apple n'a pas de courriel vérifié.", |
| 23 | + "jeton-invalide": | |
| 24 | + "Ce lien est invalide ou expiré — demandez-en un nouveau ci-dessous.", | |
| 23 | 25 | }; |
| 24 | 26 | |
| 27 | +type Step = | |
| 28 | + | { kind: "form" } | |
| 29 | + | { kind: "code"; email: string } | |
| 30 | + | { kind: "sent"; email: string; flavor: "verify" | "magic" }; | |
| 31 | + | |
| 25 | 32 | export default function AuthForm({ |
| 26 | 33 | next, |
| 27 | 34 | error, |
@@ -32,31 +39,170 @@ export default function AuthForm({ | ||
| 32 | 39 | via?: string; |
| 33 | 40 | }) { |
| 34 | 41 | const [mode, setMode] = useState<"login" | "register">("login"); |
| 42 | + const [step, setStep] = useState<Step>({ kind: "form" }); | |
| 43 | + const [email, setEmail] = useState(""); | |
| 35 | 44 | const [msg, setMsg] = useState<string | null>( |
| 36 | 45 | error ? (ERRORS[error] ?? "Une erreur est survenue — réessayez.") : null, |
| 37 | 46 | ); |
| 38 | 47 | const [busy, setBusy] = useState(false); |
| 39 | 48 | |
| 49 | + async function post(url: string, body: Record<string, unknown>) { | |
| 50 | + const res = await fetch(url, { | |
| 51 | + method: "POST", | |
| 52 | + headers: { "Content-Type": "application/json" }, | |
| 53 | + body: JSON.stringify(body), | |
| 54 | + }); | |
| 55 | + const data = (await res.json().catch(() => ({}))) as { | |
| 56 | + error?: string; | |
| 57 | + twofa?: boolean; | |
| 58 | + verify?: boolean; | |
| 59 | + sent?: boolean; | |
| 60 | + }; | |
| 61 | + return { ok: res.ok, data }; | |
| 62 | + } | |
| 63 | + | |
| 40 | 64 | async function submit(e: React.FormEvent<HTMLFormElement>) { |
| 41 | 65 | e.preventDefault(); |
| 42 | 66 | setBusy(true); |
| 43 | 67 | setMsg(null); |
| 44 | 68 | const fd = new FormData(e.currentTarget); |
| 45 | − const body = Object.fromEntries(fd.entries()); | |
| 46 | − const res = await fetch(`/api/auth/${mode}`, { | |
| 47 | − method: "POST", | |
| 48 | − headers: { "Content-Type": "application/json" }, | |
| 49 | − body: JSON.stringify(body), | |
| 50 | − }); | |
| 51 | − const data = await res.json().catch(() => ({})); | |
| 52 | − if (res.ok) { | |
| 53 | − window.location.href = next; | |
| 54 | − } else { | |
| 69 | + const body = Object.fromEntries(fd.entries()) as Record<string, string>; | |
| 70 | + const { ok, data } = await post(`/api/auth/${mode}`, { ...body, next }); | |
| 71 | + setBusy(false); | |
| 72 | + if (!ok) { | |
| 55 | 73 | setMsg(data.error ?? "Une erreur est survenue — réessayez."); |
| 56 | − setBusy(false); | |
| 74 | + return; | |
| 75 | + } | |
| 76 | + const addr = (body.email ?? "").trim().toLowerCase(); | |
| 77 | + if (data.twofa) setStep({ kind: "code", email: addr }); | |
| 78 | + else if (data.verify) setStep({ kind: "sent", email: addr, flavor: "verify" }); | |
| 79 | + else window.location.href = next; | |
| 80 | + } | |
| 81 | + | |
| 82 | + async function submitCode(e: React.FormEvent<HTMLFormElement>) { | |
| 83 | + e.preventDefault(); | |
| 84 | + if (step.kind !== "code") return; | |
| 85 | + setBusy(true); | |
| 86 | + setMsg(null); | |
| 87 | + const fd = new FormData(e.currentTarget); | |
| 88 | + const { ok, data } = await post("/api/auth/2fa", { | |
| 89 | + email: step.email, | |
| 90 | + code: String(fd.get("code") ?? ""), | |
| 91 | + }); | |
| 92 | + setBusy(false); | |
| 93 | + if (ok) window.location.href = next; | |
| 94 | + else setMsg(data.error ?? "Code refusé — réessayez."); | |
| 95 | + } | |
| 96 | + | |
| 97 | + async function sendMagicLink() { | |
| 98 | + const addr = email.trim().toLowerCase(); | |
| 99 | + if (!addr) { | |
| 100 | + setMsg("Entrez d'abord votre adresse courriel ci-dessus."); | |
| 101 | + return; | |
| 57 | 102 | } |
| 103 | + setBusy(true); | |
| 104 | + setMsg(null); | |
| 105 | + const { ok, data } = await post("/api/auth/magic", { email: addr, next }); | |
| 106 | + setBusy(false); | |
| 107 | + if (ok) setStep({ kind: "sent", email: addr, flavor: "magic" }); | |
| 108 | + else setMsg(data.error ?? "Une erreur est survenue — réessayez."); | |
| 58 | 109 | } |
| 59 | 110 | |
| 111 | + /* ---------- étape « code de confirmation » (2FA) ---------- */ | |
| 112 | + if (step.kind === "code") { | |
| 113 | + return ( | |
| 114 | + <div className="gk-card p-6 sm:p-8"> | |
| 115 | + <p className="kicker">Vérification en deux étapes</p> | |
| 116 | + <h2 className="gk-display mt-3 text-[20px] font-bold"> | |
| 117 | + Un code vous attend | |
| 118 | + </h2> | |
| 119 | + <p className="mt-3 text-[13.5px] leading-relaxed text-ink-2"> | |
| 120 | + Nous avons envoyé un code à 6 chiffres à{" "} | |
| 121 | + <strong>{step.email}</strong>. Il expire dans 10 minutes. | |
| 122 | + </p> | |
| 123 | + <form onSubmit={submitCode} className="mt-5 space-y-4"> | |
| 124 | + <label className="block"> | |
| 125 | + <span className="klabel">Code de confirmation</span> | |
| 126 | + <input | |
| 127 | + name="code" | |
| 128 | + type="text" | |
| 129 | + inputMode="numeric" | |
| 130 | + pattern="\d{6}" | |
| 131 | + maxLength={6} | |
| 132 | + required | |
| 133 | + autoFocus | |
| 134 | + autoComplete="one-time-code" | |
| 135 | + className="field mt-[6px] text-center text-[22px] tracking-[0.4em]" | |
| 136 | + placeholder="······" | |
| 137 | + /> | |
| 138 | + </label> | |
| 139 | + {msg && ( | |
| 140 | + <p className="rounded-md border-[1.5px] border-[#b3423a] bg-[#fbe9e7] px-4 py-3 text-[13px] font-medium text-[#7c2f2a]"> | |
| 141 | + {msg} | |
| 142 | + </p> | |
| 143 | + )} | |
| 144 | + <button type="submit" disabled={busy} className="btn btn-primary w-full"> | |
| 145 | + {busy ? "Un instant…" : "Confirmer ma connexion"} | |
| 146 | + </button> | |
| 147 | + </form> | |
| 148 | + <button | |
| 149 | + type="button" | |
| 150 | + className="mt-4 w-full text-center text-[12px] text-ink-2 underline underline-offset-4" | |
| 151 | + onClick={() => { | |
| 152 | + setStep({ kind: "form" }); | |
| 153 | + setMsg(null); | |
| 154 | + }} | |
| 155 | + > | |
| 156 | + ← Revenir à la connexion | |
| 157 | + </button> | |
| 158 | + </div> | |
| 159 | + ); | |
| 160 | + } | |
| 161 | + | |
| 162 | + /* ---------- étape « courriel envoyé » (vérification / lien magique) ---------- */ | |
| 163 | + if (step.kind === "sent") { | |
| 164 | + return ( | |
| 165 | + <div className="gk-card p-6 sm:p-8 text-center"> | |
| 166 | + <p className="kicker justify-center">Courriel envoyé</p> | |
| 167 | + <h2 className="gk-display mt-3 text-[20px] font-bold"> | |
| 168 | + {step.flavor === "verify" | |
| 169 | + ? "Confirmez votre adresse" | |
| 170 | + : "Votre lien de connexion est parti"} | |
| 171 | + </h2> | |
| 172 | + <p className="mt-3 text-[13.5px] leading-relaxed text-ink-2"> | |
| 173 | + {step.flavor === "verify" ? ( | |
| 174 | + <> | |
| 175 | + Un lien de confirmation a été envoyé à{" "} | |
| 176 | + <strong>{step.email}</strong>. Cliquez-le pour activer votre | |
| 177 | + KA ID — vous serez connecté automatiquement. Le lien est valable | |
| 178 | + 24 heures. | |
| 179 | + </> | |
| 180 | + ) : ( | |
| 181 | + <> | |
| 182 | + Si un KA ID existe pour <strong>{step.email}</strong>, un lien de | |
| 183 | + connexion vient d'y être envoyé. Il est valable 15 minutes | |
| 184 | + et ne sert qu'une fois. | |
| 185 | + </> | |
| 186 | + )} | |
| 187 | + </p> | |
| 188 | + <p className="gk-mono mt-4 text-[11px] text-ink-3"> | |
| 189 | + Rien reçu ? Vérifiez vos indésirables, puis réessayez. | |
| 190 | + </p> | |
| 191 | + <button | |
| 192 | + type="button" | |
| 193 | + className="mt-5 w-full text-center text-[12px] text-ink-2 underline underline-offset-4" | |
| 194 | + onClick={() => { | |
| 195 | + setStep({ kind: "form" }); | |
| 196 | + setMsg(null); | |
| 197 | + }} | |
| 198 | + > | |
| 199 | + ← Revenir à la connexion | |
| 200 | + </button> | |
| 201 | + </div> | |
| 202 | + ); | |
| 203 | + } | |
| 204 | + | |
| 205 | + /* ---------- formulaire principal ---------- */ | |
| 60 | 206 | return ( |
| 61 | 207 | <div className="gk-card p-6 sm:p-8"> |
| 62 | 208 | {via && ( |
@@ -191,6 +337,8 @@ export default function AuthForm({ | ||
| 191 | 337 | autoComplete="email" |
| 192 | 338 | className="field mt-[6px]" |
| 193 | 339 | placeholder="vous@exemple.com" |
| 340 | + value={email} | |
| 341 | + onChange={(e) => setEmail(e.target.value)} | |
| 194 | 342 | /> |
| 195 | 343 | </label> |
| 196 | 344 | <label className="block"> |
@@ -221,6 +369,27 @@ export default function AuthForm({ | ||
| 221 | 369 | ? "Se connecter" |
| 222 | 370 | : "Créer mon KA ID"} |
| 223 | 371 | </button> |
| 372 | + | |
| 373 | + {mode === "login" && ( | |
| 374 | + <> | |
| 375 | + <p className="gk-mono text-center text-[10.5px] text-ink-3"> | |
| 376 | + Un code de confirmation vous sera envoyé par courriel. | |
| 377 | + </p> | |
| 378 | + <button | |
| 379 | + type="button" | |
| 380 | + disabled={busy} | |
| 381 | + onClick={sendMagicLink} | |
| 382 | + className="btn btn-ghost w-full !min-h-[40px] !text-[13px]" | |
| 383 | + > | |
| 384 | + Recevoir un lien de connexion — sans mot de passe | |
| 385 | + </button> | |
| 386 | + </> | |
| 387 | + )} | |
| 388 | + {mode === "register" && ( | |
| 389 | + <p className="gk-mono text-center text-[10.5px] text-ink-3"> | |
| 390 | + Un lien de confirmation sera envoyé pour valider votre adresse. | |
| 391 | + </p> | |
| 392 | + )} | |
| 224 | 393 | </form> |
| 225 | 394 | |
| 226 | 395 | <p className="mt-6 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.25)] pt-4 text-[12px] leading-relaxed text-ink-2"> |
modified
src/lib/db.ts
+135 −0
@@ -106,6 +106,34 @@ function open() { | ||
| 106 | 106 | db.exec( |
| 107 | 107 | "CREATE UNIQUE INDEX IF NOT EXISTS users_api_token ON users(api_token)", |
| 108 | 108 | ); |
| 109 | + // Vérification de courriel (2026-08-26) — les comptes mot de passe doivent | |
| 110 | + // prouver leur adresse (lien Resend). Les comptes antérieurs à la migration | |
| 111 | + // sont réputés vérifiés pour ne bloquer personne. | |
| 112 | + try { | |
| 113 | + db.exec("ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0"); | |
| 114 | + db.exec("UPDATE users SET email_verified = 1"); | |
| 115 | + } catch { | |
| 116 | + /* colonne déjà présente */ | |
| 117 | + } | |
| 118 | + // Jetons éphémères d'auth par courriel : lien de vérification, lien magique | |
| 119 | + // (connexion sans mot de passe) et code 2FA du login mot de passe. | |
| 120 | + // On ne stocke JAMAIS le jeton en clair — uniquement son SHA-256. | |
| 121 | + db.exec(` | |
| 122 | + CREATE TABLE IF NOT EXISTS auth_tokens ( | |
| 123 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 124 | + email TEXT NOT NULL, | |
| 125 | + user_id INTEGER, | |
| 126 | + purpose TEXT NOT NULL, | |
| 127 | + token_hash TEXT NOT NULL, | |
| 128 | + payload TEXT, | |
| 129 | + attempts INTEGER NOT NULL DEFAULT 0, | |
| 130 | + expires_at TEXT NOT NULL, | |
| 131 | + used_at TEXT, | |
| 132 | + created_at TEXT NOT NULL DEFAULT (datetime('now')) | |
| 133 | + ); | |
| 134 | + CREATE INDEX IF NOT EXISTS auth_tokens_hash ON auth_tokens(token_hash); | |
| 135 | + CREATE INDEX IF NOT EXISTS auth_tokens_email ON auth_tokens(email, purpose); | |
| 136 | + `); | |
| 109 | 137 | return db; |
| 110 | 138 | } |
| 111 | 139 | |
@@ -164,8 +192,115 @@ export type UserRow = { | ||
| 164 | 192 | public: number; |
| 165 | 193 | api_token: string | null; |
| 166 | 194 | api_token_created: string | null; |
| 195 | + email_verified: number; | |
| 167 | 196 | }; |
| 168 | 197 | |
| 198 | +/* ---------- jetons d'auth par courriel (Resend) ---------- */ | |
| 199 | + | |
| 200 | +export type AuthTokenPurpose = "verify-email" | "magic-link" | "login-code"; | |
| 201 | + | |
| 202 | +export type AuthTokenRow = { | |
| 203 | + id: number; | |
| 204 | + email: string; | |
| 205 | + user_id: number | null; | |
| 206 | + purpose: AuthTokenPurpose; | |
| 207 | + token_hash: string; | |
| 208 | + payload: string | null; | |
| 209 | + attempts: number; | |
| 210 | + expires_at: string; | |
| 211 | + used_at: string | null; | |
| 212 | + created_at: string; | |
| 213 | +}; | |
| 214 | + | |
| 215 | +function hashToken(raw: string): string { | |
| 216 | + return crypto.createHash("sha256").update(raw).digest("hex"); | |
| 217 | +} | |
| 218 | + | |
| 219 | +/** Crée un jeton (invalide les précédents du même usage pour ce courriel). */ | |
| 220 | +export function createAuthToken(opts: { | |
| 221 | + email: string; | |
| 222 | + userId?: number; | |
| 223 | + purpose: AuthTokenPurpose; | |
| 224 | + raw: string; | |
| 225 | + ttlMinutes: number; | |
| 226 | + payload?: unknown; | |
| 227 | +}): void { | |
| 228 | + db.prepare( | |
| 229 | + "UPDATE auth_tokens SET used_at = datetime('now') WHERE email = ? AND purpose = ? AND used_at IS NULL", | |
| 230 | + ).run(opts.email, opts.purpose); | |
| 231 | + db.prepare( | |
| 232 | + "DELETE FROM auth_tokens WHERE expires_at < datetime('now', '-1 day')", | |
| 233 | + ).run(); | |
| 234 | + db.prepare( | |
| 235 | + `INSERT INTO auth_tokens (email, user_id, purpose, token_hash, payload, expires_at) | |
| 236 | + VALUES (?, ?, ?, ?, ?, datetime('now', ?))`, | |
| 237 | + ).run( | |
| 238 | + opts.email, | |
| 239 | + opts.userId ?? null, | |
| 240 | + opts.purpose, | |
| 241 | + hashToken(opts.raw), | |
| 242 | + opts.payload != null ? JSON.stringify(opts.payload) : null, | |
| 243 | + `+${Math.max(1, Math.floor(opts.ttlMinutes))} minutes`, | |
| 244 | + ); | |
| 245 | +} | |
| 246 | + | |
| 247 | +/** Consomme un jeton de lien (vérification / magique) — usage unique. */ | |
| 248 | +export function consumeAuthToken( | |
| 249 | + raw: string, | |
| 250 | + purpose: AuthTokenPurpose, | |
| 251 | +): AuthTokenRow | undefined { | |
| 252 | + const row = db | |
| 253 | + .prepare( | |
| 254 | + `SELECT * FROM auth_tokens | |
| 255 | + WHERE token_hash = ? AND purpose = ? AND used_at IS NULL | |
| 256 | + AND expires_at > datetime('now')`, | |
| 257 | + ) | |
| 258 | + .get(hashToken(raw), purpose) as AuthTokenRow | undefined; | |
| 259 | + if (!row) return undefined; | |
| 260 | + db.prepare("UPDATE auth_tokens SET used_at = datetime('now') WHERE id = ?").run( | |
| 261 | + row.id, | |
| 262 | + ); | |
| 263 | + return row; | |
| 264 | +} | |
| 265 | + | |
| 266 | +/** Valide le code 2FA du login mot de passe (max 5 essais, usage unique). */ | |
| 267 | +export function consumeLoginCode( | |
| 268 | + email: string, | |
| 269 | + code: string, | |
| 270 | +): { ok: boolean; reason?: "expire" | "tentatives" | "invalide"; userId?: number } { | |
| 271 | + const row = db | |
| 272 | + .prepare( | |
| 273 | + `SELECT * FROM auth_tokens | |
| 274 | + WHERE email = ? AND purpose = 'login-code' AND used_at IS NULL | |
| 275 | + ORDER BY id DESC LIMIT 1`, | |
| 276 | + ) | |
| 277 | + .get(email) as AuthTokenRow | undefined; | |
| 278 | + if (!row) return { ok: false, reason: "expire" }; | |
| 279 | + const alive = db | |
| 280 | + .prepare("SELECT expires_at > datetime('now') AS alive FROM auth_tokens WHERE id = ?") | |
| 281 | + .get(row.id) as { alive: number }; | |
| 282 | + if (!alive.alive) { | |
| 283 | + db.prepare("UPDATE auth_tokens SET used_at = datetime('now') WHERE id = ?").run(row.id); | |
| 284 | + return { ok: false, reason: "expire" }; | |
| 285 | + } | |
| 286 | + if (row.attempts >= 5) { | |
| 287 | + db.prepare("UPDATE auth_tokens SET used_at = datetime('now') WHERE id = ?").run(row.id); | |
| 288 | + return { ok: false, reason: "tentatives" }; | |
| 289 | + } | |
| 290 | + db.prepare("UPDATE auth_tokens SET attempts = attempts + 1 WHERE id = ?").run(row.id); | |
| 291 | + const given = Buffer.from(hashToken(code), "hex"); | |
| 292 | + const stored = Buffer.from(row.token_hash, "hex"); | |
| 293 | + if (given.length !== stored.length || !crypto.timingSafeEqual(given, stored)) | |
| 294 | + return { ok: false, reason: "invalide" }; | |
| 295 | + db.prepare("UPDATE auth_tokens SET used_at = datetime('now') WHERE id = ?").run(row.id); | |
| 296 | + return { ok: true, userId: row.user_id ?? undefined }; | |
| 297 | +} | |
| 298 | + | |
| 299 | +/** Marque l'adresse courriel comme vérifiée (lien cliqué, ou OAuth vérifié). */ | |
| 300 | +export function markEmailVerified(id: number): void { | |
| 301 | + db.prepare("UPDATE users SET email_verified = 1 WHERE id = ?").run(id); | |
| 302 | +} | |
| 303 | + | |
| 169 | 304 | // ---------- jeton API personnel (API·Ka) ---------- |
| 170 | 305 | |
| 171 | 306 | function randomApiToken(): string { |
added
src/lib/email.ts
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// KA ID — courriels transactionnels via Resend (domaine groupe-ka.com). | |
| 3 | +// API REST directe (fetch), zéro dépendance — même philosophie que scrypt. | |
| 4 | + | |
| 5 | +const RESEND_ENDPOINT = "https://api.resend.com/emails"; | |
| 6 | + | |
| 7 | +export const MAIL_FROM = | |
| 8 | + process.env.RESEND_FROM ?? "KA ID — Groupe Ka <ka-id@groupe-ka.com>"; | |
| 9 | + | |
| 10 | +export async function sendEmail(opts: { | |
| 11 | + to: string; | |
| 12 | + subject: string; | |
| 13 | + html: string; | |
| 14 | + text: string; | |
| 15 | +}): Promise<void> { | |
| 16 | + const key = process.env.RESEND_API_KEY; | |
| 17 | + if (!key) throw new Error("RESEND_API_KEY manquant"); | |
| 18 | + const res = await fetch(RESEND_ENDPOINT, { | |
| 19 | + method: "POST", | |
| 20 | + headers: { | |
| 21 | + Authorization: `Bearer ${key}`, | |
| 22 | + "Content-Type": "application/json", | |
| 23 | + }, | |
| 24 | + body: JSON.stringify({ | |
| 25 | + from: MAIL_FROM, | |
| 26 | + to: [opts.to], | |
| 27 | + subject: opts.subject, | |
| 28 | + html: opts.html, | |
| 29 | + text: opts.text, | |
| 30 | + }), | |
| 31 | + }); | |
| 32 | + if (!res.ok) { | |
| 33 | + const detail = await res.text().catch(() => ""); | |
| 34 | + throw new Error(`Resend ${res.status} : ${detail.slice(0, 300)}`); | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +/* ---------- gabarits (encre sur papier, style Groupe Ka) ---------- */ | |
| 39 | + | |
| 40 | +function layout(title: string, inner: string): string { | |
| 41 | + return `<!doctype html> | |
| 42 | +<html lang="fr"><body style="margin:0;padding:0;background:#f2f0ea;"> | |
| 43 | + <div style="max-width:520px;margin:0 auto;padding:32px 20px;font-family:Georgia,'Times New Roman',serif;color:#141814;"> | |
| 44 | + <p style="margin:0 0 18px;font-family:Menlo,Consolas,monospace;font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#4a4f4a;"> | |
| 45 | + Groupe Ka · KA ID | |
| 46 | + </p> | |
| 47 | + <div style="background:#fbfaf6;border:1.5px solid rgba(20,24,20,0.35);padding:28px 26px;"> | |
| 48 | + <h1 style="margin:0 0 14px;font-size:22px;line-height:1.15;letter-spacing:-0.02em;">${title}</h1> | |
| 49 | + ${inner} | |
| 50 | + </div> | |
| 51 | + <p style="margin:16px 0 0;font-size:11.5px;line-height:1.5;color:#6a6f6a;"> | |
| 52 | + Un seul compte — le KA ID — pour toutes les plateformes du Groupe Ka.<br> | |
| 53 | + Si vous n'êtes pas à l'origine de cette demande, ignorez ce courriel. | |
| 54 | + </p> | |
| 55 | + </div> | |
| 56 | +</body></html>`; | |
| 57 | +} | |
| 58 | + | |
| 59 | +function button(url: string, label: string): string { | |
| 60 | + return `<p style="margin:22px 0;"> | |
| 61 | + <a href="${url}" style="display:inline-block;background:#141814;color:#d9f26b;text-decoration:none;font-family:Menlo,Consolas,monospace;font-size:13px;letter-spacing:0.06em;padding:13px 22px;border:1.5px solid #141814;">${label}</a> | |
| 62 | + </p> | |
| 63 | + <p style="margin:0;font-size:12px;color:#6a6f6a;word-break:break-all;">Ou copiez ce lien : ${url}</p>`; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export function verificationEmail(name: string, url: string) { | |
| 67 | + return { | |
| 68 | + subject: "Confirmez votre adresse — KA ID", | |
| 69 | + html: layout( | |
| 70 | + "Confirmez votre adresse courriel", | |
| 71 | + `<p style="margin:0;font-size:14.5px;line-height:1.6;">Bonjour ${name},<br> | |
| 72 | + votre KA ID vient d'être créé. Il ne reste qu'à confirmer que cette | |
| 73 | + adresse est bien la vôtre — le lien est valable 24 heures.</p> | |
| 74 | + ${button(url, "Confirmer mon adresse →")}`, | |
| 75 | + ), | |
| 76 | + text: `Bonjour ${name},\n\nConfirmez votre adresse pour activer votre KA ID (lien valable 24 h) :\n${url}\n\nSi vous n'êtes pas à l'origine de cette demande, ignorez ce courriel.`, | |
| 77 | + }; | |
| 78 | +} | |
| 79 | + | |
| 80 | +export function magicLinkEmail(name: string, url: string) { | |
| 81 | + return { | |
| 82 | + subject: "Votre lien de connexion — KA ID", | |
| 83 | + html: layout( | |
| 84 | + "Connexion sans mot de passe", | |
| 85 | + `<p style="margin:0;font-size:14.5px;line-height:1.6;">Bonjour ${name},<br> | |
| 86 | + cliquez pour vous connecter à votre KA ID — aucun mot de passe requis. | |
| 87 | + Le lien est valable 15 minutes et ne sert qu'une fois.</p> | |
| 88 | + ${button(url, "Me connecter →")}`, | |
| 89 | + ), | |
| 90 | + text: `Bonjour ${name},\n\nVotre lien de connexion KA ID (valable 15 minutes, usage unique) :\n${url}\n\nSi vous n'êtes pas à l'origine de cette demande, ignorez ce courriel.`, | |
| 91 | + }; | |
| 92 | +} | |
| 93 | + | |
| 94 | +export function loginCodeEmail(name: string, code: string) { | |
| 95 | + return { | |
| 96 | + subject: `${code} — votre code de connexion KA ID`, | |
| 97 | + html: layout( | |
| 98 | + "Votre code de connexion", | |
| 99 | + `<p style="margin:0;font-size:14.5px;line-height:1.6;">Bonjour ${name},<br> | |
| 100 | + voici le code pour confirmer votre connexion. Il expire dans 10 minutes.</p> | |
| 101 | + <p style="margin:22px 0 0;font-family:Menlo,Consolas,monospace;font-size:32px;letter-spacing:0.35em;font-weight:bold;">${code}</p>`, | |
| 102 | + ), | |
| 103 | + text: `Bonjour ${name},\n\nVotre code de connexion KA ID (expire dans 10 minutes) : ${code}\n\nSi vous n'êtes pas à l'origine de cette connexion, changez votre mot de passe.`, | |
| 104 | + }; | |
| 105 | +} | |
| 106 | ||