SPB Git

spb/crea-ka

Public

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

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 (Aug 17, 2026) parent e09cc05 Browse files

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 from fastapi.responses import FileResponse 15 from fastapi.responses import FileResponse
16 from pydantic import BaseModel, Field 16 from pydantic import BaseModel, Field
17 17
18−from . import db, ethics 18+from . import auth, db, ethics
19 from .normalize import NICHES, PLATFORMS, REGIONS 19 from .normalize import NICHES, PLATFORMS, REGIONS
20 20
21 ROOT = Path(__file__).resolve().parent.parent 21 ROOT = Path(__file__).resolve().parent.parent
@@ -31,6 +31,9 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
31 31
32 _con = db.connect() 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 @app.get("/healthz") 38 @app.get("/healthz")
36 def healthz(): 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 .hbtn:active{transform:translate(2px,2px);box-shadow:none} 104 .hbtn:active{transform:translate(2px,2px);box-shadow:none}
105 .hbtn.primary{background:var(--ink);color:var(--lime)} 105 .hbtn.primary{background:var(--ink);color:var(--lime)}
106 .hbtn.primary:hover{background:var(--green-deep)} 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 /* ===== héros ===== */ 139 /* ===== héros ===== */
110 .hero{padding:54px 0 26px} 140 .hero{padding:54px 0 26px}
@@ -433,8 +463,26 @@ function header(){
433 <a class="hbtn" href="/stats" data-nav>Statistiques</a> 463 <a class="hbtn" href="/stats" data-nav>Statistiques</a>
434 <a class="hbtn" href="/retrait" data-nav>Retrait</a> 464 <a class="hbtn" href="/retrait" data-nav>Retrait</a>
435 <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a> 465 <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a>
466+ <span id="acct-slot"></span>
436 </div></header>`; 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 function tabbar(active){ 486 function tabbar(active){
439 const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`; 487 const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`;
440 return `<nav class="tabbar"> 488 return `<nav class="tabbar">
@@ -458,6 +506,7 @@ function footer(){
458 <a class="fam" href="https://www.food-ka.com" target="_blank" rel="noopener">Food-Ka</a> 506 <a class="fam" href="https://www.food-ka.com" target="_blank" rel="noopener">Food-Ka</a>
459 <a class="fam" href="https://www.fabri-ka.com" target="_blank" rel="noopener">Fabri-Ka</a></div></div> 507 <a class="fam" href="https://www.fabri-ka.com" target="_blank" rel="noopener">Fabri-Ka</a></div></div>
460 <div class="footer-links"> 508 <div class="footer-links">
509+ <a href="/compte" data-nav>Mon compte KA ID</a>
461 <a href="/stats" data-nav>Statistiques</a> 510 <a href="/stats" data-nav>Statistiques</a>
462 <a href="/retrait" data-nav>Demander un retrait</a> 511 <a href="/retrait" data-nav>Demander un retrait</a>
463 <a href="/api/stats" target="_blank" rel="noopener">API publique</a> 512 <a href="/api/stats" target="_blank" rel="noopener">API publique</a>
@@ -637,6 +686,62 @@ async function renderStats(){
637 bindNav();window.scrollTo(0,0); 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 /* ---------- retrait / opt-out ---------- */ 745 /* ---------- retrait / opt-out ---------- */
641 function renderOptout(){ 746 function renderOptout(){
642 document.title="Retrait (opt-out) — Créa-Ka"; 747 document.title="Retrait (opt-out) — Créa-Ka";
@@ -683,10 +788,13 @@ function bindNav(root){
683 function route(){ 788 function route(){
684 const p=location.pathname; 789 const p=location.pathname;
685 const m=p.match(/^\/createur\/([^/]+)$/); 790 const m=p.match(/^\/createur\/([^/]+)$/);
686− if(m)return renderCreator(decodeURIComponent(m[1])); 791+ let r;
687− if(p==="/retrait")return renderOptout(); 792+ if(m)r=renderCreator(decodeURIComponent(m[1]));
688− if(p==="/stats")return renderStats(); 793+ else if(p==="/retrait")r=renderOptout();
689− return renderHome(); 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 window.addEventListener("popstate",route); 799 window.addEventListener("popstate",route);
692 route(); 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 .hbtn:active{transform:translate(2px,2px);box-shadow:none} 104 .hbtn:active{transform:translate(2px,2px);box-shadow:none}
105 .hbtn.primary{background:var(--ink);color:var(--lime)} 105 .hbtn.primary{background:var(--ink);color:var(--lime)}
106 .hbtn.primary:hover{background:var(--green-deep)} 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 /* ===== héros ===== */ 139 /* ===== héros ===== */
110 .hero{padding:54px 0 26px} 140 .hero{padding:54px 0 26px}
@@ -433,8 +463,26 @@ function header(){
433 <a class="hbtn" href="/stats" data-nav>Statistiques</a> 463 <a class="hbtn" href="/stats" data-nav>Statistiques</a>
434 <a class="hbtn" href="/retrait" data-nav>Retrait</a> 464 <a class="hbtn" href="/retrait" data-nav>Retrait</a>
435 <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a> 465 <a class="hbtn primary" href="/#annuaire" data-nav>Explorer l'annuaire</a>
466+ <span id="acct-slot"></span>
436 </div></header>`; 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 function tabbar(active){ 486 function tabbar(active){
439 const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`; 487 const t=(href,label,svg,key)=>`<a href="${href}" data-nav class="${active===key?"active":""}">${svg}<span>${label}</span></a>`;
440 return `<nav class="tabbar"> 488 return `<nav class="tabbar">
@@ -458,6 +506,7 @@ function footer(){
458 <a class="fam" href="https://www.food-ka.com" target="_blank" rel="noopener">Food-Ka</a> 506 <a class="fam" href="https://www.food-ka.com" target="_blank" rel="noopener">Food-Ka</a>
459 <a class="fam" href="https://www.fabri-ka.com" target="_blank" rel="noopener">Fabri-Ka</a></div></div> 507 <a class="fam" href="https://www.fabri-ka.com" target="_blank" rel="noopener">Fabri-Ka</a></div></div>
460 <div class="footer-links"> 508 <div class="footer-links">
509+ <a href="/compte" data-nav>Mon compte KA ID</a>
461 <a href="/stats" data-nav>Statistiques</a> 510 <a href="/stats" data-nav>Statistiques</a>
462 <a href="/retrait" data-nav>Demander un retrait</a> 511 <a href="/retrait" data-nav>Demander un retrait</a>
463 <a href="/api/stats" target="_blank" rel="noopener">API publique</a> 512 <a href="/api/stats" target="_blank" rel="noopener">API publique</a>
@@ -637,6 +686,62 @@ async function renderStats(){
637 bindNav();window.scrollTo(0,0); 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 /* ---------- retrait / opt-out ---------- */ 745 /* ---------- retrait / opt-out ---------- */
641 function renderOptout(){ 746 function renderOptout(){
642 document.title="Retrait (opt-out) — Créa-Ka"; 747 document.title="Retrait (opt-out) — Créa-Ka";
@@ -683,10 +788,13 @@ function bindNav(root){
683 function route(){ 788 function route(){
684 const p=location.pathname; 789 const p=location.pathname;
685 const m=p.match(/^\/createur\/([^/]+)$/); 790 const m=p.match(/^\/createur\/([^/]+)$/);
686− if(m)return renderCreator(decodeURIComponent(m[1])); 791+ let r;
687− if(p==="/retrait")return renderOptout(); 792+ if(m)r=renderCreator(decodeURIComponent(m[1]));
688− if(p==="/stats")return renderStats(); 793+ else if(p==="/retrait")r=renderOptout();
689− return renderHome(); 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 window.addEventListener("popstate",route); 799 window.addEventListener("popstate",route);
692 route(); 800 route();
693 801
Menu

Theme