SPB Git forge

spb/groupe-ka

Public

Groupe KA — site du holding + KA ID (compte unique & SSO des 7 plateformes). Next.js 16, SQLite, Google & Apple login.

81commits 1branches 0releases
89.2 MBsize
maindefault branch
22 days agolast push
TypeScript 70.4% HTML 18.4% JavaScript 4% Python 3.8% CSS 3.4%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 26, 2026) parent 4a5a08d

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&apos;y être envoyé. Il est valable 15 minutes
184 + et ne sert qu&apos;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