Mon univers Ka : SSO KA ID + favoris unifiés au hub Groupe KA
- SSO KA ID (patron vrai-prix) : lib/ka-auth.ts (state signé, JWT HS256 du hub, session cookie httpOnly vpx_ka_session 30 j) + routes /api/auth/ka/login, /api/auth/ka/callback, /api/auth/logout, /api/auth/me - Favoris « Mon univers Ka » (patron food-ka hubfav) : lib/hubfav.ts — AUCUN stockage local, toggle synchrone au hub (HMAC client_id.ka_id.ts, timeout 6 s), lecture au hub avec cache mémoire 30 s invalidé au toggle - API : GET /api/favorites (401 sans session), POST /api/favorites/toggle - UI : cœur ♥ FavHeart (magasin client partagé, optimiste, redirige vers le SSO sans session) sur la fiche d'estimation et les cartes du parc ; page /favoris (connexion KA ID, liste, retrait) ; entrée « ♥ Favoris » au menu - .env.local (non versionné) : KA_SSO_SECRET, KA_AUTH_SECRET, KA_HUB_URL, KA_BASE_URL Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 changed files +937 −3
added
app/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 vpx_ka_session signé HMAC-SHA256, 30 jours) et | |
| 5 | +// redirige vers la destination demandée (ou /favoris). | |
| 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
app/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
app/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
app/src/app/api/auth/me/route.ts
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Session courante : { user } si le cookie vpx_ka_session est valide, sinon | |
| 3 | +// { user: null } — consommé par la page /favoris et les cœurs ♥ du site. | |
| 4 | +import { NextRequest, NextResponse } from "next/server"; | |
| 5 | +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth"; | |
| 6 | + | |
| 7 | +export function GET(req: NextRequest) { | |
| 8 | + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); | |
| 9 | + if (!s) return NextResponse.json({ user: null }); | |
| 10 | + return NextResponse.json({ | |
| 11 | + user: { | |
| 12 | + ka_id: s.ka_id, | |
| 13 | + email: s.email, | |
| 14 | + name: s.name, | |
| 15 | + picture: s.picture, | |
| 16 | + provider: s.provider ?? "ka-id", | |
| 17 | + }, | |
| 18 | + }); | |
| 19 | +} | |
added
app/src/app/api/favorites/route.ts
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Favoris « Mon univers Ka » du membre connecté — lus au HUB Groupe KA | |
| 3 | +// (groupe-ka.com), magasin central des favoris du groupe : aucun stockage | |
| 4 | +// local, cache mémoire 30 s (voir src/lib/hubfav.ts). 401 sans session KA ID. | |
| 5 | +import { NextRequest, NextResponse } from "next/server"; | |
| 6 | +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth"; | |
| 7 | +import { hubList } from "@/lib/hubfav"; | |
| 8 | + | |
| 9 | +export const dynamic = "force-dynamic"; | |
| 10 | + | |
| 11 | +export async function GET(req: NextRequest) { | |
| 12 | + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); | |
| 13 | + if (!s) { | |
| 14 | + return NextResponse.json( | |
| 15 | + { error: "connexion KA ID requise" }, | |
| 16 | + { status: 401 }, | |
| 17 | + ); | |
| 18 | + } | |
| 19 | + const favorites = await hubList(s.ka_id); | |
| 20 | + if (favorites === null) { | |
| 21 | + return NextResponse.json( | |
| 22 | + { error: "hub Groupe KA injoignable — réessayez dans un instant" }, | |
| 23 | + { status: 502 }, | |
| 24 | + ); | |
| 25 | + } | |
| 26 | + return NextResponse.json({ favorites }); | |
| 27 | +} | |
added
app/src/app/api/favorites/toggle/route.ts
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Toggle d'un favori « Mon univers Ka » — poussé de façon SYNCHRONE au HUB | |
| 3 | +// Groupe KA (POST signé HMAC, timeout 6 s) : le hub est le magasin central, | |
| 4 | +// un échec remonte à l'appelant (502). 401 sans session KA ID. | |
| 5 | +import { NextRequest, NextResponse } from "next/server"; | |
| 6 | +import { SESSION_COOKIE, readSession } from "@/lib/ka-auth"; | |
| 7 | +import { cleanItem, hubToggle } from "@/lib/hubfav"; | |
| 8 | + | |
| 9 | +export const dynamic = "force-dynamic"; | |
| 10 | + | |
| 11 | +export async function POST(req: NextRequest) { | |
| 12 | + const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); | |
| 13 | + if (!s) { | |
| 14 | + return NextResponse.json( | |
| 15 | + { error: "connexion KA ID requise" }, | |
| 16 | + { status: 401 }, | |
| 17 | + ); | |
| 18 | + } | |
| 19 | + let body: { action?: unknown; item?: unknown }; | |
| 20 | + try { | |
| 21 | + body = await req.json(); | |
| 22 | + } catch { | |
| 23 | + return NextResponse.json({ error: "corps JSON invalide" }, { status: 400 }); | |
| 24 | + } | |
| 25 | + const action = body.action === "add" || body.action === "remove" ? body.action : null; | |
| 26 | + const item = | |
| 27 | + typeof body.item === "object" && body.item !== null | |
| 28 | + ? cleanItem(body.item as Record<string, unknown>) | |
| 29 | + : null; | |
| 30 | + if (!action || !item?.item_id || (action === "add" && !item.title)) { | |
| 31 | + return NextResponse.json( | |
| 32 | + { error: "action (add|remove) et item.item_id requis (title requis pour add)" }, | |
| 33 | + { status: 400 }, | |
| 34 | + ); | |
| 35 | + } | |
| 36 | + const ok = await hubToggle(s.ka_id, action, item); | |
| 37 | + if (!ok) { | |
| 38 | + return NextResponse.json( | |
| 39 | + { error: "hub Groupe KA injoignable — favori non sauvegardé" }, | |
| 40 | + { status: 502 }, | |
| 41 | + ); | |
| 42 | + } | |
| 43 | + return NextResponse.json({ ok: true, action, item_id: item.item_id }); | |
| 44 | +} | |
added
app/src/app/favoris/FavorisClient.tsx
+250 −0
@@ -0,0 +1,250 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * /favoris — « Mon univers Ka » : les plex mis en ♥ avec le KA ID, lus au HUB | |
| 4 | + * Groupe KA (magasin central des favoris — les mêmes favoris suivent le membre | |
| 5 | + * sur toutes les plateformes du groupe). Aucun stockage local : retrait ici = | |
| 6 | + * retrait partout. Sans session, la page invite à se connecter avec KA ID. | |
| 7 | + */ | |
| 8 | +"use client"; | |
| 9 | +import { useEffect, useState } from "react"; | |
| 10 | +import Link from "next/link"; | |
| 11 | +import { useLang } from "@/components/LangContext"; | |
| 12 | +import { favStoreRemove } from "@/components/FavHeart"; | |
| 13 | + | |
| 14 | +type Fav = { | |
| 15 | + item_id: string; | |
| 16 | + title?: string; | |
| 17 | + subtitle?: string; | |
| 18 | + price_label?: string; | |
| 19 | + image_url?: string; | |
| 20 | + url?: string; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +type User = { ka_id: string; name: string; email: string }; | |
| 24 | + | |
| 25 | +/** Ne suit que les liens du site — un favori transporte une URL absolue. */ | |
| 26 | +function localPath(url: string | undefined): string | null { | |
| 27 | + if (!url) return null; | |
| 28 | + try { | |
| 29 | + const u = new URL(url); | |
| 30 | + if (u.hostname.endsWith("valoplex.com")) return u.pathname + u.search; | |
| 31 | + } catch {} | |
| 32 | + return null; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export default function FavorisClient() { | |
| 36 | + const { lang } = useLang(); | |
| 37 | + const fr = lang === "fr"; | |
| 38 | + const [user, setUser] = useState<User | null | undefined>(undefined); | |
| 39 | + const [favs, setFavs] = useState<Fav[] | null | undefined>(undefined); | |
| 40 | + const [removing, setRemoving] = useState<string | null>(null); | |
| 41 | + | |
| 42 | + useEffect(() => { | |
| 43 | + let alive = true; | |
| 44 | + (async () => { | |
| 45 | + try { | |
| 46 | + const me = (await (await fetch("/api/auth/me")).json()) as { | |
| 47 | + user: User | null; | |
| 48 | + }; | |
| 49 | + if (!alive) return; | |
| 50 | + setUser(me.user); | |
| 51 | + if (!me.user) { | |
| 52 | + setFavs(null); | |
| 53 | + return; | |
| 54 | + } | |
| 55 | + const r = await fetch("/api/favorites", { cache: "no-store" }); | |
| 56 | + if (!alive) return; | |
| 57 | + if (!r.ok) { | |
| 58 | + setFavs(null); | |
| 59 | + return; | |
| 60 | + } | |
| 61 | + const body = (await r.json()) as { favorites?: Fav[] }; | |
| 62 | + setFavs(body.favorites ?? []); | |
| 63 | + } catch { | |
| 64 | + if (alive) { | |
| 65 | + setUser(null); | |
| 66 | + setFavs(null); | |
| 67 | + } | |
| 68 | + } | |
| 69 | + })(); | |
| 70 | + return () => { | |
| 71 | + alive = false; | |
| 72 | + }; | |
| 73 | + }, []); | |
| 74 | + | |
| 75 | + const remove = async (f: Fav) => { | |
| 76 | + setRemoving(f.item_id); | |
| 77 | + try { | |
| 78 | + const r = await fetch("/api/favorites/toggle", { | |
| 79 | + method: "POST", | |
| 80 | + headers: { "Content-Type": "application/json" }, | |
| 81 | + body: JSON.stringify({ action: "remove", item: { item_id: f.item_id } }), | |
| 82 | + }); | |
| 83 | + if (r.ok) { | |
| 84 | + setFavs((xs) => (xs ?? []).filter((x) => x.item_id !== f.item_id)); | |
| 85 | + favStoreRemove(f.item_id); | |
| 86 | + } | |
| 87 | + } finally { | |
| 88 | + setRemoving(null); | |
| 89 | + } | |
| 90 | + }; | |
| 91 | + | |
| 92 | + const logout = async () => { | |
| 93 | + await fetch("/api/auth/logout", { method: "POST" }); | |
| 94 | + window.location.href = "/"; | |
| 95 | + }; | |
| 96 | + | |
| 97 | + return ( | |
| 98 | + <div className="pb-6 pt-10 sm:pt-14"> | |
| 99 | + {/* héros */} | |
| 100 | + <section> | |
| 101 | + <span className="kicker">Mon univers Ka</span> | |
| 102 | + <h1 className="vp-display mt-3 max-w-4xl text-[clamp(30px,5.6vw,58px)] font-bold uppercase leading-[0.98] tracking-[-0.035em]"> | |
| 103 | + {fr ? ( | |
| 104 | + <> | |
| 105 | + Mes <span className="hl">plex favoris</span> | |
| 106 | + </> | |
| 107 | + ) : ( | |
| 108 | + <> | |
| 109 | + My <span className="hl">favourite plexes</span> | |
| 110 | + </> | |
| 111 | + )} | |
| 112 | + </h1> | |
| 113 | + <p className="mt-4 max-w-xl text-[16px] text-ink-2"> | |
| 114 | + {fr | |
| 115 | + ? "Vos favoris sont attachés à votre KA ID et sauvegardés au hub Groupe KA : les mêmes cœurs vous suivent sur toutes les plateformes du groupe." | |
| 116 | + : "Your favourites are attached to your KA ID and saved at the Groupe KA hub: the same hearts follow you across every platform of the group."} | |
| 117 | + </p> | |
| 118 | + </section> | |
| 119 | + | |
| 120 | + {/* contenu */} | |
| 121 | + <section className="mt-9"> | |
| 122 | + {user === undefined || favs === undefined ? ( | |
| 123 | + <div className="vp-card p-6"> | |
| 124 | + <p className="vp-mono text-[12px] font-bold uppercase tracking-[0.08em] text-ink-3"> | |
| 125 | + {fr ? "Chargement…" : "Loading…"} | |
| 126 | + </p> | |
| 127 | + </div> | |
| 128 | + ) : !user ? ( | |
| 129 | + <div className="vp-card p-6 sm:p-8"> | |
| 130 | + <p className="vp-display text-[20px] font-bold uppercase tracking-[-0.02em]"> | |
| 131 | + {fr ? "Connectez-vous avec KA ID" : "Sign in with KA ID"} | |
| 132 | + </p> | |
| 133 | + <p className="mt-2 max-w-lg text-[15px] text-ink-2"> | |
| 134 | + {fr | |
| 135 | + ? "Un seul compte — gratuit — pour tout l'univers Groupe KA : vos favoris ValoPlex y sont réunis avec ceux des autres plateformes." | |
| 136 | + : "One free account for the whole Groupe KA universe: your ValoPlex favourites live there together with those of the other platforms."} | |
| 137 | + </p> | |
| 138 | + <a | |
| 139 | + href="/api/auth/ka/login?next=/favoris" | |
| 140 | + className="btn btn-primary mt-5" | |
| 141 | + > | |
| 142 | + {fr ? "Se connecter avec KA ID" : "Sign in with KA ID"} → | |
| 143 | + </a> | |
| 144 | + </div> | |
| 145 | + ) : favs === null ? ( | |
| 146 | + <div className="vp-card p-6"> | |
| 147 | + <p className="text-[15px] text-ink-2"> | |
| 148 | + {fr | |
| 149 | + ? "Le hub Groupe KA est injoignable pour l'instant — réessayez dans un instant." | |
| 150 | + : "The Groupe KA hub is unreachable right now — try again shortly."} | |
| 151 | + </p> | |
| 152 | + </div> | |
| 153 | + ) : ( | |
| 154 | + <> | |
| 155 | + <div className="mb-4 flex flex-wrap items-center gap-3"> | |
| 156 | + <p className="vp-mono text-[11.5px] font-bold uppercase tracking-[0.08em] text-ink-3"> | |
| 157 | + {user.name} · {user.ka_id} —{" "} | |
| 158 | + {fr | |
| 159 | + ? `${favs.length} favori${favs.length > 1 ? "s" : ""}` | |
| 160 | + : `${favs.length} favourite${favs.length > 1 ? "s" : ""}`} | |
| 161 | + </p> | |
| 162 | + <button | |
| 163 | + onClick={logout} | |
| 164 | + className="vp-mono cursor-pointer rounded-full border-[1.5px] border-ink bg-surface px-3 py-1 text-[10.5px] font-bold uppercase tracking-[0.08em] hover:bg-ink hover:text-lime" | |
| 165 | + > | |
| 166 | + {fr ? "Déconnexion" : "Sign out"} | |
| 167 | + </button> | |
| 168 | + </div> | |
| 169 | + {favs.length === 0 ? ( | |
| 170 | + <div className="vp-card p-6 sm:p-8"> | |
| 171 | + <p className="vp-display text-[20px] font-bold uppercase tracking-[-0.02em]"> | |
| 172 | + {fr ? "Aucun favori pour l'instant" : "No favourites yet"} | |
| 173 | + </p> | |
| 174 | + <p className="mt-2 max-w-lg text-[15px] text-ink-2"> | |
| 175 | + {fr | |
| 176 | + ? "Évaluez un plex puis touchez le cœur ♥ sur sa fiche : il apparaîtra ici — et partout dans votre univers Ka." | |
| 177 | + : "Value a plex, then tap the heart ♥ on its page: it will appear here — and everywhere in your Ka universe."} | |
| 178 | + </p> | |
| 179 | + <Link href="/" className="btn btn-primary mt-5"> | |
| 180 | + {fr ? "Évaluer un plex" : "Value a plex"} → | |
| 181 | + </Link> | |
| 182 | + </div> | |
| 183 | + ) : ( | |
| 184 | + <div className="grid gap-4 sm:grid-cols-2"> | |
| 185 | + {favs.map((f, i) => { | |
| 186 | + const href = localPath(f.url); | |
| 187 | + const inner = ( | |
| 188 | + <> | |
| 189 | + <div className="flex items-start justify-between gap-3"> | |
| 190 | + <div className="min-w-0"> | |
| 191 | + <p className="vp-mono text-[10px] font-bold text-green"> | |
| 192 | + {String(i + 1).padStart(2, "0")} | |
| 193 | + </p> | |
| 194 | + <p className="vp-display mt-0.5 text-[16px] font-bold uppercase leading-tight"> | |
| 195 | + {f.title || f.item_id} | |
| 196 | + </p> | |
| 197 | + {f.subtitle ? ( | |
| 198 | + <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.05em] text-ink-3"> | |
| 199 | + {f.subtitle} | |
| 200 | + </p> | |
| 201 | + ) : null} | |
| 202 | + </div> | |
| 203 | + <button | |
| 204 | + aria-label={fr ? "Retirer des favoris" : "Remove from favourites"} | |
| 205 | + title={fr ? "Retirer des favoris" : "Remove from favourites"} | |
| 206 | + disabled={removing === f.item_id} | |
| 207 | + onClick={(e) => { | |
| 208 | + e.preventDefault(); | |
| 209 | + e.stopPropagation(); | |
| 210 | + remove(f); | |
| 211 | + }} | |
| 212 | + className="vp-mono shrink-0 cursor-pointer rounded-full border-[1.5px] border-ink bg-ink px-2 py-1 text-[10px] font-bold text-lime hover:bg-surface hover:text-ink disabled:opacity-60" | |
| 213 | + > | |
| 214 | + ♥ ✕ | |
| 215 | + </button> | |
| 216 | + </div> | |
| 217 | + {f.price_label ? ( | |
| 218 | + <p className="vp-display mt-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3 text-[22px] font-bold tracking-[-0.02em]"> | |
| 219 | + {f.price_label} | |
| 220 | + </p> | |
| 221 | + ) : null} | |
| 222 | + </> | |
| 223 | + ); | |
| 224 | + return href ? ( | |
| 225 | + <Link | |
| 226 | + key={f.item_id} | |
| 227 | + href={href} | |
| 228 | + className="vp-card vp-card-hover rise block p-5" | |
| 229 | + style={{ animationDelay: `${i * 60}ms` }} | |
| 230 | + > | |
| 231 | + {inner} | |
| 232 | + </Link> | |
| 233 | + ) : ( | |
| 234 | + <div | |
| 235 | + key={f.item_id} | |
| 236 | + className="vp-card rise p-5" | |
| 237 | + style={{ animationDelay: `${i * 60}ms` }} | |
| 238 | + > | |
| 239 | + {inner} | |
| 240 | + </div> | |
| 241 | + ); | |
| 242 | + })} | |
| 243 | + </div> | |
| 244 | + )} | |
| 245 | + </> | |
| 246 | + )} | |
| 247 | + </section> | |
| 248 | + </div> | |
| 249 | + ); | |
| 250 | +} | |
added
app/src/app/favoris/page.tsx
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +import type { Metadata } from "next"; | |
| 3 | +import FavorisClient from "./FavorisClient"; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { | |
| 6 | + title: "Mes favoris — Mon univers Ka", | |
| 7 | + description: | |
| 8 | + "Vos plex favoris, sauvegardés avec votre KA ID et partagés sur toutes les plateformes du Groupe KA.", | |
| 9 | + robots: { index: false, follow: false }, | |
| 10 | +}; | |
| 11 | + | |
| 12 | +export default function FavorisPage() { | |
| 13 | + return <FavorisClient />; | |
| 14 | +} | |
modified
app/src/app/parc/page.tsx
+16 −3
@@ -2,6 +2,7 @@ | ||
| 2 | 2 | "use client"; |
| 3 | 3 | import { useEffect, useState } from "react"; |
| 4 | 4 | import Link from "next/link"; |
| 5 | +import FavHeart from "@/components/FavHeart"; | |
| 5 | 6 | import SearchBox, { type Suggestion } from "@/components/SearchBox"; |
| 6 | 7 | import { CountUp, locNum, Sparkline, ValueSplit } from "@/components/MetricViz"; |
| 7 | 8 | import { useLang } from "@/components/LangContext"; |
@@ -317,9 +318,21 @@ export default function ParcPage() { | ||
| 317 | 318 | {u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} |
| 318 | 319 | </p> |
| 319 | 320 | </div> |
| 320 | − <span className={`pill ${CONF[r.confidenceLevel]} shrink-0`}> | |
| 321 | − {r.confidenceLevel} | |
| 322 | − </span> | |
| 321 | + <div className="flex shrink-0 items-center gap-2"> | |
| 322 | + <FavHeart | |
| 323 | + small | |
| 324 | + item={{ | |
| 325 | + item_id: u.id, | |
| 326 | + title: u.adresse ?? "", | |
| 327 | + subtitle: u.municipalite ?? "", | |
| 328 | + price_label: fmt(r.estimate, lang), | |
| 329 | + url: `https://www.valoplex.com/estimation/${encodeURIComponent(u.id)}`, | |
| 330 | + }} | |
| 331 | + /> | |
| 332 | + <span className={`pill ${CONF[r.confidenceLevel]}`}> | |
| 333 | + {r.confidenceLevel} | |
| 334 | + </span> | |
| 335 | + </div> | |
| 323 | 336 | </div> |
| 324 | 337 | <div className="mt-3 flex items-end justify-between gap-3 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3"> |
| 325 | 338 | <div> |
added
app/src/components/FavHeart.tsx
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Cœur ♥ « Mon univers Ka » — favoris unifiés du Groupe KA. | |
| 4 | + * Le hub groupe-ka.com est le magasin central : le composant lit la liste UNE | |
| 5 | + * fois par page (magasin client partagé entre tous les cœurs) puis pousse | |
| 6 | + * chaque toggle au serveur (/api/favorites/toggle → hub, synchrone). Sans | |
| 7 | + * session KA ID, le clic envoie vers la connexion SSO (retour sur la page). | |
| 8 | + */ | |
| 9 | +"use client"; | |
| 10 | +import { useEffect, useReducer, useState } from "react"; | |
| 11 | +import { useLang } from "./LangContext"; | |
| 12 | + | |
| 13 | +export type FavHeartItem = { | |
| 14 | + item_id: string; | |
| 15 | + title: string; | |
| 16 | + subtitle?: string; | |
| 17 | + price_label?: string; | |
| 18 | + image_url?: string; | |
| 19 | + url?: string; | |
| 20 | +}; | |
| 21 | + | |
| 22 | +// -- magasin client partagé (un seul GET /api/favorites par page) ------------- | |
| 23 | +// undefined = pas encore chargé · null = pas de session KA ID · Set = favoris | |
| 24 | +let favSet: Set<string> | null | undefined; | |
| 25 | +let inflight: Promise<void> | null = null; | |
| 26 | +const subs = new Set<() => void>(); | |
| 27 | +const notify = () => subs.forEach((f) => f()); | |
| 28 | + | |
| 29 | +async function loadFavs(): Promise<void> { | |
| 30 | + try { | |
| 31 | + const r = await fetch("/api/favorites", { cache: "no-store" }); | |
| 32 | + if (r.status === 401) { | |
| 33 | + favSet = null; | |
| 34 | + return; | |
| 35 | + } | |
| 36 | + if (!r.ok) { | |
| 37 | + favSet = null; | |
| 38 | + return; | |
| 39 | + } | |
| 40 | + const body = (await r.json()) as { favorites?: { item_id?: string }[] }; | |
| 41 | + favSet = new Set( | |
| 42 | + (body.favorites ?? []).map((f) => String(f.item_id ?? "")).filter(Boolean), | |
| 43 | + ); | |
| 44 | + } catch { | |
| 45 | + favSet = null; | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +function ensureLoaded() { | |
| 50 | + if (favSet === undefined && !inflight) { | |
| 51 | + inflight = loadFavs().finally(() => { | |
| 52 | + inflight = null; | |
| 53 | + notify(); | |
| 54 | + }); | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** À appeler après un retrait effectué ailleurs (ex. page /favoris). */ | |
| 59 | +export function favStoreRemove(itemId: string) { | |
| 60 | + if (favSet instanceof Set) { | |
| 61 | + favSet.delete(itemId); | |
| 62 | + notify(); | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +export default function FavHeart({ | |
| 67 | + item, | |
| 68 | + small = false, | |
| 69 | +}: { | |
| 70 | + item: FavHeartItem; | |
| 71 | + small?: boolean; | |
| 72 | +}) { | |
| 73 | + const { lang } = useLang(); | |
| 74 | + const fr = lang === "fr"; | |
| 75 | + const [, force] = useReducer((x: number) => x + 1, 0); | |
| 76 | + const [busy, setBusy] = useState(false); | |
| 77 | + | |
| 78 | + useEffect(() => { | |
| 79 | + subs.add(force); | |
| 80 | + ensureLoaded(); | |
| 81 | + return () => { | |
| 82 | + subs.delete(force); | |
| 83 | + }; | |
| 84 | + }, []); | |
| 85 | + | |
| 86 | + const fav = favSet instanceof Set && favSet.has(item.item_id); | |
| 87 | + | |
| 88 | + const onClick = async (e: React.MouseEvent) => { | |
| 89 | + // le cœur vit parfois DANS un <Link> (cartes) — ne pas naviguer | |
| 90 | + e.preventDefault(); | |
| 91 | + e.stopPropagation(); | |
| 92 | + if (busy) return; | |
| 93 | + if (!(favSet instanceof Set)) { | |
| 94 | + // pas de session KA ID → connexion SSO, retour sur la page courante | |
| 95 | + const next = window.location.pathname + window.location.search; | |
| 96 | + window.location.href = `/api/auth/ka/login?next=${encodeURIComponent(next)}`; | |
| 97 | + return; | |
| 98 | + } | |
| 99 | + const action = fav ? "remove" : "add"; | |
| 100 | + setBusy(true); | |
| 101 | + // optimiste — retour arrière si le hub refuse | |
| 102 | + if (action === "add") favSet.add(item.item_id); | |
| 103 | + else favSet.delete(item.item_id); | |
| 104 | + notify(); | |
| 105 | + try { | |
| 106 | + const r = await fetch("/api/favorites/toggle", { | |
| 107 | + method: "POST", | |
| 108 | + headers: { "Content-Type": "application/json" }, | |
| 109 | + body: JSON.stringify({ action, item }), | |
| 110 | + }); | |
| 111 | + if (!r.ok) throw new Error(String(r.status)); | |
| 112 | + } catch { | |
| 113 | + if (action === "add") favSet.delete(item.item_id); | |
| 114 | + else favSet.add(item.item_id); | |
| 115 | + notify(); | |
| 116 | + } finally { | |
| 117 | + setBusy(false); | |
| 118 | + } | |
| 119 | + }; | |
| 120 | + | |
| 121 | + const label = fav | |
| 122 | + ? fr | |
| 123 | + ? "Retirer de mes favoris (Mon univers Ka)" | |
| 124 | + : "Remove from my favourites (My Ka universe)" | |
| 125 | + : fr | |
| 126 | + ? "Ajouter à mes favoris (Mon univers Ka)" | |
| 127 | + : "Add to my favourites (My Ka universe)"; | |
| 128 | + const size = small ? "h-9 w-9" : "h-11 w-11"; | |
| 129 | + const glyph = small ? 15 : 18; | |
| 130 | + | |
| 131 | + return ( | |
| 132 | + <button | |
| 133 | + type="button" | |
| 134 | + onClick={onClick} | |
| 135 | + aria-label={label} | |
| 136 | + title={label} | |
| 137 | + aria-pressed={fav} | |
| 138 | + disabled={busy} | |
| 139 | + className={`inline-flex ${size} shrink-0 cursor-pointer items-center justify-center rounded-full border-[1.5px] border-ink transition-all ${ | |
| 140 | + fav | |
| 141 | + ? "rotate-[-3deg] bg-ink text-lime" | |
| 142 | + : "bg-surface text-ink hover:bg-ink hover:text-lime" | |
| 143 | + } ${busy ? "opacity-60" : ""}`} | |
| 144 | + > | |
| 145 | + <svg | |
| 146 | + width={glyph} | |
| 147 | + height={glyph} | |
| 148 | + viewBox="0 0 24 24" | |
| 149 | + aria-hidden="true" | |
| 150 | + fill={fav ? "currentColor" : "none"} | |
| 151 | + stroke="currentColor" | |
| 152 | + strokeWidth="2.2" | |
| 153 | + strokeLinejoin="round" | |
| 154 | + > | |
| 155 | + <path d="M12 20.7l-1.3-1.2C6 15.3 2.9 12.5 2.9 9 2.9 6.2 5.1 4 7.9 4c1.6 0 3.1.7 4.1 1.9C13 4.7 14.5 4 16.1 4c2.8 0 5 2.2 5 5 0 3.5-3.1 6.3-7.8 10.5L12 20.7z" /> | |
| 156 | + </svg> | |
| 157 | + </button> | |
| 158 | + ); | |
| 159 | +} | |
modified
app/src/components/HeaderNav.tsx
+1 −0
@@ -30,6 +30,7 @@ export default function HeaderNav() { | ||
| 30 | 30 | { href: "/", label: fr ? "Estimer" : "Estimate" }, |
| 31 | 31 | { href: "/ka", label: fr ? "Ka · agent IA" : "Ka · AI agent" }, |
| 32 | 32 | { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" }, |
| 33 | + { href: "/favoris", label: fr ? "♥ Favoris" : "♥ Saved" }, | |
| 33 | 34 | { href: "/stats", label: fr ? "Statistiques" : "Statistics" }, |
| 34 | 35 | { href: "/methodologie", label: fr ? "Méthodologie" : "Methodology" }, |
| 35 | 36 | ]; |
modified
app/src/components/ResultView.tsx
+12 −0
@@ -3,6 +3,7 @@ | ||
| 3 | 3 | import { useState } from "react"; |
| 4 | 4 | import Link from "next/link"; |
| 5 | 5 | import { useLang } from "./LangContext"; |
| 6 | +import FavHeart from "./FavHeart"; | |
| 6 | 7 | import LeadForm from "./LeadForm"; |
| 7 | 8 | import RadarMap from "./RadarMap"; |
| 8 | 9 | import { |
@@ -136,6 +137,17 @@ export default function ResultView({ data }: { data: UnitEstimate }) { | ||
| 136 | 137 | <span className="vp-mono rotate-[-1deg] rounded-md border-[1.5px] border-ink bg-lime px-2.5 py-1 text-[11px] font-bold uppercase tracking-[0.08em] text-ink"> |
| 137 | 138 | {doorsLabel(doors, fr)} |
| 138 | 139 | </span> |
| 140 | + {u && ( | |
| 141 | + <FavHeart | |
| 142 | + item={{ | |
| 143 | + item_id: u.id, | |
| 144 | + title: u.adresse || doorsLabel(doors, fr), | |
| 145 | + subtitle: u.municipalite ?? "", | |
| 146 | + price_label: moneyFmt(r.estimate), | |
| 147 | + url: `https://www.valoplex.com/estimation/${encodeURIComponent(u.id)}`, | |
| 148 | + }} | |
| 149 | + /> | |
| 150 | + )} | |
| 139 | 151 | </div> |
| 140 | 152 | {u && ( |
| 141 | 153 | <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]"> |
added
app/src/lib/hubfav.ts
+129 −0
@@ -0,0 +1,129 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// ----------------------------------------------------------------------------- | |
| 3 | +// hubfav — favoris « Mon univers Ka » : le hub Groupe KA (groupe-ka.com) est le | |
| 4 | +// MAGASIN CENTRAL des favoris du groupe — ValoPlex ne stocke RIEN localement. | |
| 5 | +// Chaque ♥ est poussé au hub (POST signé HMAC, synchrone : un échec remonte à | |
| 6 | +// l'appelant) et la liste est lue au hub (GET signé, cache mémoire 30 s, | |
| 7 | +// invalidé à chaque toggle). Même secret que le SSO (KA_SSO_SECRET). | |
| 8 | +// sig = HMAC-SHA256(KA_SSO_SECRET, "valoplex.<ka_id>.<ts>") en hex, | |
| 9 | +// ts en secondes epoch (fenêtre ±300 s côté hub). | |
| 10 | +// ----------------------------------------------------------------------------- | |
| 11 | +import { createHmac } from "node:crypto"; | |
| 12 | +import { CLIENT_ID, KA_HUB_URL } from "@/lib/ka-auth"; | |
| 13 | + | |
| 14 | +const CACHE_TTL_MS = 30_000; // liste des favoris | |
| 15 | +const TIMEOUT_MS = 6_000; | |
| 16 | + | |
| 17 | +/** Item de favori tel qu'accepté par le hub (champ → longueur maximale). */ | |
| 18 | +const FIELDS: Record<string, number> = { | |
| 19 | + item_id: 120, | |
| 20 | + title: 200, | |
| 21 | + subtitle: 200, | |
| 22 | + price_label: 60, | |
| 23 | + image_url: 500, | |
| 24 | + url: 500, | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export type FavItem = { | |
| 28 | + item_id: string; | |
| 29 | + title: string; | |
| 30 | + subtitle?: string; | |
| 31 | + price_label?: string; | |
| 32 | + image_url?: string; | |
| 33 | + url?: string; | |
| 34 | +} & Record<string, unknown>; | |
| 35 | + | |
| 36 | +const cache = new Map<string, { at: number; favs: FavItem[] }>(); | |
| 37 | + | |
| 38 | +function sig(kaId: string, ts: number): string | null { | |
| 39 | + const secret = process.env.KA_SSO_SECRET; | |
| 40 | + if (!secret) return null; | |
| 41 | + return createHmac("sha256", secret) | |
| 42 | + .update(`${CLIENT_ID}.${kaId}.${ts}`) | |
| 43 | + .digest("hex"); | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** Vrai si le compte est relié au hub (KA-ID « ka-… » du groupe). */ | |
| 47 | +export function linked(kaId: string | null | undefined): boolean { | |
| 48 | + return typeof kaId === "string" && kaId.startsWith("ka-"); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Ne garde que les champs d'item connus, en chaînes tronquées. */ | |
| 52 | +export function cleanItem(raw: Record<string, unknown>): FavItem { | |
| 53 | + const out: Record<string, string> = {}; | |
| 54 | + for (const [k, max] of Object.entries(FIELDS)) { | |
| 55 | + const v = raw?.[k]; | |
| 56 | + if (v != null && String(v)) out[k] = String(v).slice(0, max); | |
| 57 | + } | |
| 58 | + return out as FavItem; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** Pousse un ♥ (« add » / « remove ») au hub — SYNCHRONE, timeout 6 s : | |
| 62 | + * le hub est le magasin des favoris, l'échec doit remonter à l'appelant. */ | |
| 63 | +export async function hubToggle( | |
| 64 | + kaId: string, | |
| 65 | + action: string, | |
| 66 | + item: FavItem, | |
| 67 | +): Promise<boolean> { | |
| 68 | + const ts = Math.floor(Date.now() / 1000); | |
| 69 | + const s = sig(kaId, ts); | |
| 70 | + if (!s || !linked(kaId) || (action !== "add" && action !== "remove")) { | |
| 71 | + return false; | |
| 72 | + } | |
| 73 | + let ok = false; | |
| 74 | + try { | |
| 75 | + const r = await fetch(`${KA_HUB_URL}/api/sso/favorites`, { | |
| 76 | + method: "POST", | |
| 77 | + headers: { "Content-Type": "application/json" }, | |
| 78 | + signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 79 | + body: JSON.stringify({ | |
| 80 | + client_id: CLIENT_ID, | |
| 81 | + ka_id: kaId, | |
| 82 | + ts: String(ts), | |
| 83 | + sig: s, | |
| 84 | + action, | |
| 85 | + item, | |
| 86 | + }), | |
| 87 | + }); | |
| 88 | + ok = r.status === 200; | |
| 89 | + } catch { | |
| 90 | + ok = false; | |
| 91 | + } | |
| 92 | + if (ok) cache.delete(kaId); // la prochaine lecture reflète le toggle | |
| 93 | + return ok; | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** Favoris ValoPlex du membre, lus au hub (cache mémoire 30 s). | |
| 97 | + * [] = aucun favori ; null = hub injoignable (erreur, jamais mise en cache). */ | |
| 98 | +export async function hubList(kaId: string): Promise<FavItem[] | null> { | |
| 99 | + if (!linked(kaId)) return []; // compte non relié au hub | |
| 100 | + const now = Date.now(); | |
| 101 | + const hit = cache.get(kaId); | |
| 102 | + if (hit && now - hit.at < CACHE_TTL_MS) return hit.favs; | |
| 103 | + const ts = Math.floor(now / 1000); | |
| 104 | + const s = sig(kaId, ts); | |
| 105 | + if (!s) return null; | |
| 106 | + let favs: FavItem[]; | |
| 107 | + try { | |
| 108 | + const q = new URLSearchParams({ | |
| 109 | + client_id: CLIENT_ID, | |
| 110 | + ka_id: kaId, | |
| 111 | + ts: String(ts), | |
| 112 | + sig: s, | |
| 113 | + }); | |
| 114 | + const r = await fetch(`${KA_HUB_URL}/api/sso/favorites?${q}`, { | |
| 115 | + signal: AbortSignal.timeout(TIMEOUT_MS), | |
| 116 | + cache: "no-store", | |
| 117 | + }); | |
| 118 | + if (r.status !== 200) return null; | |
| 119 | + const body = (await r.json()) as { favorites?: unknown }; | |
| 120 | + if (!Array.isArray(body.favorites)) return null; | |
| 121 | + favs = body.favorites.filter( | |
| 122 | + (f): f is FavItem => typeof f === "object" && f !== null, | |
| 123 | + ); | |
| 124 | + } catch { | |
| 125 | + return null; | |
| 126 | + } | |
| 127 | + cache.set(kaId, { at: now, favs }); | |
| 128 | + return favs; | |
| 129 | +} | |
added
app/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 = "vpx_ka_session"; | |
| 16 | +export const SESSION_DAYS = 30; | |
| 17 | +export const CLIENT_ID = "valoplex"; | |
| 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 valoplex.com). */ | |
| 24 | +export const KA_BASE_URL = ( | |
| 25 | + process.env.KA_BASE_URL || "https://www.valoplex.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 : "/favoris"; | |
| 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 "/favoris"; | |
| 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 | ||