SPB Git forge

spb/trouve-ka

Public ★ Pinned

Trouve-KA — moteur de recherche web indépendant, Québec-first. Crawler distribué, index OpenSearch, ranking bilingue, galerie d'images. En prod : www.trouve-ka.com

40commits 1branches 0releases
9.2 MBsize
maindefault branch
23 days agolast push
Python 51.9% TypeScript 32.7% JavaScript 4.8% CSS 4.1% Shell 2.9% HTML 1.8% SQL 1.5%

Design Groupe KA (éditorial sharp, accent azur) + SSO KA ID

TÂCHE DESIGN — harmonisation sur le design system commun ka-ui :
- apps/web/ka/ : tokens.css (socle canonique), ecosystem.json, KaFooter
  (footer commun encre + flux du bot en enfant), GroupeKaBadge — vendorés
  depuis le repo ka-ui (ex-frontend/src/ka déplacé au bon endroit).
- globals.css rethémé : papier #f5f3ee, encre #141814, accent AZUR
  (#1c7ed6 / #e7f2fd / #14508f, texte blanc sur accent), bordures encre
  1,5 px + ombres décalées ; mode sombre retiré (thème éditorial unique).
- layout.tsx : tokens.css chargé en premier, badge « Un service Groupe KA »
  cliquable vers le hub, bouton compte KA au header, KaFooter commun
  (11 sites sœurs, 3 courriels + rôles, liens légaux du hub, © année),
  title « Trouve-Ka — Un service Groupe KA », themeColor papier.
- Nouvelle page /contact : courriels contact@/info@/admin@groupe-ka.com,
  avertissement agrégateur, création de compte KA → groupe-ka.com/connexion.

TÂCHE SSO KA ID — connexion déléguée au hub groupe-ka.com :
- lib/ka-auth.ts (node:crypto pur, modèle vrai-prix) : state signé local,
  vérification JWT HS256 ka_token (aud=trouve-ka, KA_SSO_SECRET), session
  cookie httpOnly tk_ka_session 30 jours.
- Routes /api/auth/ka/login, /api/auth/ka/callback, /api/auth/me,
  /api/auth/logout (servies par Next avant le rewrite vers FastAPI).
- next.config.mjs : chargement du .env racine du monorepo (KA_SSO_SECRET,
  KA_HUB_URL) sans dépendance.

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

15 changed files +1,013 −271

added apps/web/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 tk_ka_session signé HMAC-SHA256, 30 jours) et
5 +// redirige vers la destination demandée (ou l'accueil).
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 apps/web/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 » : 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 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 apps/web/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 apps/web/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 tk_ka_session est valide, sinon
3 +// { user: null } — consommé par le bouton compte de l'en-tête.
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 apps/web/app/contact/page.tsx +91 −0
@@ -0,0 +1,91 @@
1 +/**
2 + * Trouve-KA — page /contact (coordonnées communes Groupe KA)
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + *
6 + * Les coordonnées viennent de ka/ecosystem.json (source canonique ka-ui) :
7 + * 3 courriels + rôles, avertissement agrégateur, liens légaux du hub et
8 + * création de compte KA ID sur groupe-ka.com/connexion.
9 + */
10 +
11 +import type { Metadata } from "next";
12 +import eco from "@/ka/ecosystem.json";
13 +
14 +export const metadata: Metadata = {
15 + title: "Contact",
16 + description:
17 + "Joindre Groupe KA — projets et partenariats, médias, légal et vie privée (Loi 25). Trouve-KA est un service Groupe KA.",
18 +};
19 +
20 +export default function ContactPage() {
21 + return (
22 + <main id="contenu" className="mx-auto w-full max-w-3xl flex-1 px-4 py-12 sm:px-6">
23 + <p className="kicker">Un service Groupe KA</p>
24 + <h1 className="mt-3 text-4xl font-bold sm:text-5xl">
25 + Nous <span className="hl">joindre</span>
26 + </h1>
27 + <p className="mt-4 max-w-xl text-[15px] text-ink-2">
28 + Trouve-KA — le moteur de recherche du web québécois — est un service de{" "}
29 + <a
30 + href={eco.hub.url}
31 + target="_blank"
32 + rel="noopener noreferrer"
33 + className="font-bold text-ink underline underline-offset-4 hover:decoration-[var(--accent)]"
34 + >
35 + {eco.org.name}
36 + </a>
37 + . {eco.org.tagline}
38 + </p>
39 +
40 + <div className="mt-8 grid gap-4 sm:grid-cols-1">
41 + {eco.contacts.map((c) => (
42 + <div key={c.email} className="gk-card flex flex-col gap-1 p-5">
43 + <span className="klabel">{c.role}</span>
44 + <a
45 + href={`mailto:${c.email}`}
46 + className="gk-mono text-[15px] font-bold text-ink underline-offset-4 hover:text-[var(--accent-deep)] hover:underline"
47 + >
48 + {c.email}
49 + </a>
50 + </div>
51 + ))}
52 + </div>
53 +
54 + <p className="mt-8 border-l-2 border-[var(--accent)] pl-4 text-[14px] text-ink-2">
55 + <b className="text-ink">{eco.org.disclaimer}</b>
56 + </p>
57 +
58 + <div className="gk-card mt-8 p-5">
59 + <span className="klabel">Compte KA ID</span>
60 + <p className="mt-2 text-[14px] text-ink-2">
61 + Un seul compte pour tout l&apos;écosystème ·Ka. La création de compte se
62 + fait sur le hub&nbsp;:
63 + </p>
64 + <a
65 + href={`${eco.hub.url}${eco.hub.loginPath}`}
66 + target="_blank"
67 + rel="noopener noreferrer"
68 + className="btn btn-primary mt-4"
69 + >
70 + Créer un compte KA
71 + </a>
72 + </div>
73 +
74 + <p className="gk-mono mt-10 text-[11px] uppercase tracking-[0.08em] text-ink-3">
75 + {eco.legal.map((l, i) => (
76 + <span key={l.href}>
77 + {i > 0 ? " · " : ""}
78 + <a
79 + href={l.href}
80 + target="_blank"
81 + rel="noopener noreferrer"
82 + className="underline-offset-4 hover:text-ink hover:underline"
83 + >
84 + {l.label}
85 + </a>
86 + </span>
87 + ))}
88 + </p>
89 + </main>
90 + );
91 +}
modified apps/web/app/globals.css +75 −110
@@ -3,9 +3,14 @@
3 3 * Author: Simon-Pierre Boucher
4 4 * Contact: contact@spboucher.ai
5 5 *
6 + * Le socle canonique (palette papier/encre, composants partagés) vient de
7 + * ka/tokens.css, chargé AVANT ce fichier dans app/layout.tsx. Ici : l'accent
8 + * AZUR attribué à trouve-ka, le pont vers les polices next/font et les
9 + * composants propres au moteur de recherche.
10 + *
6 11 * Typo display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
7 − * Signature : bordures encre + ombres décalées (néo-brutalisme raffiné)
8 − * Accent bleu pâle marine #b9cfee sur encre
12 + * Signature : papier #f5f3ee, encre #141814, bordures encre + ombres décalées
13 + * Accent trouve-ka : AZUR #1c7ed6
9 14 */
10 15
11 16 @tailwind base;
@@ -13,85 +18,56 @@
13 18 @tailwind utilities;
14 19
15 20 :root {
16 − /* Thème clair « Lou·Ka » : blanc, encre navy, accent orange, liens bleus,
17 − coins doux et ombres diffuses (fini les ombres décalées en clair). */
18 − --paper: #ffffff;
21 + /* ---- Palette fixe Groupe KA (tokens.css fait foi) ---- */
22 + --paper: #f5f3ee;
19 23 --surface: #ffffff;
20 − --surface-2: #f4f5f8;
21 − --ink: #0b1330;
22 − --ink-2: #3e4560;
23 − --ink-3: #8a90a5;
24 − --line: rgba(11, 19, 48, 0.1);
25 − --line-strong: rgba(11, 19, 48, 0.16);
26 − --edge: rgba(11, 19, 48, 0.14);
27 − --green: #2258ff;
28 − --green-deep: #0b1330;
29 − --lime: #ff6a00;
30 − --lime-soft: #fff1e6;
31 − --blue-soft: #e9efff;
32 − --amber: #ff6a00;
33 − --amber-soft: #fff1e6;
34 − --danger: #d64545;
35 − --danger-soft: #fdecec;
36 −
37 − --paper-glass: rgba(255, 255, 255, 0.88);
38 −
39 − --r-card: 16px;
40 − --r-ctl: 10px;
41 − --shadow-flat: 0 1px 3px rgba(11, 19, 48, 0.06);
42 − --shadow-off: 0 16px 40px rgba(11, 19, 48, 0.14);
43 − --shadow-off-soft: 0 8px 24px rgba(11, 19, 48, 0.08);
44 − --shadow-btn: 0 4px 14px rgba(11, 19, 48, 0.18);
45 − --shadow-field: 0 2px 10px rgba(11, 19, 48, 0.06);
24 + --surface-2: #faf9f5;
25 + --ink: #141814;
26 + --ink-2: #4d5551;
27 + --ink-3: #8b928c;
28 + --line: rgba(20, 24, 20, 0.14);
29 + --line-strong: rgba(20, 24, 20, 0.28);
30 + --edge: rgba(20, 24, 20, 0.85);
31 + --green: #1c5c41;
32 + --green-deep: #123f2e;
33 +
34 + /* ---- ACCENT trouve-ka : AZUR ---- */
35 + --accent: #1c7ed6;
36 + --accent-soft: #e7f2fd;
37 + --accent-deep: #14508f;
38 + --on-accent: #ffffff;
39 +
40 + /* alias historiques du site (l'accent s'appelait --lime) */
41 + --lime: var(--accent);
42 + --lime-soft: var(--accent-soft);
43 + --blue-soft: var(--accent-soft);
44 + --amber: #e8a33d;
45 + --amber-soft: #fdf3e2;
46 + --danger: #b3423a;
47 + --danger-soft: #fbe9e7;
48 +
49 + --paper-glass: rgba(245, 243, 238, 0.88);
50 +
51 + /* ---- Géométrie sharp + ombres décalées ---- */
52 + --r-card: 10px;
53 + --r-ctl: 6px;
54 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
55 + --shadow-off: 6px 6px 0 var(--ink);
56 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
57 + --shadow-btn: 4px 4px 0 rgba(20, 24, 20, 0.18);
58 + --shadow-field: 0 1px 2px rgba(20, 24, 20, 0.05);
46 59
47 60 --btn-primary-bg: var(--ink);
48 − --btn-primary-fg: #ffffff;
49 − --btn-primary-hover: #22315e;
61 + --btn-primary-fg: var(--accent);
62 + --btn-primary-hover: var(--green-deep);
50 63
64 + /* ---- Pont next/font → tokens ---- */
51 65 --font-display: var(--font-space-grotesk), system-ui, sans-serif;
52 66 --font-serif: var(--font-fraunces), Georgia, serif;
53 67 --font-body: var(--font-inter), system-ui, sans-serif;
54 68 --font-mono: var(--font-jetbrains), ui-monospace, monospace;
55 69 }
56 70
57 −/* ---------- Mode sombre — navy profond, mêmes accents ---------- */
58 −[data-theme="dark"] {
59 − --paper: #0a0f22;
60 − --surface: #121834;
61 − --surface-2: #0e142c;
62 − --ink: #edf0f7;
63 − --ink-2: #b3bacf;
64 − --ink-3: #7b83a0;
65 − --line: rgba(237, 240, 247, 0.12);
66 − --line-strong: rgba(237, 240, 247, 0.24);
67 − --edge: rgba(237, 240, 247, 0.16);
68 − --green: #7da2ff;
69 − --green-deep: #0a0f22;
70 − --lime: #ff7d1f;
71 − --lime-soft: #3a2408;
72 − --blue-soft: #16224a;
73 − --amber: #ff7d1f;
74 − --amber-soft: #3a2408;
75 − --danger: #e57373;
76 − --danger-soft: #3a1414;
77 −
78 − --paper-glass: rgba(10, 15, 34, 0.86);
79 −
80 − --shadow-flat: 0 1px 3px rgba(0, 0, 0, 0.45);
81 − --shadow-off: 0 16px 40px rgba(0, 0, 0, 0.5);
82 − --shadow-off-soft: 0 8px 24px rgba(0, 0, 0, 0.4);
83 − --shadow-btn: 0 4px 14px rgba(0, 0, 0, 0.5);
84 − --shadow-field: 0 2px 10px rgba(0, 0, 0, 0.4);
85 −
86 − /* Bouton primaire inversé : accent orange, texte sombre */
87 − --btn-primary-bg: var(--lime);
88 − --btn-primary-fg: #0a0f22;
89 − --btn-primary-hover: #ff9346;
90 −}
91 −[data-theme="dark"] body::before {
92 − opacity: 0.22;
93 −}
94 −
95 71 @layer base {
96 72 html {
97 73 -webkit-text-size-adjust: 100%;
@@ -107,21 +83,6 @@
107 83 -webkit-font-smoothing: antialiased;
108 84 }
109 85
110 − /* Grain de film pleine page — jamais de z-index sur body > * (position:
111 − relative seulement) pour ne pas casser les utilitaires z-* de Tailwind. */
112 − body::before {
113 − content: "";
114 − position: fixed;
115 − inset: 0;
116 − pointer-events: none;
117 − opacity: 0.35;
118 − z-index: 9999;
119 − background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
120 − }
121 − body > * {
122 − position: relative;
123 − }
124 −
125 86 h1,
126 87 h2,
127 88 h3,
@@ -131,8 +92,8 @@
131 92 }
132 93
133 94 ::selection {
134 − background: var(--lime);
135 − color: var(--ink);
95 + background: var(--accent);
96 + color: var(--on-accent);
136 97 }
137 98
138 99 :focus-visible {
@@ -185,7 +146,8 @@
185 146 }
186 147 .hl {
187 148 display: inline-block;
188 − background: var(--lime);
149 + background: var(--accent);
150 + color: var(--on-accent);
189 151 border-radius: 8px;
190 152 padding: 0 10px 2px;
191 153 transform: rotate(-1deg);
@@ -195,7 +157,7 @@
195 157
196 158 .gk-card {
197 159 background: var(--surface);
198 − border: 1px solid var(--edge);
160 + border: 1.5px solid var(--edge);
199 161 border-radius: var(--r-card);
200 162 box-shadow: var(--shadow-off-soft);
201 163 }
@@ -218,7 +180,7 @@
218 180 gap: 8px;
219 181 min-height: 44px;
220 182 padding: 10px 20px;
221 − border: 1px solid var(--edge);
183 + border: 1.5px solid var(--edge);
222 184 border-radius: var(--r-ctl);
223 185 font-family: var(--font-display);
224 186 font-size: 14px;
@@ -227,6 +189,7 @@
227 189 text-decoration: none;
228 190 transition:
229 191 background 0.15s ease,
192 + color 0.15s ease,
230 193 transform 0.1s ease,
231 194 box-shadow 0.1s ease;
232 195 }
@@ -247,7 +210,7 @@
247 210 .btn-search {
248 211 font-family: var(--font-display);
249 212 font-weight: 700;
250 − border: 1px solid var(--edge);
213 + border: 1.5px solid var(--edge);
251 214 border-radius: 5px;
252 215 background: var(--btn-primary-bg);
253 216 color: var(--btn-primary-fg);
@@ -257,11 +220,11 @@
257 220 background: var(--btn-primary-hover);
258 221 }
259 222
260 − /* ---------- Pastilles de filtre (thème-aware) ---------- */
223 + /* ---------- Pastilles de filtre ---------- */
261 224 .pill {
262 225 font-family: var(--font-mono);
263 226 white-space: nowrap;
264 − border: 1px solid var(--edge);
227 + border: 1.5px solid var(--edge);
265 228 border-radius: 999px;
266 229 padding: 6px 14px;
267 230 font-size: 11px;
@@ -278,7 +241,7 @@
278 241 box-shadow 0.15s ease;
279 242 }
280 243 .pill:hover {
281 − background: var(--lime-soft);
244 + background: var(--accent-soft);
282 245 color: var(--ink);
283 246 transform: translateY(-1px);
284 247 box-shadow: var(--shadow-field);
@@ -305,7 +268,7 @@
305 268 z-index: 50;
306 269 overflow: hidden;
307 270 background: var(--surface);
308 − border: 1px solid var(--edge);
271 + border: 1.5px solid var(--edge);
309 272 border-radius: var(--r-card);
310 273 box-shadow: var(--shadow-off-soft);
311 274 animation: rise 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both;
@@ -323,7 +286,7 @@
323 286 }
324 287 .suggest-item[aria-selected="true"],
325 288 .suggest-item:hover {
326 − background: var(--lime-soft);
289 + background: var(--accent-soft);
327 290 color: var(--ink);
328 291 }
329 292 .suggest-item mark {
@@ -357,7 +320,7 @@
357 320 display: inline-flex;
358 321 align-items: center;
359 322 gap: 7px;
360 − border: 1px solid var(--edge);
323 + border: 1.5px solid var(--edge);
361 324 border-radius: 999px;
362 325 padding: 7px 15px;
363 326 font-size: 13px;
@@ -370,7 +333,7 @@
370 333 transform 0.15s ease;
371 334 }
372 335 .chip-link:hover {
373 − background: var(--lime-soft);
336 + background: var(--accent-soft);
374 337 color: var(--ink);
375 338 transform: translateY(-1px);
376 339 }
@@ -379,15 +342,17 @@
379 342 color: var(--ink);
380 343 }
381 344 .btn-ghost:hover {
382 − background: var(--lime);
345 + background: var(--accent);
346 + color: var(--on-accent);
383 347 }
384 348 .btn-lime {
385 − background: var(--lime);
386 − color: var(--ink);
349 + background: var(--accent);
350 + color: var(--on-accent);
387 351 box-shadow: var(--shadow-btn);
388 352 }
389 353 .btn-lime:hover {
390 − background: var(--lime-soft);
354 + background: var(--accent-deep);
355 + color: var(--on-accent);
391 356 }
392 357
393 358 /* ---------- Champs de formulaire ---------- */
@@ -397,7 +362,7 @@
397 362 width: 100%;
398 363 padding: 11px 14px;
399 364 background: var(--surface);
400 − border: 1px solid var(--edge);
365 + border: 1.5px solid var(--edge);
401 366 border-radius: var(--r-ctl);
402 367 font-family: var(--font-body);
403 368 font-size: 14px;
@@ -409,9 +374,9 @@
409 374 color: var(--ink-3);
410 375 }
411 376 .field:focus {
412 − outline: 2px solid var(--green);
413 − outline-offset: 1px;
414 − box-shadow: 0 0 0 3px var(--blue-soft);
377 + outline: none;
378 + border-color: var(--ink);
379 + box-shadow: 3px 3px 0 var(--accent);
415 380 }
416 381
417 382 /* ---------- Pilules de stats ---------- */
@@ -423,7 +388,7 @@
423 388 white-space: nowrap;
424 389 padding: 8px 14px;
425 390 background: var(--surface);
426 − border: 1px solid var(--edge);
391 + border: 1.5px solid var(--edge);
427 392 border-radius: 999px;
428 393 font-family: var(--font-mono);
429 394 font-size: 12px;
@@ -444,14 +409,14 @@
444 409 position: absolute;
445 410 inset: -4px;
446 411 border-radius: 999px;
447 − border: 2px solid var(--lime);
412 + border: 2px solid var(--accent);
448 413 animation: pulse 2.4s ease-out infinite;
449 414 }
450 415
451 416 /* ---------- Tableau éditorial ---------- */
452 417
453 418 .src-wrap {
454 − border: 1px solid var(--edge);
419 + border: 1.5px solid var(--edge);
455 420 border-radius: var(--r-card);
456 421 box-shadow: var(--shadow-off-soft);
457 422 overflow-x: auto;
modified apps/web/app/layout.tsx +44 −100
@@ -2,6 +2,11 @@
2 2 * Trouve-KA — layout racine de l'application web
3 3 * Author: Simon-Pierre Boucher
4 4 * Contact: contact@spboucher.ai
5 + *
6 + * Design « éditorial sharp » Groupe KA : ka/tokens.css (socle canonique)
7 + * chargé en premier, puis globals.css (accent AZUR + composants du moteur).
8 + * Header : wordmark + badge « Un service Groupe KA » + connexion KA ID.
9 + * Footer : KaFooter commun (ecosystem.json) + flux temps réel du bot.
5 10 */
6 11
7 12 import type { Metadata, Viewport } from "next";
@@ -9,7 +14,10 @@ import { Fraunces, Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google
9 14 import Link from "next/link";
10 15 import { Wordmark } from "@/components/wordmark";
11 16 import { LiveCrawl } from "@/components/live-crawl";
12 −import { ThemeToggle } from "@/components/theme-toggle";
17 +import { AuthButton } from "@/components/auth-button";
18 +import GroupeKaBadge from "@/ka/GroupeKaBadge";
19 +import KaFooter from "@/ka/KaFooter";
20 +import "@/ka/tokens.css";
13 21 import "./globals.css";
14 22
15 23 const spaceGrotesk = Space_Grotesk({
@@ -27,49 +35,43 @@ const fraunces = Fraunces({
27 35 variable: "--font-fraunces",
28 36 });
29 37
30 −/** Applique le thème avant le premier rendu (aucun flash clair/sombre). */
31 −const THEME_INIT = `(function(){try{var t=localStorage.getItem("tk-theme");if(!t)t=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";document.documentElement.dataset.theme=t;}catch(e){}})();`;
32 −
33 38 export const metadata: Metadata = {
34 39 metadataBase: new URL(process.env.PUBLIC_URL ?? "https://www.trouve-ka.com"),
35 40 title: {
36 − default: "Trouve-KA — Cherche le Québec",
41 + default: "Trouve-Ka — Un service Groupe KA",
37 42 template: "%s — Trouve-KA",
38 43 },
39 44 description:
40 − "Moteur de recherche indépendant du web québécois. Trouve-KA découvre, indexe et classe les sites du Québec — en continu.",
45 + "Moteur de recherche indépendant du web québécois. Trouve-KA découvre, indexe et classe les sites du Québec — en continu. Un service Groupe KA.",
41 46 };
42 47
43 48 export const viewport: Viewport = {
44 − themeColor: "#0b1330",
49 + themeColor: "#f5f3ee",
45 50 width: "device-width",
46 51 initialScale: 1,
47 52 };
48 53
49 −const FOOTER_LINKS = [
50 − { href: "/trouveka-bot", label: "À propos du robot" },
54 +const NAV_LINKS = [
51 55 { href: "/status", label: "État du moteur" },
52 56 { href: "/soumettre", label: "Soumettre un site" },
57 + { href: "/contact", label: "Contact" },
53 58 ] as const;
54 59
55 −const SISTER_SITES = [
56 − ["Groupe·KA", "https://www.groupe-ka.com"],
57 − ["Lou·Ka", "https://www.lou-ka.com"],
58 − ["Immo·Ka", "https://www.immo-ka.com"],
59 − ["Auto·Ka", "https://www.auto-ka.com"],
60 − ["Fabri·Ka", "https://www.fabri-ka.com"],
61 − ["Food·Ka", "https://www.food-ka.com"],
62 −] as const;
60 +const LOCAL_FOOTER_LINKS = [
61 + { href: "/trouveka-bot", label: "À propos du robot" },
62 + { href: "/status", label: "État du moteur" },
63 + { href: "/soumettre", label: "Soumettre un site" },
64 + { href: "/contact", label: "Contact" },
65 +];
63 66
64 67 export default function RootLayout({
65 68 children,
66 69 }: Readonly<{ children: React.ReactNode }>) {
67 70 return (
68 − <html lang="fr" suppressHydrationWarning>
71 + <html lang="fr">
69 72 <body
70 73 className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrains.variable} ${fraunces.variable} flex min-h-screen flex-col bg-paper font-body text-ink antialiased`}
71 74 >
72 − <script dangerouslySetInnerHTML={{ __html: THEME_INIT }} />
73 75 <a
74 76 href="#contenu"
75 77 className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-ctl focus:border-[1.5px] focus:border-ink focus:bg-ink focus:px-4 focus:py-2 focus:font-display focus:text-sm focus:font-bold focus:text-lime"
@@ -77,99 +79,41 @@ export default function RootLayout({
77 79 Aller au contenu principal
78 80 </a>
79 81
80 − <header className="sticky top-0 z-40 border-b border-line bg-paper md:bg-[var(--paper-glass)] md:backdrop-blur-xl">
81 − <div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-4 sm:px-6">
82 − <Link href="/" className="group rounded-sm" aria-label="Trouve-KA — accueil">
83 − <Wordmark />
84 − </Link>
82 + <header className="sticky top-0 z-40 border-b-[1.5px] border-[var(--line-strong)] bg-paper md:bg-[var(--paper-glass)] md:backdrop-blur-xl">
83 + <div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-3 px-4 sm:px-6">
84 + <div className="flex min-w-0 items-center gap-3">
85 + <Link href="/" className="group rounded-sm" aria-label="Trouve-KA — accueil">
86 + <Wordmark />
87 + </Link>
88 + <span className="hidden sm:inline-flex">
89 + <GroupeKaBadge />
90 + </span>
91 + </div>
85 92 <nav aria-label="Navigation principale">
86 93 <ul className="flex items-center gap-1">
87 − <li>
88 − <Link
89 − href="/status"
90 − className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-3 py-[6px] text-[13px] font-bold text-ink-2 transition-colors hover:border-ink hover:text-ink"
91 − >
92 − État du moteur
93 − </Link>
94 − </li>
95 − <li>
96 − <Link
97 − href="/soumettre"
98 − className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-3 py-[6px] text-[13px] font-bold text-ink-2 transition-colors hover:border-ink hover:text-ink"
99 − >
100 − Soumettre un site
101 − </Link>
102 − </li>
103 − <li>
104 − <ThemeToggle />
105 − </li>
106 − </ul>
107 − </nav>
108 − </div>
109 − </header>
110 −
111 − <div className="flex flex-1 flex-col">{children}</div>
112 −
113 − <footer className="border-t border-[#0b1330] bg-[#0b1330] py-9 text-[13px] text-[rgba(237,240,247,0.75)]">
114 − <div className="mx-auto max-w-6xl px-4 sm:px-6">
115 − <div className="flex flex-wrap items-baseline justify-between gap-x-8 gap-y-3">
116 − <Wordmark variant="footer" />
117 − <p className="gk-display text-[15px] font-bold text-[rgba(237,240,247,0.85)]">
118 − Un moteur de recherche par{" "}
119 − <a
120 − href="https://www.groupe-ka.com"
121 − target="_blank"
122 − rel="noopener noreferrer"
123 − className="whitespace-nowrap underline-offset-4 hover:underline hover:decoration-lime"
124 − >
125 − <span className="text-white">Groupe</span>{" "}
126 − <span className="text-lime">KA</span>
127 − </a>
128 − </p>
129 − </div>
130 − <p className="gk-display mt-3 text-[15px] font-bold text-lime">
131 − Cherche le Québec.
132 − </p>
133 − <LiveCrawl />
134 − <nav aria-label="Liens de pied de page" className="mt-5">
135 − <ul className="flex flex-wrap gap-x-6 gap-y-2">
136 − {FOOTER_LINKS.map((link) => (
137 − <li key={link.href}>
94 + {NAV_LINKS.map((link) => (
95 + <li key={link.href} className="hidden md:block">
138 96 <Link
139 97 href={link.href}
140 − className="rounded-sm text-[rgba(237,240,247,0.75)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime"
98 + className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-3 py-[6px] text-[13px] font-bold text-ink-2 transition-colors hover:border-ink hover:text-ink"
141 99 >
142 100 {link.label}
143 101 </Link>
144 102 </li>
145 103 ))}
104 + <li>
105 + <AuthButton />
106 + </li>
146 107 </ul>
147 108 </nav>
148 − <ul className="gk-mono mt-6 flex flex-wrap gap-x-6 gap-y-2 text-[11px] font-bold uppercase tracking-[0.1em]">
149 − {SISTER_SITES.map(([label, href]) => (
150 − <li key={label}>
151 − <a
152 − href={href}
153 − target="_blank"
154 − rel="noopener noreferrer"
155 − className="text-[rgba(237,240,247,0.6)] underline-offset-4 transition-colors hover:text-lime hover:underline hover:decoration-lime"
156 − >
157 − {label}
158 − </a>
159 − </li>
160 − ))}
161 − </ul>
162 − <p className="gk-mono mt-7 border-t border-[rgba(237,240,247,0.15)] pt-5 text-[11px] text-[rgba(237,240,247,0.45)]">
163 − © 2026 Trouve-KA — Simon-Pierre Boucher ·{" "}
164 − <a
165 − href="mailto:contact@spboucher.ai"
166 − className="underline-offset-4 hover:text-lime hover:underline"
167 − >
168 − contact@spboucher.ai
169 − </a>
170 − </p>
171 109 </div>
172 − </footer>
110 + </header>
111 +
112 + <div className="flex flex-1 flex-col">{children}</div>
113 +
114 + <KaFooter siteId="trouve-ka" localLegal={LOCAL_FOOTER_LINKS}>
115 + <LiveCrawl />
116 + </KaFooter>
173 117 </body>
174 118 </html>
175 119 );
added apps/web/components/auth-button.tsx +85 −0
@@ -0,0 +1,85 @@
1 +/**
2 + * Trouve-KA — bouton compte KA ID (header)
3 + * Author: Simon-Pierre Boucher
4 + * Contact: contact@spboucher.ai
5 + *
6 + * « Se connecter avec KA » : délègue l'identité au hub groupe-ka.com via
7 + * /api/auth/ka/login (SSO KA ID). Une fois connecté : nom + déconnexion.
8 + * La création de compte se fait TOUJOURS sur le hub (groupe-ka.com/connexion).
9 + */
10 +
11 +"use client";
12 +
13 +import { useEffect, useState } from "react";
14 +import { usePathname } from "next/navigation";
15 +
16 +type KaUser = {
17 + ka_id: string;
18 + email: string;
19 + name: string;
20 + picture: string;
21 +};
22 +
23 +export function AuthButton() {
24 + const [user, setUser] = useState<KaUser | null>(null);
25 + const [ready, setReady] = useState(false);
26 + const pathname = usePathname();
27 +
28 + useEffect(() => {
29 + let active = true;
30 + fetch("/api/auth/me", { cache: "no-store" })
31 + .then((r) => (r.ok ? r.json() : { user: null }))
32 + .then((d: { user: KaUser | null }) => {
33 + if (active) setUser(d.user);
34 + })
35 + .catch(() => {})
36 + .finally(() => {
37 + if (active) setReady(true);
38 + });
39 + return () => {
40 + active = false;
41 + };
42 + }, []);
43 +
44 + const logout = async () => {
45 + try {
46 + await fetch("/api/auth/logout", { method: "POST" });
47 + } catch {
48 + /* réseau indisponible : on recharge quand même */
49 + }
50 + location.reload();
51 + };
52 +
53 + if (!ready) {
54 + return <span className="inline-block h-[44px] w-[150px]" aria-hidden="true" />;
55 + }
56 +
57 + if (user) {
58 + return (
59 + <span className="flex items-center gap-2">
60 + <span
61 + className="gk-display hidden max-w-[140px] truncate text-[13px] font-bold text-ink sm:inline"
62 + title={user.email || user.ka_id}
63 + >
64 + {user.name || "Membre KA"}
65 + </span>
66 + <button
67 + type="button"
68 + onClick={logout}
69 + className="gk-display inline-flex min-h-[44px] items-center rounded-full border-[1.5px] border-ink bg-surface px-4 text-[13px] font-bold text-ink transition-colors hover:bg-[var(--accent-soft)]"
70 + >
71 + Se déconnecter
72 + </button>
73 + </span>
74 + );
75 + }
76 +
77 + return (
78 + <a
79 + href={`/api/auth/ka/login?next=${encodeURIComponent(pathname || "/")}`}
80 + className="gk-display inline-flex min-h-[44px] items-center whitespace-nowrap rounded-full border-[1.5px] border-ink bg-[var(--btn-primary-bg)] px-4 text-[13px] font-bold text-[var(--btn-primary-fg)] transition-colors hover:bg-[var(--btn-primary-hover)]"
81 + >
82 + Se connecter avec KA
83 + </a>
84 + );
85 +}
deleted apps/web/components/theme-toggle.tsx +0 −59
@@ -1,59 +0,0 @@
1 −/**
2 − * Trouve-KA — bascule clair/sombre (persistée, défaut système)
3 − * Author: Simon-Pierre Boucher
4 − * Contact: contact@spboucher.ai
5 − */
6 −
7 −"use client";
8 −
9 −import { useEffect, useState } from "react";
10 −
11 −type Theme = "light" | "dark";
12 −
13 −function applyTheme(theme: Theme) {
14 − document.documentElement.dataset.theme = theme;
15 − try {
16 − localStorage.setItem("tk-theme", theme);
17 − } catch {
18 − /* stockage indisponible (navigation privée) : le choix vaut pour la session */
19 − }
20 −}
21 −
22 −export function ThemeToggle() {
23 − // null jusqu'au montage : le SSR ne connaît pas le thème → pas d'hydration mismatch
24 − const [theme, setTheme] = useState<Theme | null>(null);
25 −
26 − useEffect(() => {
27 − setTheme(
28 − (document.documentElement.dataset.theme as Theme) === "dark"
29 − ? "dark"
30 − : "light",
31 − );
32 − }, []);
33 −
34 − const next: Theme = theme === "dark" ? "light" : "dark";
35 −
36 − return (
37 − <button
38 − type="button"
39 − aria-label={theme === "dark" ? "Passer au mode clair" : "Passer au mode sombre"}
40 − title={theme === "dark" ? "Mode clair" : "Mode sombre"}
41 − onClick={() => {
42 − setTheme(next);
43 − applyTheme(next);
44 − }}
45 − className="inline-flex h-8 w-8 items-center justify-center rounded-full border-[1.5px] border-transparent text-ink-2 transition-colors hover:border-ink hover:text-ink"
46 − >
47 − {theme === "dark" ? (
48 − <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="h-4 w-4">
49 − <circle cx="12" cy="12" r="4" />
50 − <path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
51 − </svg>
52 − ) : (
53 − <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
54 − <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
55 − </svg>
56 − )}
57 − </button>
58 − );
59 −}
added apps/web/ka/GroupeKaBadge.tsx +21 −0
@@ -0,0 +1,21 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/react/GroupeKaBadge.tsx — badge « Un service Groupe KA », cliquable
3 +// vers le hub. À placer dans le header de chaque site (à côté du logo de la
4 +// marque). Variante dark pour les surfaces encre. Zone de protection : le
5 +// padding interne du badge suffit ; ne rien coller à moins de 8 px du badge.
6 +export default function GroupeKaBadge({ dark = false }: { dark?: boolean }) {
7 + return (
8 + <a
9 + className={`gk-badge${dark ? " gk-badge--dark" : ""}`}
10 + href="https://www.groupe-ka.com"
11 + target="_blank"
12 + rel="noopener noreferrer"
13 + aria-label="Un service Groupe KA — visiter le portail de l'écosystème"
14 + >
15 + Un service
16 + <b>
17 + Groupe<span className="ka">KA</span>
18 + </b>
19 + </a>
20 + );
21 +}
added apps/web/ka/KaFooter.tsx +97 −0
@@ -0,0 +1,97 @@
1 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
2 +// ka-ui/react/KaFooter.tsx — footer commun Groupe KA (fond encre), vendoré ici
3 +// sous apps/web/ka/. Consomme ecosystem.json : toute modification des infos de
4 +// contact se fait dans ka-ui/ecosystem.json puis se resynchronise sur les 12
5 +// sites (voir ka-ui/README.md).
6 +// Extension locale trouve-ka : prop `children` (rendue après l'avertissement)
7 +// pour accueillir le flux temps réel du bot (<LiveCrawl />).
8 +import eco from "./ecosystem.json";
9 +
10 +type LocalLink = { label: string; href: string };
11 +
12 +export default function KaFooter({
13 + siteId,
14 + localLegal = [],
15 + children,
16 +}: {
17 + /** id du site courant dans ecosystem.json (ex. "trouve-ka") */
18 + siteId: string;
19 + /** pages légales PROPRES au site (ex. /conditions), affichées avant celles du hub */
20 + localLegal?: LocalLink[];
21 + /** contenu propre au site, rendu après l'avertissement (ex. flux du bot) */
22 + children?: React.ReactNode;
23 +}) {
24 + const year = new Date().getFullYear();
25 + const site = eco.sites.find((s) => s.id === siteId);
26 + const others = eco.sites.filter((s) => s.id !== siteId);
27 + return (
28 + <footer className="ka-footer" id="contact">
29 + <div className="container">
30 + <a
31 + className="wordmark"
32 + href={eco.hub.url}
33 + target={siteId === "groupe-ka" ? undefined : "_blank"}
34 + rel="noopener noreferrer"
35 + aria-label="Groupe KA — le portail de l'écosystème"
36 + >
37 + Groupe <span className="ka">KA</span>
38 + </a>
39 + <p className="desc">
40 + {eco.org.tagline} Des centaines de connecteurs lisent les sites à la
41 + source, normalisent la donnée et se resynchronisent seuls.{" "}
42 + {site ? (
43 + <>
44 + <b style={{ color: "var(--paper)" }}>{site.wordmark}</b> —{" "}
45 + {site.tagline} — est un service Groupe KA.
46 + </>
47 + ) : null}
48 + </p>
49 + <p className="notice">
50 + <b>{eco.org.disclaimer}</b>
51 + </p>
52 + {children}
53 + <ul className="sites">
54 + {others.map((s) => (
55 + <li key={s.id}>
56 + <a href={`https://${s.domain}`} target="_blank" rel="noopener noreferrer">
57 + {s.wordmark}
58 + </a>
59 + </li>
60 + ))}
61 + {eco.extraFooterLinks.map((l) => (
62 + <li key={l.href}>
63 + <a href={l.href} target="_blank" rel="noopener noreferrer">
64 + {l.label}
65 + </a>
66 + </li>
67 + ))}
68 + </ul>
69 + <div className="contacts">
70 + {eco.contacts.map((c) => (
71 + <p key={c.email} style={{ margin: 0 }}>
72 + <a href={`mailto:${c.email}`}>{c.email}</a>
73 + <span>{c.role}</span>
74 + </p>
75 + ))}
76 + </div>
77 + <p className="legal">
78 + © {year} {eco.org.copyrightHolder}
79 + {localLegal.map((l) => (
80 + <span key={l.href}>
81 + {" · "}
82 + <a href={l.href}>{l.label}</a>
83 + </span>
84 + ))}
85 + {eco.legal.map((l) => (
86 + <span key={l.href}>
87 + {" · "}
88 + <a href={l.href} target="_blank" rel="noopener noreferrer">
89 + {l.label}
90 + </a>
91 + </span>
92 + ))}
93 + </p>
94 + </div>
95 + </footer>
96 + );
97 +}
added apps/web/ka/ecosystem.json +45 −0
@@ -0,0 +1,45 @@
1 +{
2 + "org": {
3 + "name": "Groupe KA",
4 + "legalName": "Groupe KA — Simon-Pierre Boucher",
5 + "tagline": "Holding québécois d'agrégateurs de produits et services entièrement automatisés.",
6 + "disclaimer": "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons rien et ne sommes partie à aucune transaction.",
7 + "copyrightHolder": "Groupe KA — Simon-Pierre Boucher"
8 + },
9 + "hub": {
10 + "url": "https://www.groupe-ka.com",
11 + "loginPath": "/connexion",
12 + "signupNote": "La création de compte KA ID se fait sur le hub groupe-ka.com ; chaque site délègue sa connexion via /api/auth/ka/login."
13 + },
14 + "contacts": [
15 + { "email": "contact@groupe-ka.com", "role": "Projets, partenariats & données" },
16 + { "email": "info@groupe-ka.com", "role": "Médias & questions générales" },
17 + { "email": "admin@groupe-ka.com", "role": "Légal, vie privée & Loi 25" }
18 + ],
19 + "legal": [
20 + { "label": "Conditions d'utilisation", "href": "https://www.groupe-ka.com/conditions" },
21 + { "label": "Politique de confidentialité", "href": "https://www.groupe-ka.com/confidentialite" },
22 + { "label": "Protection des renseignements personnels (Loi 25)", "href": "https://www.groupe-ka.com/loi-25" },
23 + { "label": "Transparence des robots d'indexation", "href": "https://www.groupe-ka.com/bots" }
24 + ],
25 + "sites": [
26 + { "id": "groupe-ka", "wordmark": "Groupe KA", "domain": "www.groupe-ka.com", "accent": "#d9f26b", "accentSoft": "#f0f9d2", "accentDeep": "#123f2e", "onAccent": "#141814", "tagline": "Le portail de l'écosystème ·Ka" },
27 + { "id": "trouve-ka", "wordmark": "Trouve·Ka", "domain": "www.trouve-ka.com", "accent": "#1c7ed6", "accentSoft": "#e7f2fd", "accentDeep": "#14508f", "onAccent": "#ffffff", "tagline": "Le moteur de recherche du web québécois" },
28 + { "id": "lou-ka", "wordmark": "Lou·Ka", "domain": "www.lou-ka.com", "accent": "#ff6a00", "accentSoft": "#fff1e6", "accentDeep": "#cc5500", "onAccent": "#ffffff", "tagline": "Tous les logements à louer" },
29 + { "id": "immo-ka", "wordmark": "Immo·Ka", "domain": "www.immo-ka.com", "accent": "#e23744", "accentSoft": "#fbe0e2", "accentDeep": "#a8232e", "onAccent": "#ffffff", "tagline": "Toutes les propriétés à vendre" },
30 + { "id": "vrai-prix", "wordmark": "Vrai-Prix", "domain": "www.vrai-prix.com", "accent": "#ff5148", "accentSoft": "#ffe3e0", "accentDeep": "#9e2a25", "onAccent": "#ffffff", "tagline": "La valeur réelle de chaque propriété" },
31 + { "id": "auto-ka", "wordmark": "Auto·Ka", "domain": "www.auto-ka.com", "accent": "#ff5a2a", "accentSoft": "#ffe8de", "accentDeep": "#cc3f16", "onAccent": "#ffffff", "tagline": "Les voitures usagées du Québec" },
32 + { "id": "fabri-ka", "wordmark": "Fabri·Ka", "domain": "www.fabri-ka.com", "accent": "#c4532e", "accentSoft": "#f7e3da", "accentDeep": "#a94525", "onAccent": "#ffffff", "tagline": "Les produits fabriqués au Québec" },
33 + { "id": "food-ka", "wordmark": "Food·Ka", "domain": "www.food-ka.com", "accent": "#1f9d55", "accentSoft": "#e2f5ea", "accentDeep": "#157a40", "onAccent": "#ffffff", "tagline": "Les prix d'épicerie, suivis à la source" },
34 + { "id": "resto-ka", "wordmark": "Resto·Ka", "domain": "www.resto-ka.com", "accent": "#f08c00", "accentSoft": "#fdeed7", "accentDeep": "#b96a00", "onAccent": "#141814", "tagline": "Chaque resto, chaque plat, chaque prix" },
35 + { "id": "sorti-ka", "wordmark": "Sorti·Ka", "domain": "www.sorti-ka.com", "accent": "#d6336c", "accentSoft": "#fbe0eb", "accentDeep": "#a12551", "onAccent": "#ffffff", "tagline": "Toutes les sorties, dans les 17 régions" },
36 + { "id": "crea-ka", "wordmark": "Créa·Ka", "domain": "www.crea-ka.com", "accent": "#7048e8", "accentSoft": "#ece5fc", "accentDeep": "#5433b8", "onAccent": "#ffffff", "tagline": "Les créateurs d'ici, tous leurs liens" },
37 + { "id": "api-ka", "wordmark": "API·Ka", "domain": "www.api-ka.com", "accent": "#3b5bdb", "accentSoft": "#e4eafb", "accentDeep": "#2b44a8", "onAccent": "#ffffff", "tagline": "La donnée de l'écosystème, par API" }
38 + ],
39 + "extraFooterLinks": [
40 + { "label": "ValoPlex", "href": "https://www.valoplex.com" },
41 + { "label": "Ka2", "href": "https://www.ka2.bot" },
42 + { "label": "Ka4", "href": "https://www.ka4.bot" },
43 + { "label": "Ka6", "href": "https://www.ka6.bot" }
44 + ]
45 +}
added apps/web/ka/tokens.css +229 −0
@@ -0,0 +1,229 @@
1 +/* -----------------------------------------------------------------------------
2 + Auteur : Simon-Pierre Boucher — contact@spboucher.ai
3 + Fichier : ka-ui/tokens.css — SOURCE CANONIQUE (repo ka-ui sur spbgit)
4 + Desc. : Design system commun GROUPE KA « éditorial sharp » — tokens + socle
5 + de composants partagés par les 12 plateformes. Chaque site importe ce
6 + fichier PUIS surcharge uniquement son accent (voir accents.css).
7 +
8 + · Typo : display Space Grotesk / texte Inter / micro-étiquettes JetBrains Mono
9 + · Palette: papier #f5f3ee · encre #141814 · vert profond #1c5c41 + ACCENT du site
10 + · Signature : bordures encre 1,5–2 px + ombres décalées dures, grain de film,
11 + surlignés d'accent inclinés (néo-brutalisme raffiné)
12 + · Breakpoints communs : 360 / 768 / 1024 / 1440 px (mobile-first)
13 + · Zones tactiles ≥ 44×44 px, safe-areas iOS, typographie fluide (clamp)
14 +----------------------------------------------------------------------------- */
15 +
16 +:root {
17 + /* ---- Palette fixe Groupe KA (identique sur les 12 sites) ---- */
18 + --paper: #f5f3ee;
19 + --surface: #ffffff;
20 + --surface-2: #faf9f5;
21 + --ink: #141814;
22 + --ink-2: #4d5551;
23 + --ink-3: #8b928c;
24 + --line: rgba(20, 24, 20, 0.14);
25 + --line-strong: rgba(20, 24, 20, 0.85);
26 + --green: #1c5c41;
27 + --green-deep: #123f2e;
28 + --amber: #e8a33d;
29 + --amber-soft: #fdf3e2;
30 + --danger: #b3423a;
31 + --danger-soft: #fbe9e7;
32 +
33 + /* ---- ACCENT du site — SEULES variables surchargées par marque ---- */
34 + --accent: #d9f26b; /* défaut : lime Groupe KA */
35 + --accent-soft: #f0f9d2;
36 + --accent-deep: #123f2e;
37 + --on-accent: var(--ink); /* couleur du texte posé SUR l'accent */
38 +
39 + /* alias rétro-compatibles (les satellites historiques utilisent --lime) */
40 + --lime: var(--accent);
41 + --lime-soft: var(--accent-soft);
42 +
43 + /* ---- Géométrie sharp ---- */
44 + --r-card: 10px;
45 + --r-ctl: 6px;
46 + --r-pill: 999px;
47 +
48 + /* ---- Ombres décalées — la signature du groupe ---- */
49 + --shadow-flat: 0 1px 2px rgba(20, 24, 20, 0.05);
50 + --shadow-off: 6px 6px 0 var(--ink);
51 + --shadow-off-soft: 8px 8px 0 rgba(20, 24, 20, 0.08);
52 + --shadow-off-mid: 4px 4px 0 rgba(20, 24, 20, 0.18);
53 +
54 + /* ---- Typographie ---- */
55 + --font-display: "Space Grotesk", system-ui, sans-serif;
56 + --font-body: "Inter", system-ui, sans-serif;
57 + --font-mono: "JetBrains Mono", ui-monospace, monospace;
58 +
59 + /* Échelle fluide (mobile-first, clamp) */
60 + --fs-h1: clamp(30px, 3.2vw + 18px, 54px);
61 + --fs-h2: clamp(23px, 1.8vw + 14px, 34px);
62 + --fs-h3: clamp(17px, 0.9vw + 12px, 22px);
63 + --fs-body: 15px;
64 + --fs-small: 13px;
65 +
66 + /* Espacements */
67 + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
68 + --sp-5: 24px; --sp-6: 32px; --sp-7: 48px; --sp-8: 64px;
69 +
70 + /* Cible tactile minimale */
71 + --touch: 44px;
72 +}
73 +
74 +/* ---- Socle ---- */
75 +* { box-sizing: border-box; }
76 +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; }
77 +body {
78 + margin: 0;
79 + background: var(--paper);
80 + color: var(--ink);
81 + font-family: var(--font-body);
82 + font-size: var(--fs-body);
83 + line-height: 1.55;
84 + -webkit-font-smoothing: antialiased;
85 + padding-bottom: env(safe-area-inset-bottom);
86 + overflow-x: clip; /* jamais de débordement horizontal */
87 +}
88 +img, svg, video { max-width: 100%; height: auto; display: block; }
89 +h1, h2, h3, h4 { font-family: var(--font-display); letter-spacing: -0.03em; margin: 0 0 0.5em; }
90 +h1 { font-size: var(--fs-h1); line-height: 1.06; }
91 +h2 { font-size: var(--fs-h2); line-height: 1.15; }
92 +h3 { font-size: var(--fs-h3); line-height: 1.25; }
93 +a { color: inherit; }
94 +::selection { background: var(--accent); color: var(--on-accent); }
95 +:focus-visible { outline: 2px solid var(--green); outline-offset: 2px; }
96 +
97 +/* Grain de film pleine page — ⚠️ jamais de z-index sur body > * (position:
98 + relative seulement), sinon les utilitaires z-* sont écrasés. */
99 +body::before {
100 + content: "";
101 + position: fixed; inset: 0; pointer-events: none; opacity: 0.35; z-index: 9999;
102 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='0.06'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
103 +}
104 +body > * { position: relative; }
105 +
106 +/* ---- Conteneur commun ---- */
107 +.container { max-width: 1152px; margin: 0 auto; padding: 0 var(--sp-4); }
108 +@media (min-width: 768px) { .container { padding: 0 var(--sp-5); } }
109 +
110 +/* ---- Micro-typographie signature ---- */
111 +.kicker {
112 + display: inline-flex; align-items: center; gap: 10px;
113 + font-family: var(--font-mono); font-size: 11.5px; font-weight: 500;
114 + text-transform: uppercase; letter-spacing: 0.12em; color: var(--green);
115 +}
116 +.kicker::before { content: ""; width: 22px; height: 2px; background: var(--green); }
117 +.klabel {
118 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
119 + text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-3);
120 +}
121 +.hl {
122 + display: inline-block; background: var(--accent); color: var(--on-accent);
123 + border-radius: 8px; padding: 0 10px 2px; transform: rotate(-1deg);
124 +}
125 +.outline-txt { color: transparent; -webkit-text-stroke: 2px var(--ink); }
126 +
127 +/* ---- Boutons (cible ≥ 44 px) ---- */
128 +.btn {
129 + display: inline-flex; align-items: center; justify-content: center; gap: 8px;
130 + min-height: var(--touch); padding: 10px 18px;
131 + font-family: var(--font-display); font-weight: 700; font-size: 14px;
132 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
133 + background: var(--surface); color: var(--ink);
134 + cursor: pointer; text-decoration: none;
135 + transition: transform 0.15s, box-shadow 0.15s, background 0.15s, color 0.15s;
136 +}
137 +.btn:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off-mid); }
138 +.btn:active { transform: translate(0, 0); box-shadow: none; }
139 +.btn:disabled { opacity: 0.45; pointer-events: none; }
140 +.btn-primary { background: var(--ink); color: var(--accent); }
141 +.btn-primary:hover { background: var(--accent-deep); }
142 +.btn-accent { background: var(--accent); color: var(--on-accent); }
143 +.btn-ghost { background: transparent; border-color: var(--line); }
144 +.btn-ghost:hover { border-color: var(--ink); }
145 +
146 +/* ---- Cartes ---- */
147 +.card {
148 + background: var(--surface); border: 1.5px solid var(--ink);
149 + border-radius: var(--r-card); box-shadow: var(--shadow-off-soft);
150 + overflow: hidden;
151 +}
152 +.card-hover { transition: transform 0.15s, box-shadow 0.15s; }
153 +.card-hover:hover { transform: translate(-2px, -2px); box-shadow: var(--shadow-off); }
154 +
155 +/* ---- Chips / badges ---- */
156 +.chip {
157 + display: inline-flex; align-items: center; gap: 6px;
158 + padding: 4px 11px; font-family: var(--font-mono); font-size: 11px; font-weight: 700;
159 + text-transform: uppercase; letter-spacing: 0.06em;
160 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
161 + background: var(--surface); color: var(--ink);
162 +}
163 +.chip-accent { background: var(--accent); color: var(--on-accent); }
164 +.chip-soft { background: var(--accent-soft); border-color: var(--line); }
165 +
166 +/* ---- Champs ---- */
167 +.input, .select, .textarea {
168 + width: 100%; min-height: var(--touch); padding: 10px 14px;
169 + font: inherit; color: var(--ink); background: var(--surface);
170 + border: 1.5px solid var(--ink); border-radius: var(--r-ctl);
171 +}
172 +.input:focus, .select:focus, .textarea:focus {
173 + outline: none; box-shadow: 3px 3px 0 var(--accent); border-color: var(--ink);
174 +}
175 +.input::placeholder { color: var(--ink-3); }
176 +
177 +/* ---- Badge « Un service Groupe KA » (header, cliquable → hub) ---- */
178 +.gk-badge {
179 + display: inline-flex; align-items: center; gap: 7px;
180 + min-height: 30px; padding: 3px 10px 4px;
181 + font-family: var(--font-mono); font-size: 10px; font-weight: 700;
182 + letter-spacing: 0.08em; text-transform: uppercase; text-decoration: none;
183 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
184 + background: var(--surface); color: var(--ink-2); white-space: nowrap;
185 +}
186 +.gk-badge b {
187 + font-family: var(--font-display); font-size: 12px; letter-spacing: -0.02em;
188 + text-transform: none; color: var(--ink);
189 +}
190 +.gk-badge b .ka {
191 + display: inline-block; background: var(--ink); color: var(--accent);
192 + border-radius: 5px; padding: 0 5px 1px; margin-left: 3px; transform: rotate(-2deg);
193 +}
194 +.gk-badge:hover .ka { transform: rotate(0); }
195 +.gk-badge--dark { background: transparent; border-color: rgba(245,243,238,0.4); color: rgba(245,243,238,0.75); }
196 +.gk-badge--dark b { color: var(--paper); }
197 +
198 +/* ---- Footer commun (fond encre — voir react/KaFooter.tsx) ---- */
199 +.ka-footer { margin-top: var(--sp-8); background: var(--ink); padding: 44px 0; font-size: 13px; color: rgba(245,243,238,0.75); }
200 +.ka-footer a { text-decoration: none; }
201 +.ka-footer .wordmark { font-family: var(--font-display); font-weight: 700; font-size: 30px; letter-spacing: -0.04em; color: var(--paper); text-decoration: none; }
202 +.ka-footer .wordmark .ka { color: var(--accent); }
203 +.ka-footer .desc { max-width: 640px; margin-top: var(--sp-4); }
204 +.ka-footer .notice { max-width: 640px; margin-top: var(--sp-4); border-left: 2px solid var(--accent); padding-left: var(--sp-4); }
205 +.ka-footer .notice b { color: var(--paper); }
206 +.ka-footer .sites { list-style: none; display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 26px 0 0; padding: 0; font-family: var(--font-mono); font-size: 11px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
207 +.ka-footer .sites a { color: rgba(245,243,238,0.65); }
208 +.ka-footer .sites a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
209 +.ka-footer .contacts { display: grid; gap: 14px 32px; border-top: 1px solid rgba(245,243,238,0.15); margin-top: 30px; padding-top: 26px; }
210 +@media (min-width: 768px) { .ka-footer .contacts { grid-template-columns: repeat(3, 1fr); } }
211 +.ka-footer .contacts a { font-family: var(--font-mono); font-weight: 700; font-size: 12px; color: rgba(245,243,238,0.85); }
212 +.ka-footer .contacts a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
213 +.ka-footer .contacts span { display: block; margin-top: 3px; font-size: 11px; color: rgba(245,243,238,0.5); }
214 +.ka-footer .legal { margin-top: 30px; font-family: var(--font-mono); font-size: 11px; color: rgba(245,243,238,0.45); }
215 +.ka-footer .legal a { color: inherit; }
216 +.ka-footer .legal a:hover { color: var(--accent); text-decoration: underline; text-underline-offset: 4px; }
217 +
218 +/* ---- Tableaux → cartes empilées sous 768 px ---- */
219 +.tbl-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
220 +@media (max-width: 767px) {
221 + .tbl-stack thead { display: none; }
222 + .tbl-stack tr { display: block; border: 1.5px solid var(--ink); border-radius: var(--r-card); margin-bottom: var(--sp-3); background: var(--surface); }
223 + .tbl-stack td { display: flex; justify-content: space-between; gap: var(--sp-3); padding: 8px 12px; border: 0; }
224 + .tbl-stack td::before { content: attr(data-label); font-family: var(--font-mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-3); }
225 +}
226 +
227 +/* ---- Utilitaires responsive ---- */
228 +.only-mobile { display: initial; } .only-desktop { display: none; }
229 +@media (min-width: 768px) { .only-mobile { display: none; } .only-desktop { display: initial; } }
added apps/web/lib/ka-auth.ts +188 −0
@@ -0,0 +1,188 @@
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 » : l'utilisateur est envoyé au hub, qui le renvoie
5 +// ici avec un jeton JWT HS256 signé du secret partagé KA_SSO_SECRET et
6 +// 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 une clé LOCALE (KA_AUTH_SECRET, sinon dérivée de
10 +// KA_SSO_SECRET — jamais le secret partagé tel quel).
11 +// · ka_token du hub : JWT HS256 vérifié avec KA_SSO_SECRET
12 +// (signature, alg, iss, aud, exp).
13 +// Modèle de référence : vrai-prix/src/lib/ka-auth.ts.
14 +// -----------------------------------------------------------------------------
15 +import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
16 +
17 +export const SESSION_COOKIE = "tk_ka_session";
18 +export const SESSION_DAYS = 30;
19 +export const CLIENT_ID = "trouve-ka";
20 +
21 +export const KA_HUB_URL = (
22 + process.env.KA_HUB_URL || "https://www.groupe-ka.com"
23 +).replace(/\/+$/, "");
24 +
25 +/** URL publique de l'app (redirect_uri — le hub n'accepte que trouve-ka.com). */
26 +export const KA_BASE_URL = (
27 + process.env.KA_BASE_URL ||
28 + process.env.PUBLIC_URL ||
29 + "https://www.trouve-ka.com"
30 +).replace(/\/+$/, "");
31 +
32 +/** Session locale posée en cookie httpOnly après un retour SSO réussi. */
33 +export type KaSession = {
34 + ka_id: string;
35 + email: string;
36 + name: string;
37 + picture: string;
38 + provider?: string;
39 + exp: number; // epoch (secondes)
40 +};
41 +
42 +const now = () => Math.floor(Date.now() / 1000);
43 +
44 +/** Clé LOCALE de signature (state + session). Dérivée de KA_SSO_SECRET si
45 + * KA_AUTH_SECRET n'est pas défini — jamais le secret partagé directement. */
46 +function authSecret(): Buffer | null {
47 + const local = process.env.KA_AUTH_SECRET;
48 + if (local) return Buffer.from(local, "utf8");
49 + const shared = process.env.KA_SSO_SECRET;
50 + if (!shared) return null;
51 + return createHash("sha256").update(`trouve-ka-local|${shared}`).digest();
52 +}
53 +
54 +function ssoSecret(): Buffer | null {
55 + const s = process.env.KA_SSO_SECRET;
56 + return s ? Buffer.from(s, "utf8") : null;
57 +}
58 +
59 +/** Comparaison à temps constant (timingSafeEqual exige des longueurs égales). */
60 +function safeEqual(a: Buffer, b: Buffer): boolean {
61 + return a.length === b.length && timingSafeEqual(a, b);
62 +}
63 +
64 +// -- jeton local signé (state SSO + session) : base64url(JSON).hex(HMAC) ------
65 +
66 +export function signLocal(payload: Record<string, unknown>): string | null {
67 + const secret = authSecret();
68 + if (!secret) return null;
69 + const raw = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
70 + const sig = createHmac("sha256", secret).update(raw).digest("hex");
71 + return `${raw}.${sig}`;
72 +}
73 +
74 +export function verifyLocal(token: string): Record<string, unknown> | null {
75 + const secret = authSecret();
76 + if (!secret || !token) return null;
77 + try {
78 + const dot = token.lastIndexOf(".");
79 + if (dot < 0) return null;
80 + const raw = token.slice(0, dot);
81 + const sig = token.slice(dot + 1);
82 + const expected = createHmac("sha256", secret).update(raw).digest("hex");
83 + if (!safeEqual(Buffer.from(sig, "utf8"), Buffer.from(expected, "utf8"))) {
84 + return null;
85 + }
86 + const payload = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
87 + if (typeof payload !== "object" || payload === null) return null;
88 + if (typeof payload.exp !== "number" || payload.exp < now()) return null;
89 + return payload as Record<string, unknown>;
90 + } catch {
91 + return null;
92 + }
93 +}
94 +
95 +// -- state SSO (anti-forgerie, transporte aussi la destination `next`) --------
96 +
97 +/** State opaque signé localement — expire en 10 minutes. */
98 +export function newState(next: string): string | null {
99 + return signLocal({
100 + n: randomBytes(12).toString("base64url"),
101 + next,
102 + exp: now() + 600,
103 + });
104 +}
105 +
106 +/** Vérifie le state et rend la destination `next` (chemin local sûr). */
107 +export function verifyState(state: string): { next: string } | null {
108 + const p = verifyLocal(state);
109 + if (!p) return null;
110 + const next = typeof p.next === "string" ? p.next : "/";
111 + return { next: sanitizeNext(next) };
112 +}
113 +
114 +/** N'accepte qu'un chemin local (« /… », jamais « //… » ni une URL absolue). */
115 +export function sanitizeNext(next: string | null | undefined): string {
116 + if (!next || !next.startsWith("/") || next.startsWith("//")) return "/";
117 + return next;
118 +}
119 +
120 +// -- ka_token : JWT HS256 émis par le hub (vérification stdlib) ---------------
121 +
122 +export function verifyKaToken(token: string): Record<string, unknown> | null {
123 + const secret = ssoSecret();
124 + if (!secret || !token) return null;
125 + try {
126 + const parts = token.split(".");
127 + if (parts.length !== 3) return null;
128 + const [h64, p64, s64] = parts;
129 + const expected = createHmac("sha256", secret)
130 + .update(`${h64}.${p64}`)
131 + .digest();
132 + if (!safeEqual(Buffer.from(s64, "base64url"), expected)) return null;
133 +
134 + const header = JSON.parse(Buffer.from(h64, "base64url").toString("utf8"));
135 + if (header?.alg !== "HS256") return null;
136 +
137 + const claims = JSON.parse(Buffer.from(p64, "base64url").toString("utf8"));
138 + if (claims?.iss !== KA_HUB_URL) return null;
139 + const aud = claims?.aud;
140 + const audOk =
141 + aud === CLIENT_ID || (Array.isArray(aud) && aud.includes(CLIENT_ID));
142 + if (!audOk) return null;
143 + if (typeof claims?.exp !== "number" || claims.exp < now()) return null;
144 + return claims as Record<string, unknown>;
145 + } catch {
146 + return null;
147 + }
148 +}
149 +
150 +// -- session locale ------------------------------------------------------------
151 +
152 +/** Construit le cookie de session (30 jours) depuis les claims du hub. */
153 +export function sessionFromClaims(
154 + claims: Record<string, unknown>,
155 +): { value: string; session: KaSession } | null {
156 + const kaId = typeof claims.ka_id === "string" ? claims.ka_id.trim() : "";
157 + if (!kaId) return null; // le KA-ID est créé À LA SOURCE par le hub — requis
158 + const email =
159 + typeof claims.email === "string" ? claims.email.toLowerCase() : "";
160 + const session: KaSession = {
161 + ka_id: kaId,
162 + email,
163 + name:
164 + typeof claims.name === "string" && claims.name
165 + ? claims.name
166 + : email.split("@")[0] || "membre",
167 + picture: typeof claims.picture === "string" ? claims.picture : "",
168 + provider: typeof claims.provider === "string" ? claims.provider : undefined,
169 + exp: now() + SESSION_DAYS * 86400,
170 + };
171 + const value = signLocal({ ...session });
172 + return value ? { value, session } : null;
173 +}
174 +
175 +/** Lit et vérifie le cookie de session — null si absent/invalide/expiré. */
176 +export function readSession(cookieValue: string | undefined): KaSession | null {
177 + if (!cookieValue) return null;
178 + const p = verifyLocal(cookieValue);
179 + if (!p || typeof p.ka_id !== "string" || !p.ka_id) return null;
180 + return {
181 + ka_id: p.ka_id,
182 + email: typeof p.email === "string" ? p.email : "",
183 + name: typeof p.name === "string" ? p.name : "",
184 + picture: typeof p.picture === "string" ? p.picture : "",
185 + provider: typeof p.provider === "string" ? p.provider : undefined,
186 + exp: p.exp as number,
187 + };
188 +}
modified apps/web/next.config.mjs +32 −2
@@ -4,10 +4,40 @@
4 4 * Contact: contact@spboucher.ai
5 5 */
6 6
7 +import { readFileSync } from "node:fs";
8 +import { dirname, join } from "node:path";
9 +import { fileURLToPath } from "node:url";
10 +
11 +/**
12 + * Charge le .env RACINE du monorepo (~/trouve-ka/.env : KA_SSO_SECRET,
13 + * KA_HUB_URL, API_URL…) sans dépendance : pm2 lance `pnpm start` dans
14 + * apps/web et Next ne lit que les .env locaux — on complète process.env
15 + * sans jamais écraser une variable déjà définie.
16 + */
17 +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
18 +try {
19 + const raw = readFileSync(join(repoRoot, ".env"), "utf8");
20 + for (const line of raw.split("\n")) {
21 + const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
22 + if (!m) continue;
23 + let v = m[2].trim();
24 + if (
25 + (v.startsWith('"') && v.endsWith('"')) ||
26 + (v.startsWith("'") && v.endsWith("'"))
27 + ) {
28 + v = v.slice(1, -1);
29 + }
30 + if (process.env[m[1]] === undefined) process.env[m[1]] = v;
31 + }
32 +} catch {
33 + /* pas de .env racine (dev laptop) : les variables viennent de l'environnement */
34 +}
35 +
7 36 /**
8 37 * Le client fetch toujours des chemins relatifs `/api/...` : Next.js proxifie
9 − * vers le backend (API_URL). Ainsi tout fonctionne à l'identique en local et
10 − * derrière le tunnel ngrok (www.trouve-ka.com), sans URL absolues côté client.
38 + * vers le backend (API_URL). Les routes /api/auth/* sont servies par Next
39 + * lui-même (SSO KA ID) — les routes du système de fichiers passent AVANT les
40 + * rewrites « afterFiles ». Tout le reste va au FastAPI.
11 41 */
12 42 const API_URL = process.env.API_URL || "http://localhost:8080";
13 43
14 44