SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

SSO « Se connecter avec KA ID » via le hub groupe-ka.com

Routes /api/auth/ka/{login,callback} + /api/auth/{me,logout}, session
cookie signée (ka-auth.ts, node:crypto pur), profil hub (hub-profile.ts,
cache 60 s), page /compte carte de membre, AccountButton au header.
(Déployé le 2026-08-13 ; l'intégration au layout arrive avec le commit SEO.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 13, 2026) parent f075b4a

14 changed files +941 −0

renamed src/app/page.tsx → src/app/HomeClient.tsx +0 −0
added src/app/api/auth/ka/callback/route.ts +48 −0
@@ -0,0 +1,48 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Retour SSO KA ID : vérifie le state signé localement puis le ka_token du hub
3 +// (JWT HS256 — signature KA_SSO_SECRET, alg, iss, aud, exp), pose la session
4 +// locale (cookie httpOnly vp_ka_session signé HMAC-SHA256, 30 jours) et
5 +// redirige vers la destination demandée (ou /compte).
6 +import { NextRequest, NextResponse } from "next/server";
7 +import {
8 + KA_BASE_URL,
9 + SESSION_COOKIE,
10 + SESSION_DAYS,
11 + sessionFromClaims,
12 + verifyKaToken,
13 + verifyState,
14 +} from "@/lib/ka-auth";
15 +
16 +export function GET(req: NextRequest) {
17 + const q = req.nextUrl.searchParams;
18 + const state = verifyState(q.get("state") ?? "");
19 + if (!state) {
20 + return NextResponse.json(
21 + { error: "state invalide ou expiré — recommencez la connexion" },
22 + { status: 400 },
23 + );
24 + }
25 + const claims = verifyKaToken(q.get("ka_token") ?? "");
26 + if (!claims) {
27 + return NextResponse.json(
28 + { error: "jeton KA invalide ou expiré — recommencez la connexion" },
29 + { status: 401 },
30 + );
31 + }
32 + const made = sessionFromClaims(claims);
33 + if (!made) {
34 + return NextResponse.json(
35 + { error: "jeton KA incomplet (ka_id manquant)" },
36 + { status: 401 },
37 + );
38 + }
39 + const res = NextResponse.redirect(new URL(state.next, KA_BASE_URL));
40 + res.cookies.set(SESSION_COOKIE, made.value, {
41 + httpOnly: true,
42 + secure: KA_BASE_URL.startsWith("https"),
43 + sameSite: "lax",
44 + path: "/",
45 + maxAge: SESSION_DAYS * 86400,
46 + });
47 + return res;
48 +}
added src/app/api/auth/ka/login/route.ts +22 −0
@@ -0,0 +1,22 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Départ SSO « Se connecter avec KA ID » : redirige vers le hub d'identité du
3 +// Groupe KA (groupe-ka.com) avec un state opaque signé localement (le state
4 +// transporte aussi la destination `next` de retour — chemin local seulement).
5 +import { NextRequest, NextResponse } from "next/server";
6 +import { CLIENT_ID, KA_BASE_URL, KA_HUB_URL, newState, sanitizeNext } from "@/lib/ka-auth";
7 +
8 +export function GET(req: NextRequest) {
9 + const next = sanitizeNext(req.nextUrl.searchParams.get("next"));
10 + const state = newState(next);
11 + if (!state || !process.env.KA_SSO_SECRET) {
12 + return NextResponse.json(
13 + { error: "SSO KA ID non configuré (KA_SSO_SECRET / KA_AUTH_SECRET manquant)" },
14 + { status: 503 },
15 + );
16 + }
17 + const url = new URL(`${KA_HUB_URL}/sso/authorize`);
18 + url.searchParams.set("client_id", CLIENT_ID);
19 + url.searchParams.set("redirect_uri", `${KA_BASE_URL}/api/auth/ka/callback`);
20 + url.searchParams.set("state", state);
21 + return NextResponse.redirect(url);
22 +}
added src/app/api/auth/logout/route.ts +17 −0
@@ -0,0 +1,17 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Déconnexion : efface le cookie de session local (la session est sans
3 +// stockage serveur — supprimer le cookie suffit). POST seulement.
4 +import { NextResponse } from "next/server";
5 +import { KA_BASE_URL, SESSION_COOKIE } from "@/lib/ka-auth";
6 +
7 +export function POST() {
8 + const res = NextResponse.json({ ok: true });
9 + res.cookies.set(SESSION_COOKIE, "", {
10 + httpOnly: true,
11 + secure: KA_BASE_URL.startsWith("https"),
12 + sameSite: "lax",
13 + path: "/",
14 + maxAge: 0,
15 + });
16 + return res;
17 +}
added src/app/api/auth/me/route.ts +37 −0
@@ -0,0 +1,37 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Session courante : { user } si le cookie vp_ka_session est valide, sinon
3 +// { user: null } — consommé par le bouton compte de l'en-tête et la page /compte.
4 +// La réponse est enrichie du profil saisi au HUB Groupe KA (bio, ville, emploi,
5 +// entreprise, âge, site web, réseaux, statut, profil public) quand il est
6 +// joignable — profile_source: "groupe-ka" ; sinon retombée sur la session
7 +// locale — profile_source: "local".
8 +import { NextRequest, NextResponse } from "next/server";
9 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
10 +import { fetchHubProfile } from "@/lib/hub-profile";
11 +
12 +export async function GET(req: NextRequest) {
13 + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
14 + if (!s) return NextResponse.json({ user: null });
15 + const hub = await fetchHubProfile(s.ka_id); // cache 60 s, null si injoignable
16 + return NextResponse.json({
17 + user: {
18 + ka_id: s.ka_id,
19 + email: hub?.email || s.email,
20 + name: hub?.name || s.name,
21 + picture: hub?.picture || s.picture,
22 + provider: s.provider ?? "ka-id",
23 + // — profil Groupe KA (source de vérité : groupe-ka.com/compte) —
24 + bio: hub?.bio ?? "",
25 + city: hub?.city ?? "",
26 + job_title: hub?.job_title ?? "",
27 + company: hub?.company ?? "",
28 + age: hub?.age ?? null,
29 + website: hub?.website ?? "",
30 + socials: hub?.socials ?? {},
31 + role_label: hub?.role_label ?? "",
32 + public: hub?.public ?? false,
33 + public_url: hub?.public_url ?? "",
34 + profile_source: hub ? "groupe-ka" : "local",
35 + },
36 + });
37 +}
added src/app/compte/CompteClient.tsx +425 −0
@@ -0,0 +1,425 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Mon compte — carte de membre Groupe KA (gabarit « pc » de Lou·Ka transposé) :
4 + * fond encre, filigrane KA, KA-ID en gros mono lime (#d9f26b) avec glow,
5 + * porteur + avatar + badge statut, bande code-barres ; profil Groupe KA en
6 + * lecture seule (saisi sur groupe-ka.com/compte, LA source de vérité), grille
7 + * d'infos et actions dessous.
8 + * Le lime « Groupe KA » est propre à la carte (l'accent Vrai-Prix reste rouge).
9 + */
10 +"use client";
11 +import { useState } from "react";
12 +import { useRouter } from "next/navigation";
13 +import { useLang } from "@/components/LangContext";
14 +import type { HubProfile, HubSocials } from "@/lib/hub-profile";
15 +
16 +// couleur signature du Groupe KA — réservée à la carte de membre
17 +const KA_LIME = "#d9f26b";
18 +
19 +type User = {
20 + ka_id: string;
21 + email: string;
22 + name: string;
23 + picture: string;
24 + provider: string;
25 +};
26 +
27 +// réseaux affichés en chips — libellé + URL depuis le pseudo (« @x ») ou l'URL
28 +const SOCIAL_LABELS: Record<keyof HubSocials, string> = {
29 + instagram: "Instagram",
30 + facebook: "Facebook",
31 + x: "X",
32 + linkedin: "LinkedIn",
33 + tiktok: "TikTok",
34 + youtube: "YouTube",
35 +};
36 +
37 +function socialUrl(net: keyof HubSocials, v: string): string {
38 + if (/^https?:\/\//i.test(v)) return v;
39 + const h = v.replace(/^@/, "");
40 + switch (net) {
41 + case "instagram":
42 + return `https://instagram.com/${h}`;
43 + case "facebook":
44 + return `https://facebook.com/${h}`;
45 + case "x":
46 + return `https://x.com/${h}`;
47 + case "linkedin":
48 + return `https://linkedin.com/in/${h}`;
49 + case "tiktok":
50 + return `https://tiktok.com/@${h}`;
51 + case "youtube":
52 + return `https://youtube.com/@${h}`;
53 + }
54 +}
55 +
56 +export default function CompteClient({
57 + user,
58 + profile,
59 +}: {
60 + user: User;
61 + profile: HubProfile | null;
62 +}) {
63 + const { lang } = useLang();
64 + const fr = lang === "fr";
65 + const router = useRouter();
66 + const [copied, setCopied] = useState(false);
67 + const [out, setOut] = useState(false);
68 +
69 + const copyKaId = async () => {
70 + try {
71 + await navigator.clipboard.writeText(user.ka_id);
72 + setCopied(true);
73 + setTimeout(() => setCopied(false), 1800);
74 + } catch {
75 + /* presse-papiers indisponible : tant pis */
76 + }
77 + };
78 +
79 + const logout = async () => {
80 + setOut(true);
81 + try {
82 + await fetch("/api/auth/logout", { method: "POST" });
83 + } finally {
84 + router.push("/");
85 + router.refresh();
86 + }
87 + };
88 +
89 + const infos: [string, React.ReactNode][] = [
90 + [fr ? "Nom" : "Name", user.name || "—"],
91 + [fr ? "Courriel" : "Email", user.email || "—"],
92 + [
93 + "KA-ID",
94 + <span key="k" className="vp-mono">{user.ka_id}</span>,
95 + ],
96 + [
97 + fr ? "Connexion" : "Sign-in",
98 + user.provider === "ka-id" || !user.provider
99 + ? "KA ID (groupe-ka.com)"
100 + : `KA ID · ${user.provider}`,
101 + ],
102 + ];
103 +
104 + return (
105 + <div className="mx-auto max-w-2xl pb-10 pt-10">
106 + <span className="kicker">{fr ? "Mon compte" : "My account"}</span>
107 + <h1 className="mb-7 mt-2 text-[clamp(30px,5vw,44px)] font-bold leading-tight">
108 + {user.name ? (
109 + <>
110 + {fr ? "Salut, " : "Hi, "}
111 + <span className="hl">{user.name.split(" ")[0]}</span>.
112 + </>
113 + ) : (
114 + <>
115 + {fr ? "Votre " : "Your "}
116 + <span className="hl">{fr ? "profil" : "profile"}</span>.
117 + </>
118 + )}
119 + </h1>
120 +
121 + {/* ——— Carte de membre Groupe KA ——— */}
122 + <div
123 + role="img"
124 + aria-label={`${fr ? "Carte de membre" : "Member card"} ${user.ka_id}`}
125 + className="relative max-w-[520px] overflow-hidden rounded-2xl border-2 border-ink bg-ink px-[26px] pt-6 text-paper"
126 + style={{ boxShadow: "10px 10px 0 rgba(20, 24, 20, 0.18)" }}
127 + >
128 + {/* filigrane */}
129 + <div
130 + aria-hidden="true"
131 + className="vp-display pointer-events-none absolute -top-[34px] right-[-18px] rotate-[-8deg] text-[170px] font-bold leading-none tracking-[-0.06em]"
132 + style={{ color: "rgba(217, 242, 107, 0.07)" }}
133 + >
134 + KA
135 + </div>
136 +
137 + {/* tête : marque + libellé */}
138 + <div className="flex flex-wrap items-baseline justify-between gap-3">
139 + <span className="vp-display text-[24px] font-bold tracking-[-0.04em]">
140 + Groupe
141 + <span
142 + className="ml-[3px] inline-block rotate-[-2deg] rounded-[5px] px-[6px] pb-[3px] pt-[1px] text-ink"
143 + style={{ background: KA_LIME }}
144 + >
145 + KA
146 + </span>
147 + </span>
148 + <span className="vp-mono text-[10px] uppercase tracking-[0.16em] text-[rgba(245,243,238,0.55)]">
149 + {fr ? "Carte de membre · Groupe KA" : "Member card · Groupe KA"}
150 + </span>
151 + </div>
152 +
153 + {/* KA-ID */}
154 + <div className="my-[24px] flex flex-col gap-1">
155 + <span
156 + className="vp-mono text-[10px] uppercase tracking-[0.2em]"
157 + style={{ color: "rgba(217, 242, 107, 0.65)" }}
158 + >
159 + KA-ID
160 + </span>
161 + <span
162 + className="vp-mono text-[clamp(22px,6vw,34px)] font-bold tracking-[0.14em]"
163 + style={{ color: KA_LIME, textShadow: "0 0 24px rgba(217, 242, 107, 0.35)" }}
164 + >
165 + {user.ka_id}
166 + </span>
167 + </div>
168 +
169 + {/* porteur + avatar */}
170 + <div className="flex items-end justify-between gap-3.5 pb-[18px]">
171 + <div className="flex min-w-0 flex-col gap-0.5">
172 + <span className="flex min-w-0 items-center gap-2">
173 + <span className="truncate text-[15px] font-semibold">
174 + {user.name || user.email}
175 + </span>
176 + {profile?.role_label ? (
177 + // badge statut — vient du hub (role_label), lime comme la carte
178 + <span
179 + className="vp-mono flex-none rounded-[4px] border px-[7px] pb-[2px] pt-[1px] text-[9.5px] font-bold uppercase tracking-[0.08em]"
180 + style={{
181 + borderColor: "rgba(217, 242, 107, 0.55)",
182 + color: KA_LIME,
183 + background: "rgba(217, 242, 107, 0.1)",
184 + }}
185 + >
186 + {profile.role_label}
187 + </span>
188 + ) : null}
189 + </span>
190 + <span className="vp-mono text-[10.5px] tracking-[0.06em] text-[rgba(245,243,238,0.5)]">
191 + {fr
192 + ? "Valide sur toutes les plateformes du Groupe KA"
193 + : "Valid across all Groupe KA platforms"}
194 + </span>
195 + </div>
196 + {user.picture ? (
197 + // eslint-disable-next-line @next/next/no-img-element
198 + <img
199 + src={user.picture}
200 + alt=""
201 + referrerPolicy="no-referrer"
202 + className="h-[52px] w-[52px] flex-none rounded-full border-2 object-cover"
203 + style={{ borderColor: KA_LIME }}
204 + />
205 + ) : (
206 + <span
207 + className="vp-display flex h-[52px] w-[52px] flex-none items-center justify-center rounded-full border-2 text-[22px] font-bold"
208 + style={{ borderColor: KA_LIME, color: KA_LIME }}
209 + >
210 + {(user.name || user.email || "K").charAt(0).toUpperCase()}
211 + </span>
212 + )}
213 + </div>
214 +
215 + {/* bande code-barres */}
216 + <div
217 + aria-hidden="true"
218 + className="-mx-[26px] flex gap-[5px] overflow-hidden px-[18px] py-[9px]"
219 + style={{
220 + background: "rgba(217, 242, 107, 0.1)",
221 + borderTop: "1px solid rgba(217, 242, 107, 0.25)",
222 + }}
223 + >
224 + {Array.from({ length: 34 }).map((_, i) => (
225 + <i
226 + key={i}
227 + className="block w-[3px] rounded-[1px]"
228 + style={{
229 + background: KA_LIME,
230 + height: i % 3 === 0 ? 14 : i % 3 === 1 ? 9 : 17,
231 + opacity: i % 3 === 0 ? 0.35 : i % 3 === 1 ? 0.7 : 0.9,
232 + }}
233 + />
234 + ))}
235 + </div>
236 + </div>
237 +
238 + {/* ——— actions carte ——— */}
239 + <div className="mt-4 flex flex-wrap gap-2.5">
240 + <button
241 + className="btn btn-ghost"
242 + onClick={copyKaId}
243 + style={copied ? { background: KA_LIME, color: "var(--ink)" } : undefined}
244 + >
245 + {copied ? "✓ " + (fr ? "Copié" : "Copied") : fr ? "Copier mon KA-ID" : "Copy my KA-ID"}
246 + </button>
247 + <button className="btn btn-ghost" onClick={logout} disabled={out}>
248 + {out ? (fr ? "Déconnexion…" : "Signing out…") : fr ? "Se déconnecter" : "Sign out"}
249 + </button>
250 + </div>
251 +
252 + {/* ——— Mon profil Groupe KA — lecture seule, saisi sur groupe-ka.com ——— */}
253 + <section className="vp-card mt-8 p-5 sm:p-6">
254 + <span className="kicker">
255 + {fr ? "Mon profil Groupe KA" : "My Groupe KA profile"}
256 + </span>
257 +
258 + {profile ? (
259 + <>
260 + {profile.bio ? (
261 + <p className="mt-3 max-w-xl text-[14.5px] leading-relaxed">
262 + {profile.bio}
263 + </p>
264 + ) : null}
265 +
266 + <div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
267 + {profile.job_title || profile.company ? (
268 + <div className="kv-cell">
269 + <div className="k">{fr ? "Emploi" : "Job"}</div>
270 + <div className="v break-words">
271 + {[profile.job_title, profile.company]
272 + .filter(Boolean)
273 + .join(" · ")}
274 + </div>
275 + </div>
276 + ) : null}
277 + {profile.city ? (
278 + <div className="kv-cell">
279 + <div className="k">{fr ? "Ville" : "City"}</div>
280 + <div className="v break-words">{profile.city}</div>
281 + </div>
282 + ) : null}
283 + {profile.age != null ? (
284 + <div className="kv-cell">
285 + <div className="k">{fr ? "Âge" : "Age"}</div>
286 + <div className="v">
287 + {profile.age} {fr ? "ans" : "yrs"}
288 + </div>
289 + </div>
290 + ) : null}
291 + {profile.website ? (
292 + <div className="kv-cell">
293 + <div className="k">{fr ? "Site web" : "Website"}</div>
294 + <div className="v break-words">
295 + <a
296 + href={
297 + /^https?:\/\//i.test(profile.website)
298 + ? profile.website
299 + : `https://${profile.website}`
300 + }
301 + target="_blank"
302 + rel="noopener noreferrer"
303 + className="underline decoration-2 underline-offset-2"
304 + >
305 + {profile.website.replace(/^https?:\/\//i, "")}
306 + </a>
307 + </div>
308 + </div>
309 + ) : null}
310 + <div className="kv-cell">
311 + <div className="k">{fr ? "Statut" : "Status"}</div>
312 + <div className="v break-words">
313 + {profile.role_label || (fr ? "Membre" : "Member")}
314 + {profile.public && profile.public_url ? (
315 + <>
316 + {" · "}
317 + <a
318 + href={profile.public_url}
319 + target="_blank"
320 + rel="noopener noreferrer"
321 + className="underline decoration-2 underline-offset-2"
322 + >
323 + {fr ? "profil public ↗" : "public profile ↗"}
324 + </a>
325 + </>
326 + ) : null}
327 + </div>
328 + </div>
329 + </div>
330 +
331 + {/* réseaux sociaux — chips cliquables */}
332 + {Object.entries(profile.socials || {}).some(([, v]) => v) ? (
333 + <div className="mt-4 flex flex-wrap gap-2">
334 + {(Object.keys(SOCIAL_LABELS) as (keyof HubSocials)[]).map(
335 + (net) => {
336 + const v = profile.socials?.[net];
337 + if (!v) return null;
338 + return (
339 + <a
340 + key={net}
341 + href={socialUrl(net, v)}
342 + target="_blank"
343 + rel="noopener noreferrer"
344 + className="stat-chip hover:bg-lime-soft"
345 + >
346 + <b>{SOCIAL_LABELS[net]}</b> {v}
347 + </a>
348 + );
349 + },
350 + )}
351 + </div>
352 + ) : null}
353 + </>
354 + ) : (
355 + <p className="mt-3 max-w-xl text-[13.5px] leading-relaxed text-ink-2">
356 + {fr
357 + ? "Votre profil se remplit sur le hub groupe-ka.com — bio, ville, emploi, réseaux sociaux…"
358 + : "Your profile is filled in on the groupe-ka.com hub — bio, city, job, social networks…"}
359 + </p>
360 + )}
361 +
362 + <div className="mt-5">
363 + <a
364 + href="https://www.groupe-ka.com/compte"
365 + target="_blank"
366 + rel="noopener noreferrer"
367 + className="btn btn-primary"
368 + >
369 + {fr
370 + ? "Modifier mon profil sur groupe-ka.com ↗"
371 + : "Edit my profile on groupe-ka.com ↗"}
372 + </a>
373 + </div>
374 + <p className="mt-3 text-[12.5px] text-ink-2">
375 + {fr
376 + ? "Une seule saisie, visible sur les huit plateformes du groupe."
377 + : "Enter it once — visible across the group's eight platforms."}
378 + </p>
379 + </section>
380 +
381 + {/* ——— informations ——— */}
382 + <section className="mt-8 grid grid-cols-1 gap-3 sm:grid-cols-2">
383 + {infos.map(([k, v]) => (
384 + <div key={k} className="kv-cell">
385 + <div className="k">{k}</div>
386 + <div className="v break-words">{v}</div>
387 + </div>
388 + ))}
389 + </section>
390 +
391 + <p className="mt-7 max-w-xl text-[13.5px] leading-relaxed text-ink-2">
392 + {fr ? (
393 + <>
394 + Votre <b>KA-ID</b> vous suit sur toutes les plateformes du{" "}
395 + <a
396 + href="https://www.groupe-ka.com"
397 + target="_blank"
398 + rel="noopener noreferrer"
399 + className="font-bold underline decoration-2 underline-offset-2"
400 + >
401 + Groupe KA
402 + </a>{" "}
403 + — c&apos;est le même identifiant partout. Vrai-Prix ne conserve que
404 + cette session (nom, courriel, photo) ; rien d&apos;autre, et jamais
405 + revendu.
406 + </>
407 + ) : (
408 + <>
409 + Your <b>KA-ID</b> follows you across every{" "}
410 + <a
411 + href="https://www.groupe-ka.com"
412 + target="_blank"
413 + rel="noopener noreferrer"
414 + className="font-bold underline decoration-2 underline-offset-2"
415 + >
416 + Groupe KA
417 + </a>{" "}
418 + platform — the same ID everywhere. Vrai-Prix only keeps this session
419 + (name, email, picture); nothing else, never sold.
420 + </>
421 + )}
422 + </p>
423 + </div>
424 + );
425 +}
added src/app/compte/page.tsx +38 −0
@@ -0,0 +1,38 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// Page « Mon compte » : carte de membre Groupe KA (KA-ID), profil saisi au hub
3 +// groupe-ka.com (bio, ville, emploi… — lecture seule ici), informations de la
4 +// session et actions. Sans session valide → départ SSO KA ID (retour ici).
5 +import type { Metadata } from "next";
6 +import { cookies } from "next/headers";
7 +import { redirect } from "next/navigation";
8 +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth";
9 +import { fetchHubProfile } from "@/lib/hub-profile";
10 +import CompteClient from "./CompteClient";
11 +
12 +export const metadata: Metadata = {
13 + title: "Mon compte",
14 + description:
15 + "Votre carte de membre Groupe KA : le KA-ID vous suit sur toutes les plateformes du groupe.",
16 + robots: { index: false, follow: false },
17 +};
18 +
19 +export default async function ComptePage() {
20 + const jar = await cookies();
21 + const session = readSession(jar.get(SESSION_COOKIE)?.value);
22 + if (!session) redirect("/api/auth/ka/login?next=/compte");
23 + // Profil au hub Groupe KA — cache 60 s ; null si injoignable ou non relié
24 + // (on retombe alors sur les données de la session locale).
25 + const profile = await fetchHubProfile(session.ka_id);
26 + return (
27 + <CompteClient
28 + user={{
29 + ka_id: session.ka_id,
30 + email: profile?.email || session.email,
31 + name: profile?.name || session.name,
32 + picture: profile?.picture || session.picture,
33 + provider: session.provider ?? "ka-id",
34 + }}
35 + profile={profile}
36 + />
37 + );
38 +}
renamed src/app/conditions/page.tsx → src/app/conditions/ConditionsClient.tsx +0 −0
renamed src/app/confidentialite/page.tsx → src/app/confidentialite/ConfidentialiteClient.tsx +0 −0
renamed src/app/methodologie/page.tsx → src/app/methodologie/MethodologieClient.tsx +0 −0
renamed src/app/parc/page.tsx → src/app/parc/ParcClient.tsx +0 −0
added src/components/AccountButton.tsx +81 −0
@@ -0,0 +1,81 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +/**
3 + * Bouton compte de l'en-tête (KA ID — SSO du Groupe KA).
4 + * · Déconnecté : pilule « KA ID » → /api/auth/ka/login?next=<page courante>
5 + * · Connecté : avatar (ou initiale) + prénom → /compte
6 + * Composant client : interroge /api/auth/me au montage (session cookie httpOnly).
7 + */
8 +"use client";
9 +import { useEffect, useState } from "react";
10 +import Link from "next/link";
11 +import { usePathname } from "next/navigation";
12 +import { useLang } from "./LangContext";
13 +
14 +type MeUser = {
15 + ka_id: string;
16 + email: string;
17 + name: string;
18 + picture: string;
19 + provider: string;
20 +};
21 +
22 +export default function AccountButton() {
23 + const { lang } = useLang();
24 + const fr = lang === "fr";
25 + const path = usePathname();
26 + // undefined = chargement (on ne montre rien pour éviter le clignotement)
27 + const [user, setUser] = useState<MeUser | null | undefined>(undefined);
28 +
29 + useEffect(() => {
30 + let alive = true;
31 + fetch("/api/auth/me")
32 + .then((r) => r.json())
33 + .then((d) => alive && setUser(d?.user ?? null))
34 + .catch(() => alive && setUser(null));
35 + return () => {
36 + alive = false;
37 + };
38 + }, [path]);
39 +
40 + if (user === undefined) {
41 + return <span className="min-h-[36px] w-[52px]" aria-hidden="true" />;
42 + }
43 +
44 + if (user === null) {
45 + return (
46 + <a
47 + href={`/api/auth/ka/login?next=${encodeURIComponent(path || "/")}`}
48 + className="vp-mono inline-flex min-h-[36px] items-center rounded-full border-[1.5px] border-ink bg-ink px-4 py-1.5 text-[12px] font-bold uppercase tracking-[0.08em] text-lime transition-all hover:bg-green-deep"
49 + title={fr ? "Se connecter avec KA ID" : "Sign in with KA ID"}
50 + >
51 + KA&nbsp;ID
52 + </a>
53 + );
54 + }
55 +
56 + const prenom = (user.name || user.email).split(" ")[0];
57 + return (
58 + <Link
59 + href="/compte"
60 + className="group inline-flex min-h-[36px] items-center gap-2 rounded-full border-[1.5px] border-ink bg-surface py-1 pl-1 pr-3 transition-all hover:bg-lime-soft"
61 + title={fr ? "Mon compte — carte de membre Groupe KA" : "My account — Groupe KA member card"}
62 + >
63 + {user.picture ? (
64 + // eslint-disable-next-line @next/next/no-img-element
65 + <img
66 + src={user.picture}
67 + alt=""
68 + referrerPolicy="no-referrer"
69 + className="h-7 w-7 rounded-full border border-ink object-cover"
70 + />
71 + ) : (
72 + <span className="vp-display flex h-7 w-7 items-center justify-center rounded-full bg-ink text-[13px] font-bold text-lime">
73 + {prenom.charAt(0).toUpperCase()}
74 + </span>
75 + )}
76 + <span className="vp-display max-w-[92px] truncate text-[13px] font-bold text-ink">
77 + {prenom}
78 + </span>
79 + </Link>
80 + );
81 +}
added src/lib/hub-profile.ts +94 −0
@@ -0,0 +1,94 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// -----------------------------------------------------------------------------
3 +// Profil membre lu depuis le HUB Groupe KA (groupe-ka.com).
4 +// Le hub est LA source de vérité du profil (bio, ville, emploi, entreprise,
5 +// site web, réseaux sociaux, photo, statut, profil public) — l'édition se
6 +// fait sur groupe-ka.com/compte, Vrai-Prix ne fait qu'afficher.
7 +// GET {hub}/api/sso/profile?client_id=vrai-prix&ka_id=…&ts=…&sig=…
8 +// avec sig = HMAC-SHA256(KA_SSO_SECRET, "vrai-prix.<ka_id>.<ts>") en hex
9 +// (même secret que le SSO). Cache mémoire 60 s (les 404 aussi — cache
10 +// négatif) ; null sur toute erreur (réseau, 401, 5xx, vieux compte non
11 +// relié) → l'appelant retombe sur les données locales de session.
12 +// Aucune dépendance : node:crypto + fetch natif seulement.
13 +// -----------------------------------------------------------------------------
14 +import { createHmac } from "node:crypto";
15 +import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth";
16 +
17 +const CACHE_TTL_MS = 60_000; // 60 s
18 +const TIMEOUT_MS = 5_000; // 5 s
19 +
20 +export type HubSocials = {
21 + instagram?: string;
22 + facebook?: string;
23 + x?: string;
24 + linkedin?: string;
25 + tiktok?: string;
26 + youtube?: string;
27 +};
28 +
29 +/** Réponse de GET /api/sso/profile du hub (200). */
30 +export type HubProfile = {
31 + ka_id: string;
32 + name: string;
33 + email: string;
34 + picture: string;
35 + role: string;
36 + role_label: string;
37 + bio: string;
38 + city: string;
39 + phone: string;
40 + website: string;
41 + job_title: string;
42 + company: string;
43 + birth_date: string;
44 + age: number | null;
45 + socials: HubSocials;
46 + public: boolean;
47 + public_url: string;
48 + created_at: string | number;
49 +};
50 +
51 +// Cache mémoire process-wide (Map globale — survit au HMR en dev) :
52 +// 200 → profil ; 404 → null (cache négatif). Erreurs transitoires : pas de cache.
53 +type CacheEntry = { at: number; data: HubProfile | null };
54 +const g = globalThis as unknown as { __vpHubProfileCache?: Map<string, CacheEntry> };
55 +const cache: Map<string, CacheEntry> =
56 + g.__vpHubProfileCache ?? (g.__vpHubProfileCache = new Map());
57 +
58 +/** Profil du membre au hub Groupe KA, ou null (inconnu ou injoignable). */
59 +export async function fetchHubProfile(kaId: string): Promise<HubProfile | null> {
60 + const secret = process.env.KA_SSO_SECRET;
61 + if (!secret || !kaId) return null;
62 +
63 + const now = Date.now();
64 + const hit = cache.get(kaId);
65 + if (hit && now - hit.at < CACHE_TTL_MS) return hit.data;
66 +
67 + const ts = Math.floor(now / 1000);
68 + const sig = createHmac("sha256", secret)
69 + .update(`${CLIENT_ID}.${kaId}.${ts}`)
70 + .digest("hex");
71 + const url =
72 + `${KA_HUB_URL}/api/sso/profile?client_id=${encodeURIComponent(CLIENT_ID)}` +
73 + `&ka_id=${encodeURIComponent(kaId)}&ts=${ts}&sig=${sig}`;
74 +
75 + const ctrl = new AbortController();
76 + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
77 + try {
78 + const r = await fetch(url, { signal: ctrl.signal, cache: "no-store" });
79 + if (r.status === 404) {
80 + cache.set(kaId, { at: now, data: null }); // cache négatif 60 s
81 + return null;
82 + }
83 + if (!r.ok) return null; // transitoire (401, 5xx…) : pas de cache
84 + const data = (await r.json()) as unknown;
85 + if (typeof data !== "object" || data === null) return null;
86 + const profile = data as HubProfile;
87 + cache.set(kaId, { at: now, data: profile });
88 + return profile;
89 + } catch {
90 + return null; // réseau / timeout / JSON : pas de cache
91 + } finally {
92 + clearTimeout(timer);
93 + }
94 +}
added src/lib/ka-auth.ts +179 −0
@@ -0,0 +1,179 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// -----------------------------------------------------------------------------
3 +// KA ID — SSO du Groupe KA (hub d'identité : groupe-ka.com).
4 +// « Se connecter avec KA ID » : l'utilisateur est envoyé au hub, qui le
5 +// renvoie ici avec un jeton JWT HS256 signé du secret partagé KA_SSO_SECRET
6 +// et transportant son profil (dont le KA-ID « ka-0123456789 », LE MÊME sur
7 +// toutes les plateformes du groupe). Aucune dépendance : node:crypto seulement.
8 +// · state / session locale : base64url(JSON) + "." + HMAC-SHA256 hex,
9 +// signés avec KA_AUTH_SECRET (jamais partagé).
10 +// · ka_token du hub : JWT HS256 vérifié avec KA_SSO_SECRET
11 +// (signature, alg, iss, aud, exp).
12 +// -----------------------------------------------------------------------------
13 +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
14 +
15 +export const SESSION_COOKIE = "vp_ka_session";
16 +export const SESSION_DAYS = 30;
17 +export const CLIENT_ID = "vrai-prix";
18 +
19 +export const KA_HUB_URL = (
20 + process.env.KA_HUB_URL || "https://www.groupe-ka.com"
21 +).replace(/\/+$/, "");
22 +
23 +/** URL publique de l'app (redirect_uri — le hub n'accepte que vrai-prix.com). */
24 +export const KA_BASE_URL = (
25 + process.env.KA_BASE_URL || "https://www.vrai-prix.com"
26 +).replace(/\/+$/, "");
27 +
28 +/** Session locale posée en cookie httpOnly après un retour SSO réussi. */
29 +export type KaSession = {
30 + ka_id: string;
31 + email: string;
32 + name: string;
33 + picture: string;
34 + provider?: string;
35 + exp: number; // epoch (secondes)
36 +};
37 +
38 +const now = () => Math.floor(Date.now() / 1000);
39 +
40 +function authSecret(): Buffer | null {
41 + const s = process.env.KA_AUTH_SECRET;
42 + return s ? Buffer.from(s, "utf8") : null;
43 +}
44 +
45 +function ssoSecret(): Buffer | null {
46 + const s = process.env.KA_SSO_SECRET;
47 + return s ? Buffer.from(s, "utf8") : null;
48 +}
49 +
50 +/** Comparaison à temps constant (timingSafeEqual exige des longueurs égales). */
51 +function safeEqual(a: Buffer, b: Buffer): boolean {
52 + return a.length === b.length && timingSafeEqual(a, b);
53 +}
54 +
55 +// -- jeton local signé (state SSO + session) : base64url(JSON).hex(HMAC) ------
56 +
57 +export function signLocal(payload: Record<string, unknown>): string | null {
58 + const secret = authSecret();
59 + if (!secret) return null;
60 + const raw = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
61 + const sig = createHmac("sha256", secret).update(raw).digest("hex");
62 + return `${raw}.${sig}`;
63 +}
64 +
65 +export function verifyLocal(token: string): Record<string, unknown> | null {
66 + const secret = authSecret();
67 + if (!secret || !token) return null;
68 + try {
69 + const dot = token.lastIndexOf(".");
70 + if (dot < 0) return null;
71 + const raw = token.slice(0, dot);
72 + const sig = token.slice(dot + 1);
73 + const expected = createHmac("sha256", secret).update(raw).digest("hex");
74 + if (!safeEqual(Buffer.from(sig, "utf8"), Buffer.from(expected, "utf8"))) {
75 + return null;
76 + }
77 + const payload = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
78 + if (typeof payload !== "object" || payload === null) return null;
79 + if (typeof payload.exp !== "number" || payload.exp < now()) return null;
80 + return payload as Record<string, unknown>;
81 + } catch {
82 + return null;
83 + }
84 +}
85 +
86 +// -- state SSO (anti-forgerie, transporte aussi la destination `next`) --------
87 +
88 +/** State opaque signé localement — expire en 10 minutes. */
89 +export function newState(next: string): string | null {
90 + return signLocal({
91 + n: randomBytes(12).toString("base64url"),
92 + next,
93 + exp: now() + 600,
94 + });
95 +}
96 +
97 +/** Vérifie le state et rend la destination `next` (chemin local sûr). */
98 +export function verifyState(state: string): { next: string } | null {
99 + const p = verifyLocal(state);
100 + if (!p) return null;
101 + const next = typeof p.next === "string" ? p.next : "/compte";
102 + return { next: sanitizeNext(next) };
103 +}
104 +
105 +/** N'accepte qu'un chemin local (« /… », jamais « //… » ni une URL absolue). */
106 +export function sanitizeNext(next: string | null | undefined): string {
107 + if (!next || !next.startsWith("/") || next.startsWith("//")) return "/compte";
108 + return next;
109 +}
110 +
111 +// -- ka_token : JWT HS256 émis par le hub (vérification stdlib) ---------------
112 +
113 +export function verifyKaToken(token: string): Record<string, unknown> | null {
114 + const secret = ssoSecret();
115 + if (!secret || !token) return null;
116 + try {
117 + const parts = token.split(".");
118 + if (parts.length !== 3) return null;
119 + const [h64, p64, s64] = parts;
120 + const expected = createHmac("sha256", secret)
121 + .update(`${h64}.${p64}`)
122 + .digest();
123 + if (!safeEqual(Buffer.from(s64, "base64url"), expected)) return null;
124 +
125 + const header = JSON.parse(Buffer.from(h64, "base64url").toString("utf8"));
126 + if (header?.alg !== "HS256") return null;
127 +
128 + const claims = JSON.parse(Buffer.from(p64, "base64url").toString("utf8"));
129 + if (claims?.iss !== KA_HUB_URL) return null;
130 + const aud = claims?.aud;
131 + const audOk =
132 + aud === CLIENT_ID || (Array.isArray(aud) && aud.includes(CLIENT_ID));
133 + if (!audOk) return null;
134 + if (typeof claims?.exp !== "number" || claims.exp < now()) return null;
135 + return claims as Record<string, unknown>;
136 + } catch {
137 + return null;
138 + }
139 +}
140 +
141 +// -- session locale ------------------------------------------------------------
142 +
143 +/** Construit le cookie de session (30 jours) depuis les claims du hub. */
144 +export function sessionFromClaims(
145 + claims: Record<string, unknown>,
146 +): { value: string; session: KaSession } | null {
147 + const kaId = typeof claims.ka_id === "string" ? claims.ka_id.trim() : "";
148 + if (!kaId) return null; // le KA-ID est créé À LA SOURCE par le hub — requis
149 + const email =
150 + typeof claims.email === "string" ? claims.email.toLowerCase() : "";
151 + const session: KaSession = {
152 + ka_id: kaId,
153 + email,
154 + name:
155 + typeof claims.name === "string" && claims.name
156 + ? claims.name
157 + : email.split("@")[0] || "membre",
158 + picture: typeof claims.picture === "string" ? claims.picture : "",
159 + provider: typeof claims.provider === "string" ? claims.provider : undefined,
160 + exp: now() + SESSION_DAYS * 86400,
161 + };
162 + const value = signLocal({ ...session });
163 + return value ? { value, session } : null;
164 +}
165 +
166 +/** Lit et vérifie le cookie de session — null si absent/invalide/expiré. */
167 +export function readSession(cookieValue: string | undefined): KaSession | null {
168 + if (!cookieValue) return null;
169 + const p = verifyLocal(cookieValue);
170 + if (!p || typeof p.ka_id !== "string" || !p.ka_id) return null;
171 + return {
172 + ka_id: p.ka_id,
173 + email: typeof p.email === "string" ? p.email : "",
174 + name: typeof p.name === "string" ? p.name : "",
175 + picture: typeof p.picture === "string" ? p.picture : "",
176 + provider: typeof p.provider === "string" ? p.provider : undefined,
177 + exp: p.exp as number,
178 + };
179 +}
180