SPB Git forge

spb/crea-ka

Public

Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)

52commits 1branches 0releases
11.3 MBsize
maindefault branch
20 days agolast push
Python 73.6% HTML 13.2% TypeScript 6% JavaScript 4.5% CSS 1.7% Dockerfile 0.6%

feat(auth): connexion « Se connecter avec KA ID » via le hub groupe-ka.com

- creaka/auth.py : SSO délégué au hub (state signé, JWT HS256 vérifié en stdlib,
  session cookie creaka_session sans table users), /api/me enrichi par le
  profil hub (fetch signé HMAC, cache 60 s), logout, config
- frontend : bouton compte dans l'en-tête (visible mobile), page /compte
  avec carte de membre Groupe KA (gabarit encre + lime du hub)
- hub : client crea-ka enregistré (secret partagé KA_SSO_SECRET_CREA_KA)
- testé bout-en-bout en prod (login → callback → session → me → logout)

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

4 changed files +452 −11

added creaka/auth.py +222 −0
@@ -0,0 +1,222 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: creaka/auth.py
4 +# Desc: « Se connecter avec KA ID » — connexion déléguée au hub d'identité du
5 +# Groupe KA (groupe-ka.com), calquée sur louka/auth.py / vrai-prix.
6 +# Flux : GET /api/auth/ka/login → hub /sso/authorize (state signé)
7 +# GET /api/auth/ka/callback → vérifie le JWT HS256 du hub
8 +# (stdlib seulement), pose le cookie de session creaka_session
9 +# GET /api/me · POST /api/auth/logout · GET /api/auth/config
10 +# Session SANS stockage local (aucune table users) ; le hub est LA
11 +# source de vérité du profil (fetch signé HMAC, cache 60 s).
12 +# .env : KA_SSO_SECRET (partagé avec le hub), SESSION_SECRET,
13 +# KA_HUB_URL (optionnel), CREAKA_BASE_URL (prod/ngrok).
14 +# ==============================================================================
15 +from __future__ import annotations
16 +
17 +import base64
18 +import hashlib
19 +import hmac
20 +import json
21 +import os
22 +import secrets
23 +import time
24 +from urllib.parse import urlencode
25 +
26 +import requests
27 +from fastapi import APIRouter, HTTPException, Request
28 +from fastapi.responses import JSONResponse, RedirectResponse
29 +
30 +router = APIRouter(prefix="/api")
31 +
32 +CLIENT_ID = "crea-ka"
33 +COOKIE = "creaka_session"
34 +SESSION_DAYS = 30
35 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
36 +
37 +_profile_cache: dict[str, tuple[float, dict | None]] = {}
38 +_PROFILE_TTL = 60.0
39 +
40 +
41 +def _secret() -> bytes:
42 + s = os.environ.get("SESSION_SECRET")
43 + if not s:
44 + raise HTTPException(503, "SESSION_SECRET manquant (voir .env)")
45 + return s.encode()
46 +
47 +
48 +def _ka_secret() -> bytes | None:
49 + s = os.environ.get("KA_SSO_SECRET")
50 + return s.encode() if s else None
51 +
52 +
53 +def _base_url(request: Request) -> str:
54 + """URL publique de l'app — forcée par CREAKA_BASE_URL en prod (ngrok)."""
55 + return (os.environ.get("CREAKA_BASE_URL")
56 + or str(request.base_url)).rstrip("/")
57 +
58 +
59 +# -- jeton signé maison (payload JSON -> base64url.signature) -------------------
60 +
61 +def _sign(payload: dict) -> str:
62 + raw = base64.urlsafe_b64encode(
63 + json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")
64 + sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()
65 + return f"{raw}.{sig}"
66 +
67 +
68 +def _verify(token: str) -> dict | None:
69 + try:
70 + raw, sig = token.rsplit(".", 1)
71 + expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()
72 + if not hmac.compare_digest(sig, expected):
73 + return None
74 + payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
75 + if payload.get("exp", 0) < time.time():
76 + return None
77 + return payload
78 + except Exception:
79 + return None
80 +
81 +
82 +def current_user(request: Request) -> dict | None:
83 + """Payload de session ({ka_id, email, name, picture…}) ou None."""
84 + token = request.cookies.get(COOKIE)
85 + return _verify(token) if token else None
86 +
87 +
88 +# -- vérification du JWT HS256 émis par le hub (stdlib seulement) ---------------
89 +
90 +def _b64url(s: str) -> bytes:
91 + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
92 +
93 +
94 +def _verify_ka_token(token: str) -> dict | None:
95 + secret = _ka_secret()
96 + if not secret:
97 + return None
98 + try:
99 + h_b64, p_b64, s_b64 = token.split(".")
100 + expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),
101 + hashlib.sha256).digest()
102 + if not hmac.compare_digest(_b64url(s_b64), expected):
103 + return None
104 + if json.loads(_b64url(h_b64)).get("alg") != "HS256":
105 + return None
106 + claims = json.loads(_b64url(p_b64))
107 + if claims.get("iss") != KA_HUB_URL:
108 + return None
109 + aud = claims.get("aud")
110 + if (aud if isinstance(aud, str) else "") != CLIENT_ID \
111 + and CLIENT_ID not in (aud if isinstance(aud, list) else []):
112 + return None
113 + if claims.get("exp", 0) < time.time():
114 + return None
115 + return claims
116 + except Exception:
117 + return None
118 +
119 +
120 +# -- profil dicté par le hub (source de vérité, cache 60 s) ---------------------
121 +
122 +def fetch_hub_profile(ka_id: str) -> dict | None:
123 + """GET signé {hub}/api/sso/profile — sig = HMAC(secret, client.ka_id.ts)."""
124 + secret = _ka_secret()
125 + if not secret or not ka_id:
126 + return None
127 + now = time.time()
128 + hit = _profile_cache.get(ka_id)
129 + if hit and now - hit[0] < _PROFILE_TTL:
130 + return hit[1]
131 + ts = str(int(now))
132 + sig = hmac.new(secret, f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
133 + hashlib.sha256).hexdigest()
134 + profile = None
135 + try:
136 + resp = requests.get(f"{KA_HUB_URL}/api/sso/profile",
137 + params={"client_id": CLIENT_ID, "ka_id": ka_id,
138 + "ts": ts, "sig": sig}, timeout=8)
139 + if resp.status_code == 200:
140 + profile = resp.json()
141 + except Exception:
142 + pass
143 + _profile_cache[ka_id] = (now, profile) # cache positif ET négatif
144 + return profile
145 +
146 +
147 +# -- routes ---------------------------------------------------------------------
148 +
149 +@router.get("/auth/config")
150 +def auth_config():
151 + return {"ka": bool(_ka_secret())}
152 +
153 +
154 +@router.get("/auth/ka/login")
155 +def ka_login(request: Request):
156 + """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""
157 + if not _ka_secret():
158 + raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
159 + state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})
160 + params = {
161 + "client_id": CLIENT_ID,
162 + "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",
163 + "state": state,
164 + }
165 + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")
166 +
167 +
168 +@router.get("/auth/ka/callback")
169 +def ka_callback(request: Request, ka_token: str = "", state: str = ""):
170 + """Retour SSO : vérifie le jeton du hub, pose le cookie de session Créa-Ka.
171 +
172 + Aucune table users locale : le KA ID du hub EST l'identité (comme
173 + Vrai-Prix) — la session transporte les claims utiles, le profil riche
174 + est relu au hub à la demande.
175 + """
176 + if not ka_token or _verify(state) is None:
177 + raise HTTPException(400, "state invalide ou expiré")
178 + claims = _verify_ka_token(ka_token)
179 + if claims is None:
180 + raise HTTPException(401, "jeton KA invalide ou expiré")
181 + session = _sign({
182 + "ka_id": (claims.get("ka_id") or "").strip(),
183 + "sub": str(claims.get("sub") or ""),
184 + "email": (claims.get("email") or "").lower(),
185 + "name": claims.get("name") or "membre",
186 + "picture": claims.get("picture") or "",
187 + "role": claims.get("ka_role") or "",
188 + "exp": time.time() + SESSION_DAYS * 86400,
189 + })
190 + resp = RedirectResponse("/compte")
191 + resp.set_cookie(
192 + COOKIE, session,
193 + max_age=SESSION_DAYS * 86400,
194 + httponly=True,
195 + secure=_base_url(request).startswith("https"),
196 + samesite="lax",
197 + path="/",
198 + )
199 + return resp
200 +
201 +
202 +@router.get("/me")
203 +def me(request: Request):
204 + user = current_user(request)
205 + if user is None:
206 + raise HTTPException(401, "non connecté")
207 + out = {"provider": "ka-id", **{k: user.get(k) for k in
208 + ("ka_id", "email", "name", "picture", "role")}}
209 + profile = fetch_hub_profile(user.get("ka_id") or "")
210 + if profile: # le hub dicte le profil (nom/photo/rôle à jour, §hub-profil)
211 + out.update({k: profile.get(k) for k in
212 + ("name", "picture", "role", "role_label", "bio", "city",
213 + "job_title", "company", "website", "public_url")
214 + if profile.get(k) not in (None, "")})
215 + return out
216 +
217 +
218 +@router.post("/auth/logout")
219 +def logout():
220 + resp = JSONResponse({"ok": True})
221 + resp.delete_cookie(COOKIE, path="/")
222 + return resp
modified creaka/web.py +4 −1
@@ -15,7 +15,7 @@ from fastapi.middleware.gzip import GZipMiddleware
15 15 from fastapi.responses import FileResponse
16 16 from pydantic import BaseModel, Field
17 17
18 −from . import db, ethics
18 +from . import auth, db, ethics
19 19 from .normalize import NICHES, PLATFORMS, REGIONS
20 20
21 21 ROOT = Path(__file__).resolve().parent.parent
@@ -31,6 +31,9 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
31 31
32 32 _con = db.connect()
33 33
34 +# connexion « KA ID » (hub groupe-ka.com) — voir creaka/auth.py
35 +app.include_router(auth.router)
36 +
34 37
35 38 @app.get("/healthz")
36 39 def healthz():
modified frontend/dist/index.html +113 −5
@@ -104,7 +104,37 @@ a{color:inherit;text-decoration:none} button{font-family:inherit;cursor:pointer}
104 104 .hbtn:active{transform:translate(2px,2px);box-shadow:none}
105 105 .hbtn.primary{background:var(--ink);color:var(--lime)}
106 106 .hbtn.primary:hover{background:var(--green-deep)}
107 −@media(max-width:760px){.header .hbtn{display:none}}
107 +@media(max-width:760px){.header .hbtn:not(.acct){display:none}}
108 +/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */
109 +.hbtn.acct{padding:7px 14px}
110 +.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;
111 + border:1.5px solid var(--ink);margin-left:-4px}
112 +.hbtn.acct .ka-badge{background:var(--lime);border-radius:4px;padding:0 5px;
113 + font-family:var(--font-mono);font-size:10px;font-weight:700}
114 +
115 +/* ===== page compte (carte de membre Groupe KA) ===== */
116 +.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}
117 +.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;
118 + padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;
119 + position:relative;overflow:hidden}
120 +.membercard::after{content:"";position:absolute;right:-40px;top:-40px;width:160px;
121 + height:160px;border-radius:50%;background:rgba(201,163,255,.14)}
122 +.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;
123 + text-transform:uppercase;letter-spacing:.14em;color:var(--lime)}
124 +.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}
125 +.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--lime);
126 + background:var(--green-deep);overflow:hidden;flex:none;display:flex;
127 + align-items:center;justify-content:center;font-family:var(--font-display);
128 + font-weight:700;font-size:26px;color:var(--lime)}
129 +.mc-avatar img{width:100%;height:100%;object-fit:cover}
130 +.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}
131 +.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--lime);margin-top:2px;letter-spacing:.04em}
132 +.mc-role{display:inline-flex;background:var(--lime);color:var(--ink);border-radius:5px;
133 + padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;
134 + text-transform:uppercase;letter-spacing:.06em;margin-top:8px}
135 +.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}
136 +.mc-meta b{color:var(--paper);font-weight:600}
137 +.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}
108 138
109 139 /* ===== héros ===== */
110 140 .hero{padding:54px 0 26px}
@@ -433,8 +463,26 @@ function header(){
433 463 <a class="hbtn" href="/stats" data-nav>Statistiques</a>
434 464 <a class="hbtn" href="/retrait" data-nav>Retrait</a>
435 465 <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a>
466 + <span id="acct-slot"></span>
436 467 </div></header>`;
437 468 }
469 +/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */
470 +async function loadAccount(){
471 + const slot=$("#acct-slot");if(!slot)return;
472 + if(state.me===undefined){
473 + try{const r=await fetch("/api/me");state.me=r.ok?await r.json():null}
474 + catch(e){state.me=null}}
475 + const me=state.me;
476 + if(me){
477 + slot.innerHTML=`<a class="hbtn acct" href="/compte" data-nav>
478 + ${me.picture?`<img src="${esc(me.picture)}" alt="">`:""}${esc((me.name||"").split(" ")[0])}
479 + <span class="ka-badge">KA</span></a>`;
480 + }else{
481 + slot.innerHTML=`<a class="hbtn acct" href="/api/auth/ka/login"
482 + title="Se connecter avec KA ID">Se connecter <span class="ka-badge">KA</span></a>`;
483 + }
484 + bindNav(slot);
485 +}
438 486 function tabbar(active){
439 487 const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`;
440 488 return `<nav class="tabbar">
@@ -458,6 +506,7 @@ function footer(){
458 506 <a class="fam" href="https://www.food-ka.com" target="_blank" rel="noopener">Food-Ka</a>
459 507 <a class="fam" href="https://www.fabri-ka.com" target="_blank" rel="noopener">Fabri-Ka</a></div></div>
460 508 <div class="footer-links">
509 + <a href="/compte" data-nav>Mon compte KA ID</a>
461 510 <a href="/stats" data-nav>Statistiques</a>
462 511 <a href="/retrait" data-nav>Demander un retrait</a>
463 512 <a href="/api/stats" target="_blank" rel="noopener">API publique</a>
@@ -637,6 +686,62 @@ async function renderStats(){
637 686 bindNav();window.scrollTo(0,0);
638 687 }
639 688
689 +/* ---------- page compte (KA ID) ---------- */
690 +async function renderCompte(){
691 + document.title="Mon compte KA — Créa-Ka";
692 + app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("");bindNav();
693 + if(state.me===undefined){
694 + try{const r=await fetch("/api/me");state.me=r.ok?await r.json():null}
695 + catch(e){state.me=null}}
696 + const me=state.me;
697 + if(!me){
698 + app.innerHTML=header()+`
699 + <div class="container compte-page">
700 + <span class="kicker">KA ID — un seul compte pour tout le groupe</span>
701 + <h1 style="font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase">Connectez-vous avec votre <span class="hl">KA ID.</span></h1>
702 + <p style="color:var(--ink-2)">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et
703 + les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>
704 + <div class="compte-actions">
705 + <a class="btn btn-primary" href="/api/auth/ka/login">Se connecter avec KA ID</a>
706 + <a class="btn btn-ghost" href="https://www.groupe-ka.com" target="_blank" rel="noopener">Qu'est-ce que KA ID ?</a>
707 + </div>
708 + </div>`+footer()+tabbar("");
709 + bindNav();loadAccount();return;
710 + }
711 + const init=(me.name||"?").split(/\s+/).slice(0,2).map(w=>w[0]||"").join("").toUpperCase();
712 + app.innerHTML=header()+`
713 + <div class="container compte-page">
714 + <span class="kicker">Mon compte</span>
715 + <h1 style="font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase">Carte de <span class="hl">membre.</span></h1>
716 + <div class="membercard">
717 + <div class="mc-brand">Groupe KA · carte de membre</div>
718 + <div class="mc-row">
719 + <div class="mc-avatar">${me.picture?`<img src="${esc(me.picture)}" alt="">`:esc(init)}</div>
720 + <div><div class="mc-name">${esc(me.name||"Membre")}</div>
721 + <div class="mc-kaid">${esc(me.ka_id||"")}</div>
722 + ${me.role_label||me.role?`<span class="mc-role">${esc(me.role_label||me.role)}</span>`:""}</div>
723 + </div>
724 + <div class="mc-meta">
725 + ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:""}
726 + ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:""}
727 + ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(" · "))}</span>`:""}
728 + ${me.bio?`<span>${esc(me.bio)}</span>`:""}
729 + </div>
730 + </div>
731 + <div class="compte-actions">
732 + <a class="btn btn-ghost" href="https://www.groupe-ka.com/compte" target="_blank" rel="noopener">Modifier sur groupe-ka.com ↗</a>
733 + ${me.public_url?`<a class="btn btn-ghost" href="${esc(me.public_url)}" target="_blank" rel="noopener">Profil public ↗</a>`:""}
734 + <button class="btn btn-primary" id="logout">Se déconnecter</button>
735 + </div>
736 + <p style="font-size:12px;color:var(--ink-3);margin-top:18px">Le profil est dicté par le hub
737 + groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>
738 + </div>`+footer()+tabbar("");
739 + bindNav();loadAccount();
740 + $("#logout").addEventListener("click",async()=>{
741 + await fetch("/api/auth/logout",{method:"POST"}).catch(()=>{});
742 + state.me=null;history.pushState(null,"","/");route();});
743 +}
744 +
640 745 /* ---------- retrait / opt-out ---------- */
641 746 function renderOptout(){
642 747 document.title="Retrait (opt-out) — Créa-Ka";
@@ -683,10 +788,13 @@ function bindNav(root){
683 788 function route(){
684 789 const p=location.pathname;
685 790 const m=p.match(/^\/createur\/([^/]+)$/);
686 − if(m)return renderCreator(decodeURIComponent(m[1]));
687 − if(p==="/retrait")return renderOptout();
688 − if(p==="/stats")return renderStats();
689 − return renderHome();
791 + let r;
792 + if(m)r=renderCreator(decodeURIComponent(m[1]));
793 + else if(p==="/retrait")r=renderOptout();
794 + else if(p==="/stats")r=renderStats();
795 + else if(p==="/compte")r=renderCompte();
796 + else r=renderHome();
797 + Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});
690 798 }
691 799 window.addEventListener("popstate",route);
692 800 route();
modified frontend/src/index.template.html +113 −5
@@ -104,7 +104,37 @@ a{color:inherit;text-decoration:none} button{font-family:inherit;cursor:pointer}
104 104 .hbtn:active{transform:translate(2px,2px);box-shadow:none}
105 105 .hbtn.primary{background:var(--ink);color:var(--lime)}
106 106 .hbtn.primary:hover{background:var(--green-deep)}
107 −@media(max-width:760px){.header .hbtn{display:none}}
107 +@media(max-width:760px){.header .hbtn:not(.acct){display:none}}
108 +/* bouton / jeton de compte KA ID — toujours visible, même sur mobile */
109 +.hbtn.acct{padding:7px 14px}
110 +.hbtn.acct img{width:22px;height:22px;border-radius:50%;object-fit:cover;
111 + border:1.5px solid var(--ink);margin-left:-4px}
112 +.hbtn.acct .ka-badge{background:var(--lime);border-radius:4px;padding:0 5px;
113 + font-family:var(--font-mono);font-size:10px;font-weight:700}
114 +
115 +/* ===== page compte (carte de membre Groupe KA) ===== */
116 +.compte-page{max-width:640px;margin:0 auto;padding:44px 0 70px}
117 +.membercard{background:var(--ink);border:1.5px solid var(--ink);border-radius:14px;
118 + padding:28px;color:var(--paper);box-shadow:var(--shadow-off);margin-top:24px;
119 + position:relative;overflow:hidden}
120 +.membercard::after{content:"";position:absolute;right:-40px;top:-40px;width:160px;
121 + height:160px;border-radius:50%;background:rgba(201,163,255,.14)}
122 +.mc-brand{font-family:var(--font-mono);font-size:10px;font-weight:700;
123 + text-transform:uppercase;letter-spacing:.14em;color:var(--lime)}
124 +.mc-row{display:flex;gap:18px;align-items:center;margin-top:18px}
125 +.mc-avatar{width:72px;height:72px;border-radius:14px;border:2px solid var(--lime);
126 + background:var(--green-deep);overflow:hidden;flex:none;display:flex;
127 + align-items:center;justify-content:center;font-family:var(--font-display);
128 + font-weight:700;font-size:26px;color:var(--lime)}
129 +.mc-avatar img{width:100%;height:100%;object-fit:cover}
130 +.mc-name{font-family:var(--font-display);font-size:22px;font-weight:700;color:var(--paper)}
131 +.mc-kaid{font-family:var(--font-mono);font-size:12px;color:var(--lime);margin-top:2px;letter-spacing:.04em}
132 +.mc-role{display:inline-flex;background:var(--lime);color:var(--ink);border-radius:5px;
133 + padding:2px 10px;font-family:var(--font-mono);font-size:10px;font-weight:700;
134 + text-transform:uppercase;letter-spacing:.06em;margin-top:8px}
135 +.mc-meta{margin-top:20px;display:grid;gap:6px;font-size:12.5px;color:rgba(245,243,238,.75)}
136 +.mc-meta b{color:var(--paper);font-weight:600}
137 +.compte-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:24px}
108 138
109 139 /* ===== héros ===== */
110 140 .hero{padding:54px 0 26px}
@@ -433,8 +463,26 @@ function header(){
433 463 <a class="hbtn" href="/stats" data-nav>Statistiques</a>
434 464 <a class="hbtn" href="/retrait" data-nav>Retrait</a>
435 465 <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a>
466 + <span id="acct-slot"></span>
436 467 </div></header>`;
437 468 }
469 +/* jeton de compte KA ID dans l'en-tête (connecté → chip, sinon → bouton hub) */
470 +async function loadAccount(){
471 + const slot=$("#acct-slot");if(!slot)return;
472 + if(state.me===undefined){
473 + try{const r=await fetch("/api/me");state.me=r.ok?await r.json():null}
474 + catch(e){state.me=null}}
475 + const me=state.me;
476 + if(me){
477 + slot.innerHTML=`<a class="hbtn acct" href="/compte" data-nav>
478 + ${me.picture?`<img src="${esc(me.picture)}" alt="">`:""}${esc((me.name||"").split(" ")[0])}
479 + <span class="ka-badge">KA</span></a>`;
480 + }else{
481 + slot.innerHTML=`<a class="hbtn acct" href="/api/auth/ka/login"
482 + title="Se connecter avec KA ID">Se connecter <span class="ka-badge">KA</span></a>`;
483 + }
484 + bindNav(slot);
485 +}
438 486 function tabbar(active){
439 487 const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`;
440 488 return `<nav class="tabbar">
@@ -458,6 +506,7 @@ function footer(){
458 506 <a class="fam" href="https://www.food-ka.com" target="_blank" rel="noopener">Food-Ka</a>
459 507 <a class="fam" href="https://www.fabri-ka.com" target="_blank" rel="noopener">Fabri-Ka</a></div></div>
460 508 <div class="footer-links">
509 + <a href="/compte" data-nav>Mon compte KA ID</a>
461 510 <a href="/stats" data-nav>Statistiques</a>
462 511 <a href="/retrait" data-nav>Demander un retrait</a>
463 512 <a href="/api/stats" target="_blank" rel="noopener">API publique</a>
@@ -637,6 +686,62 @@ async function renderStats(){
637 686 bindNav();window.scrollTo(0,0);
638 687 }
639 688
689 +/* ---------- page compte (KA ID) ---------- */
690 +async function renderCompte(){
691 + document.title="Mon compte KA — Créa-Ka";
692 + app.innerHTML=header()+`<div class="container"><div class="spin"></div></div>`+footer()+tabbar("");bindNav();
693 + if(state.me===undefined){
694 + try{const r=await fetch("/api/me");state.me=r.ok?await r.json():null}
695 + catch(e){state.me=null}}
696 + const me=state.me;
697 + if(!me){
698 + app.innerHTML=header()+`
699 + <div class="container compte-page">
700 + <span class="kicker">KA ID — un seul compte pour tout le groupe</span>
701 + <h1 style="font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase">Connectez-vous avec votre <span class="hl">KA ID.</span></h1>
702 + <p style="color:var(--ink-2)">Le même compte que sur groupe-ka.com, Lou-Ka, Vrai-Prix et
703 + les autres plateformes du Groupe KA. Créez-le en une minute si vous n'en avez pas.</p>
704 + <div class="compte-actions">
705 + <a class="btn btn-primary" href="/api/auth/ka/login">Se connecter avec KA ID</a>
706 + <a class="btn btn-ghost" href="https://www.groupe-ka.com" target="_blank" rel="noopener">Qu'est-ce que KA ID ?</a>
707 + </div>
708 + </div>`+footer()+tabbar("");
709 + bindNav();loadAccount();return;
710 + }
711 + const init=(me.name||"?").split(/\s+/).slice(0,2).map(w=>w[0]||"").join("").toUpperCase();
712 + app.innerHTML=header()+`
713 + <div class="container compte-page">
714 + <span class="kicker">Mon compte</span>
715 + <h1 style="font-size:clamp(26px,4vw,34px);margin:12px 0;text-transform:uppercase">Carte de <span class="hl">membre.</span></h1>
716 + <div class="membercard">
717 + <div class="mc-brand">Groupe KA · carte de membre</div>
718 + <div class="mc-row">
719 + <div class="mc-avatar">${me.picture?`<img src="${esc(me.picture)}" alt="">`:esc(init)}</div>
720 + <div><div class="mc-name">${esc(me.name||"Membre")}</div>
721 + <div class="mc-kaid">${esc(me.ka_id||"")}</div>
722 + ${me.role_label||me.role?`<span class="mc-role">${esc(me.role_label||me.role)}</span>`:""}</div>
723 + </div>
724 + <div class="mc-meta">
725 + ${me.email?`<span><b>Courriel :</b> ${esc(me.email)}</span>`:""}
726 + ${me.city?`<span><b>Ville :</b> ${esc(me.city)}</span>`:""}
727 + ${me.job_title||me.company?`<span><b>Occupation :</b> ${esc([me.job_title,me.company].filter(Boolean).join(" · "))}</span>`:""}
728 + ${me.bio?`<span>${esc(me.bio)}</span>`:""}
729 + </div>
730 + </div>
731 + <div class="compte-actions">
732 + <a class="btn btn-ghost" href="https://www.groupe-ka.com/compte" target="_blank" rel="noopener">Modifier sur groupe-ka.com ↗</a>
733 + ${me.public_url?`<a class="btn btn-ghost" href="${esc(me.public_url)}" target="_blank" rel="noopener">Profil public ↗</a>`:""}
734 + <button class="btn btn-primary" id="logout">Se déconnecter</button>
735 + </div>
736 + <p style="font-size:12px;color:var(--ink-3);margin-top:18px">Le profil est dicté par le hub
737 + groupe-ka.com (source de vérité) ; Créa-Ka n'entrepose aucune donnée de compte.</p>
738 + </div>`+footer()+tabbar("");
739 + bindNav();loadAccount();
740 + $("#logout").addEventListener("click",async()=>{
741 + await fetch("/api/auth/logout",{method:"POST"}).catch(()=>{});
742 + state.me=null;history.pushState(null,"","/");route();});
743 +}
744 +
640 745 /* ---------- retrait / opt-out ---------- */
641 746 function renderOptout(){
642 747 document.title="Retrait (opt-out) — Créa-Ka";
@@ -683,10 +788,13 @@ function bindNav(root){
683 788 function route(){
684 789 const p=location.pathname;
685 790 const m=p.match(/^\/createur\/([^/]+)$/);
686 − if(m)return renderCreator(decodeURIComponent(m[1]));
687 − if(p==="/retrait")return renderOptout();
688 − if(p==="/stats")return renderStats();
689 − return renderHome();
791 + let r;
792 + if(m)r=renderCreator(decodeURIComponent(m[1]));
793 + else if(p==="/retrait")r=renderOptout();
794 + else if(p==="/stats")r=renderStats();
795 + else if(p==="/compte")r=renderCompte();
796 + else r=renderHome();
797 + Promise.resolve(r).then(()=>loadAccount()).catch(()=>{});
690 798 }
691 799 window.addEventListener("popstate",route);
692 800 route();
693 801